Compare commits

..

2 Commits

102 changed files with 450 additions and 12231 deletions

View File

@ -1,6 +1,6 @@
---
name: how-to-write-component
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around abstraction choices, props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
---
# How To Write A Component
@ -12,7 +12,6 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature.
- Prefer local code and purpose-named helpers over catch-all utility modules; inline cheap derived values when that is clearer.
- Use Tailwind CSS v4.1+ rules via the `tailwind-css-rules` skill. Prefer v4 utilities, `gap`, `text-size/line-height`, `min-h-dvh`, and avoid deprecated utilities and `@apply`.
## Ownership
@ -20,8 +19,6 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing.
- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children.
- Pass stable domain identity across boundaries; avoid forwarding derived presentation state when the receiver can derive it from its own data source. A component that owns a visual surface should also own the data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
- Loading states for visual surfaces should use skeleton placeholders scoped to the content that is actually loading, with shape, density, and dimensions close to the final UI. Avoid generic loading text or centered spinners for page sections, cards, lists, tables, forms, and drawers; reserve spinners for small inline busy indicators such as an in-progress status icon.
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow.
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
@ -32,9 +29,9 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially persistent IDs and route params. Normalize framework or route params at the boundary.
- Keep fallback and invariant checks at the lowest component that already handles that state; avoid defensive fallbacks that mask impossible states.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them.
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially IDs like `appInstanceId`. Normalize framework or route params at the boundary.
- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks.
## Queries And Mutations
@ -51,13 +48,12 @@ Use this as the decision guide for React/TypeScript component structure. Existin
## Component Boundaries
- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner.
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Do not expand a child component's responsibility just because its data is useful nearby; keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer.
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
- Avoid shallow wrappers, layout-only render-prop wrappers, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
## You Might Not Need An Effect

View File

@ -9,7 +9,6 @@ on:
- "release/e-*"
- "hotfix/**"
- "feat/hitl-backend"
- "4-27-app-deploy"
tags:
- "*"

View File

@ -16,7 +16,6 @@ api = ExternalApi(
inner_api_ns = Namespace("inner_api", description="Internal API operations", path="/")
from . import mail as _mail
from . import runtime_credentials as _runtime_credentials
from .app import dsl as _app_dsl
from .plugin import plugin as _plugin
from .workspace import workspace as _workspace
@ -27,7 +26,6 @@ __all__ = [
"_app_dsl",
"_mail",
"_plugin",
"_runtime_credentials",
"_workspace",
"api",
"bp",

View File

@ -1,129 +0,0 @@
"""Inner API endpoints for runtime credential resolution.
Called by Enterprise while resolving AppRunner runtime artifacts. The endpoint
returns decrypted model credentials for in-memory runtime use only.
"""
import json
import logging
from json import JSONDecodeError
from typing import Any
from flask_restx import Resource
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from controllers.common.schema import register_schema_model
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import enterprise_inner_api_only
from core.helper import encrypter
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
from extensions.ext_database import db
from models.provider import ProviderCredential
logger = logging.getLogger(__name__)
class InnerRuntimeModelCredentialResolveItem(BaseModel):
credential_id: str = Field(description="Provider credential id")
provider: str = Field(description="Runtime provider identifier, for example langgenius/openai/openai")
vendor: str | None = Field(default=None, description="Model vendor, for example openai")
plugin_unique_identifier: str | None = Field(default=None, description="Runtime plugin identifier")
class InnerRuntimeModelCredentialsResolvePayload(BaseModel):
tenant_id: str = Field(description="Workspace id")
credentials: list[InnerRuntimeModelCredentialResolveItem] = Field(default_factory=list)
register_schema_model(inner_api_ns, InnerRuntimeModelCredentialsResolvePayload)
@inner_api_ns.route("/enterprise/runtime/model-credentials:resolve")
class EnterpriseRuntimeModelCredentialsResolve(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_ns.doc(
"enterprise_runtime_model_credentials_resolve",
responses={
200: "Credentials resolved",
400: "Invalid request or credential config",
404: "Provider or credential not found",
},
)
@inner_api_ns.expect(inner_api_ns.models[InnerRuntimeModelCredentialsResolvePayload.__name__])
def post(self):
args = InnerRuntimeModelCredentialsResolvePayload.model_validate(inner_api_ns.payload or {})
if not args.credentials:
return {"model_credentials": []}, 200
provider_manager = create_plugin_provider_manager(tenant_id=args.tenant_id)
provider_configurations = provider_manager.get_configurations(args.tenant_id)
resolved: list[dict[str, Any]] = []
for item in args.credentials:
provider_configuration = provider_configurations.get(item.provider)
if provider_configuration is None:
return {"message": f"provider '{item.provider}' not found"}, 404
provider_schema = provider_configuration.provider.provider_credential_schema
secret_variables = provider_configuration.extract_secret_variables(
provider_schema.credential_form_schemas if provider_schema else []
)
with Session(db.engine) as session:
stmt = select(ProviderCredential).where(
ProviderCredential.id == item.credential_id,
ProviderCredential.tenant_id == args.tenant_id,
ProviderCredential.provider_name.in_(provider_configuration._get_provider_names()),
)
credential = session.execute(stmt).scalar_one_or_none()
if credential is None or not credential.encrypted_config:
return {"message": f"credential '{item.credential_id}' not found"}, 404
try:
values = json.loads(credential.encrypted_config)
except JSONDecodeError:
return {"message": f"credential '{item.credential_id}' has invalid config"}, 400
if not isinstance(values, dict):
return {"message": f"credential '{item.credential_id}' has invalid config"}, 400
for key in secret_variables:
value = values.get(key)
if value is None:
continue
try:
values[key] = encrypter.decrypt_token(tenant_id=args.tenant_id, token=value)
except Exception as exc:
logger.warning(
"failed to resolve runtime model credential",
extra={
"credential_id": item.credential_id,
"provider": item.provider,
"tenant_id": args.tenant_id,
"error": type(exc).__name__,
},
)
return {"message": f"credential '{item.credential_id}' decrypt failed"}, 400
resolved.append(
{
"credential_id": item.credential_id,
"provider": item.provider,
"vendor": item.vendor or _vendor_from_provider(item.provider),
"plugin_unique_identifier": item.plugin_unique_identifier,
"values": values,
}
)
return {"model_credentials": resolved}, 200
def _vendor_from_provider(provider: str) -> str:
provider = provider.strip("/")
if not provider:
return ""
return provider.rsplit("/", 1)[-1]

View File

@ -159,7 +159,6 @@ class PluginManagerModel(BaseModel):
class SystemFeatureModel(BaseModel):
app_dsl_version: str = ""
enable_app_deploy: bool = False
sso_enforced_for_signin: bool = False
sso_enforced_for_signin_protocol: str = ""
enable_marketplace: bool = False
@ -234,7 +233,6 @@ class FeatureService:
cls._fulfill_system_params_from_env(system_features)
if dify_config.ENTERPRISE_ENABLED:
system_features.enable_app_deploy = True
system_features.branding.enabled = True
system_features.webapp_auth.enabled = True
system_features.enable_change_email = False

View File

@ -291,7 +291,6 @@ class TestFeatureService:
assert isinstance(result, SystemFeatureModel)
# Verify enterprise features
assert result.enable_app_deploy is True
assert result.branding.enabled is True
assert result.webapp_auth.enabled is True
assert result.enable_change_email is False
@ -378,7 +377,6 @@ class TestFeatureService:
# Ensure that data required for frontend rendering remains accessible.
# Branding should match the mock data
assert result.enable_app_deploy is True
assert result.branding.enabled is True
assert result.branding.application_title == "Test Enterprise"
assert result.branding.login_page_logo == "https://example.com/logo.png"
@ -426,7 +424,6 @@ class TestFeatureService:
assert isinstance(result, SystemFeatureModel)
# Verify basic configuration
assert result.enable_app_deploy is False
assert result.branding.enabled is False
assert result.webapp_auth.enabled is False
assert result.enable_change_email is True
@ -628,7 +625,6 @@ class TestFeatureService:
assert isinstance(result, SystemFeatureModel)
# Verify enterprise features are disabled
assert result.enable_app_deploy is False
assert result.branding.enabled is False
assert result.webapp_auth.enabled is False
assert result.enable_change_email is True

View File

@ -1,105 +0,0 @@
"""Unit tests for runtime credential inner API."""
import inspect
from unittest.mock import MagicMock, patch
from flask import Flask
from controllers.inner_api.runtime_credentials import (
EnterpriseRuntimeModelCredentialsResolve,
InnerRuntimeModelCredentialsResolvePayload,
)
def test_runtime_model_credentials_payload_accepts_items():
payload = InnerRuntimeModelCredentialsResolvePayload.model_validate(
{
"tenant_id": "tenant-1",
"credentials": [
{
"credential_id": "credential-1",
"provider": "langgenius/openai/openai",
"vendor": "openai",
}
],
}
)
assert payload.tenant_id == "tenant-1"
assert payload.credentials[0].provider == "langgenius/openai/openai"
@patch("controllers.inner_api.runtime_credentials.encrypter.decrypt_token")
@patch("controllers.inner_api.runtime_credentials.db")
@patch("controllers.inner_api.runtime_credentials.Session")
@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager")
def test_runtime_model_credentials_resolve_returns_decrypted_values(
mock_provider_manager_factory,
mock_session_cls,
mock_db,
mock_decrypt_token,
app: Flask,
):
provider_configuration = MagicMock()
provider_configuration.provider.provider_credential_schema.credential_form_schemas = []
provider_configuration.extract_secret_variables.return_value = ["openai_api_key"]
provider_configuration._get_provider_names.return_value = ["langgenius/openai/openai", "openai"]
provider_configurations = MagicMock()
provider_configurations.get.return_value = provider_configuration
provider_manager = MagicMock()
provider_manager.get_configurations.return_value = provider_configurations
mock_provider_manager_factory.return_value = provider_manager
credential = MagicMock()
credential.encrypted_config = '{"openai_api_key":"encrypted","api_base":"https://api.openai.com/v1"}'
session = MagicMock()
session.__enter__.return_value = session
session.__exit__.return_value = False
session.execute.return_value.scalar_one_or_none.return_value = credential
mock_session_cls.return_value = session
mock_db.engine = MagicMock()
mock_decrypt_token.return_value = "sk-test"
handler = EnterpriseRuntimeModelCredentialsResolve()
unwrapped = inspect.unwrap(handler.post)
with app.test_request_context():
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
mock_ns.payload = {
"tenant_id": "tenant-1",
"credentials": [
{
"credential_id": "credential-1",
"provider": "langgenius/openai/openai",
"vendor": "openai",
}
],
}
body, status_code = unwrapped(handler)
assert status_code == 200
assert body["model_credentials"][0]["values"]["openai_api_key"] == "sk-test"
assert body["model_credentials"][0]["values"]["api_base"] == "https://api.openai.com/v1"
mock_decrypt_token.assert_called_once_with(tenant_id="tenant-1", token="encrypted")
@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager")
def test_runtime_model_credentials_resolve_rejects_unknown_provider(mock_provider_manager_factory, app: Flask):
provider_configurations = MagicMock()
provider_configurations.get.return_value = None
provider_manager = MagicMock()
provider_manager.get_configurations.return_value = provider_configurations
mock_provider_manager_factory.return_value = provider_manager
handler = EnterpriseRuntimeModelCredentialsResolve()
unwrapped = inspect.unwrap(handler.post)
with app.test_request_context():
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
mock_ns.payload = {
"tenant_id": "tenant-1",
"credentials": [{"credential_id": "credential-1", "provider": "missing"}],
}
body, status_code = unwrapped(handler)
assert status_code == 404
assert "provider" in body["message"]

View File

@ -34,8 +34,16 @@ if [[ -f "$EXCLUDES_FILE" ]]; then
fi
tmp_output="$(mktemp)"
pyrefly_command=(
uv run --directory api --dev pyrefly check
"${pyrefly_args[@]}"
)
if (( ${#target_paths[@]} > 0 )); then
pyrefly_command+=("${target_paths[@]}")
fi
set +e
uv run --directory api --dev pyrefly check "${pyrefly_args[@]}" "${target_paths[@]}" >"$tmp_output" 2>&1
"${pyrefly_command[@]}" >"$tmp_output" 2>&1
pyrefly_status=$?
set -e

View File

@ -4758,6 +4758,11 @@
"count": 1
}
},
"web/types/feature.ts": {
"erasable-syntax-only/enums": {
"count": 3
}
},
"web/types/lamejs.d.ts": {
"ts/no-explicit-any": {
"count": 3

View File

@ -8,14 +8,14 @@
Snapshot generated from `packages/contracts/generated/api/readiness.json` after running `pnpm -C packages/contracts gen-api-contract-from-openapi`.
Are we OpenAPI ready? **No.** Current generated API contracts are **16.7% ready**.
Are we OpenAPI ready? **No.** Current generated API contracts are **16.6% ready**.
| Surface | Ready | Not ready | Total | Ready % |
| --------- | ------: | --------: | ------: | --------: |
| console | 96 | 474 | 570 | 16.8% |
| console | 95 | 475 | 570 | 16.7% |
| service | 16 | 72 | 88 | 18.2% |
| web | 5 | 36 | 41 | 12.2% |
| **total** | **117** | **582** | **699** | **16.7%** |
| **total** | **116** | **583** | **699** | **16.6%** |
Readiness here means the generated contract operation is not marked with:

View File

@ -426,10 +426,16 @@ export const imports = {
/**
* Get workflow online users
*
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
*
* @deprecated
*/
export const post3 = oc
.route({
description: 'Get workflow online users',
deprecated: true,
description:
'Get workflow online users\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
inputStructure: 'detailed',
method: 'POST',
operationId: 'postAppsWorkflowsOnlineUsers',

View File

@ -1,7 +1,7 @@
{
"surfaces": {
"console": {
"notReady": 474,
"notReady": 475,
"total": 570
},
"service": {

View File

@ -4,66 +4,6 @@ import { oc } from '@orpc/contract'
import * as z from 'zod'
import {
zAccessSubjectServiceListAccessSubjectsQuery,
zAccessSubjectServiceListAccessSubjectsResponse,
zAppDeployAccessServiceCreateDeveloperApiKeyBody,
zAppDeployAccessServiceCreateDeveloperApiKeyPath,
zAppDeployAccessServiceCreateDeveloperApiKeyResponse,
zAppDeployAccessServiceDeleteDeveloperApiKeyPath,
zAppDeployAccessServiceDeleteDeveloperApiKeyResponse,
zAppDeployAccessServiceGetAppInstanceAccessPath,
zAppDeployAccessServiceGetAppInstanceAccessResponse,
zAppDeployAccessServiceRevealDeveloperApiKeyBody,
zAppDeployAccessServiceRevealDeveloperApiKeyPath,
zAppDeployAccessServiceRevealDeveloperApiKeyResponse,
zAppDeployAccessServiceUpdateAccessChannelsBody,
zAppDeployAccessServiceUpdateAccessChannelsPath,
zAppDeployAccessServiceUpdateAccessChannelsResponse,
zAppDeployAccessServiceUpdateDeveloperApiBody,
zAppDeployAccessServiceUpdateDeveloperApiPath,
zAppDeployAccessServiceUpdateDeveloperApiResponse,
zAppDeployAccessServiceUpdateEnvironmentAccessPolicyBody,
zAppDeployAccessServiceUpdateEnvironmentAccessPolicyPath,
zAppDeployAccessServiceUpdateEnvironmentAccessPolicyResponse,
zAppDeploymentServiceCancelDeploymentBody,
zAppDeploymentServiceCancelDeploymentPath,
zAppDeploymentServiceCancelDeploymentResponse,
zAppDeploymentServiceCreateDeploymentBody,
zAppDeploymentServiceCreateDeploymentPath,
zAppDeploymentServiceCreateDeploymentResponse,
zAppDeploymentServiceGetDeploymentPlanPath,
zAppDeploymentServiceGetDeploymentPlanResponse,
zAppDeploymentServiceListEnvironmentDeploymentsPath,
zAppDeploymentServiceListEnvironmentDeploymentsResponse,
zAppDeploymentServiceUndeployRuntimeInstanceBody,
zAppDeploymentServiceUndeployRuntimeInstancePath,
zAppDeploymentServiceUndeployRuntimeInstanceResponse,
zAppInstanceServiceCreateAppInstanceBody,
zAppInstanceServiceCreateAppInstanceResponse,
zAppInstanceServiceDeleteAppInstancePath,
zAppInstanceServiceDeleteAppInstanceResponse,
zAppInstanceServiceGetAppInstanceOverviewPath,
zAppInstanceServiceGetAppInstanceOverviewResponse,
zAppInstanceServiceGetAppInstancePath,
zAppInstanceServiceGetAppInstanceResponse,
zAppInstanceServiceGetAppInstanceSettingsPath,
zAppInstanceServiceGetAppInstanceSettingsResponse,
zAppInstanceServiceImportAppInstanceBody,
zAppInstanceServiceImportAppInstanceResponse,
zAppInstanceServiceListAppInstancesQuery,
zAppInstanceServiceListAppInstancesResponse,
zAppInstanceServiceUpdateAppInstanceBody,
zAppInstanceServiceUpdateAppInstancePath,
zAppInstanceServiceUpdateAppInstanceResponse,
zAppReleaseServiceCreateReleaseBody,
zAppReleaseServiceCreateReleasePath,
zAppReleaseServiceCreateReleaseResponse,
zAppReleaseServiceListReleasesPath,
zAppReleaseServiceListReleasesQuery,
zAppReleaseServiceListReleasesResponse,
zAppReleaseServicePreviewReleaseBody,
zAppReleaseServicePreviewReleasePath,
zAppReleaseServicePreviewReleaseResponse,
zConsoleSsoOAuth2LoginResponse,
zConsoleSsoOidcLoginResponse,
zConsoleSsoSamlLoginResponse,
@ -81,369 +21,6 @@ import {
zWebAppAuthUpdateWebAppWhitelistSubjectsResponse,
} from './zod.gen'
export const listAccessSubjects = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AccessSubjectService_ListAccessSubjects',
path: '/enterprise/access-subjects',
tags: ['AccessSubjectService'],
})
.input(z.object({ query: zAccessSubjectServiceListAccessSubjectsQuery.optional() }))
.output(zAccessSubjectServiceListAccessSubjectsResponse)
export const accessSubjectService = {
listAccessSubjects,
}
export const listAppInstances = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppInstanceService_ListAppInstances',
path: '/enterprise/app-instances',
tags: ['AppInstanceService'],
})
.input(z.object({ query: zAppInstanceServiceListAppInstancesQuery.optional() }))
.output(zAppInstanceServiceListAppInstancesResponse)
export const createAppInstance = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppInstanceService_CreateAppInstance',
path: '/enterprise/app-instances',
tags: ['AppInstanceService'],
})
.input(z.object({ body: zAppInstanceServiceCreateAppInstanceBody }))
.output(zAppInstanceServiceCreateAppInstanceResponse)
export const importAppInstance = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppInstanceService_ImportAppInstance',
path: '/enterprise/app-instances/import',
tags: ['AppInstanceService'],
})
.input(z.object({ body: zAppInstanceServiceImportAppInstanceBody }))
.output(zAppInstanceServiceImportAppInstanceResponse)
export const deleteAppInstance = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'AppInstanceService_DeleteAppInstance',
path: '/enterprise/app-instances/{appInstanceId}',
tags: ['AppInstanceService'],
})
.input(z.object({ params: zAppInstanceServiceDeleteAppInstancePath }))
.output(zAppInstanceServiceDeleteAppInstanceResponse)
export const getAppInstance = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppInstanceService_GetAppInstance',
path: '/enterprise/app-instances/{appInstanceId}',
tags: ['AppInstanceService'],
})
.input(z.object({ params: zAppInstanceServiceGetAppInstancePath }))
.output(zAppInstanceServiceGetAppInstanceResponse)
export const updateAppInstance = oc
.route({
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'AppInstanceService_UpdateAppInstance',
path: '/enterprise/app-instances/{appInstanceId}',
tags: ['AppInstanceService'],
})
.input(
z.object({
body: zAppInstanceServiceUpdateAppInstanceBody,
params: zAppInstanceServiceUpdateAppInstancePath,
}),
)
.output(zAppInstanceServiceUpdateAppInstanceResponse)
export const getAppInstanceOverview = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppInstanceService_GetAppInstanceOverview',
path: '/enterprise/app-instances/{appInstanceId}/overview',
tags: ['AppInstanceService'],
})
.input(z.object({ params: zAppInstanceServiceGetAppInstanceOverviewPath }))
.output(zAppInstanceServiceGetAppInstanceOverviewResponse)
export const getAppInstanceSettings = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppInstanceService_GetAppInstanceSettings',
path: '/enterprise/app-instances/{appInstanceId}/settings',
tags: ['AppInstanceService'],
})
.input(z.object({ params: zAppInstanceServiceGetAppInstanceSettingsPath }))
.output(zAppInstanceServiceGetAppInstanceSettingsResponse)
export const appInstanceService = {
listAppInstances,
createAppInstance,
importAppInstance,
deleteAppInstance,
getAppInstance,
updateAppInstance,
getAppInstanceOverview,
getAppInstanceSettings,
}
export const getAppInstanceAccess = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppDeployAccessService_GetAppInstanceAccess',
path: '/enterprise/app-instances/{appInstanceId}/access',
tags: ['AppDeployAccessService'],
})
.input(z.object({ params: zAppDeployAccessServiceGetAppInstanceAccessPath }))
.output(zAppDeployAccessServiceGetAppInstanceAccessResponse)
export const updateAccessChannels = oc
.route({
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'AppDeployAccessService_UpdateAccessChannels',
path: '/enterprise/app-instances/{appInstanceId}/access-channels',
tags: ['AppDeployAccessService'],
})
.input(
z.object({
body: zAppDeployAccessServiceUpdateAccessChannelsBody,
params: zAppDeployAccessServiceUpdateAccessChannelsPath,
}),
)
.output(zAppDeployAccessServiceUpdateAccessChannelsResponse)
export const updateDeveloperApi = oc
.route({
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'AppDeployAccessService_UpdateDeveloperApi',
path: '/enterprise/app-instances/{appInstanceId}/developer-api',
tags: ['AppDeployAccessService'],
})
.input(
z.object({
body: zAppDeployAccessServiceUpdateDeveloperApiBody,
params: zAppDeployAccessServiceUpdateDeveloperApiPath,
}),
)
.output(zAppDeployAccessServiceUpdateDeveloperApiResponse)
export const updateEnvironmentAccessPolicy = oc
.route({
inputStructure: 'detailed',
method: 'PUT',
operationId: 'AppDeployAccessService_UpdateEnvironmentAccessPolicy',
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/access-policy',
tags: ['AppDeployAccessService'],
})
.input(
z.object({
body: zAppDeployAccessServiceUpdateEnvironmentAccessPolicyBody,
params: zAppDeployAccessServiceUpdateEnvironmentAccessPolicyPath,
}),
)
.output(zAppDeployAccessServiceUpdateEnvironmentAccessPolicyResponse)
export const createDeveloperApiKey = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppDeployAccessService_CreateDeveloperApiKey',
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/api-keys',
tags: ['AppDeployAccessService'],
})
.input(
z.object({
body: zAppDeployAccessServiceCreateDeveloperApiKeyBody,
params: zAppDeployAccessServiceCreateDeveloperApiKeyPath,
}),
)
.output(zAppDeployAccessServiceCreateDeveloperApiKeyResponse)
export const deleteDeveloperApiKey = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'AppDeployAccessService_DeleteDeveloperApiKey',
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/api-keys/{apiKeyId}',
tags: ['AppDeployAccessService'],
})
.input(z.object({ params: zAppDeployAccessServiceDeleteDeveloperApiKeyPath }))
.output(zAppDeployAccessServiceDeleteDeveloperApiKeyResponse)
export const revealDeveloperApiKey = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppDeployAccessService_RevealDeveloperApiKey',
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/api-keys/{apiKeyId}/reveal',
tags: ['AppDeployAccessService'],
})
.input(
z.object({
body: zAppDeployAccessServiceRevealDeveloperApiKeyBody,
params: zAppDeployAccessServiceRevealDeveloperApiKeyPath,
}),
)
.output(zAppDeployAccessServiceRevealDeveloperApiKeyResponse)
export const appDeployAccessService = {
getAppInstanceAccess,
updateAccessChannels,
updateDeveloperApi,
updateEnvironmentAccessPolicy,
createDeveloperApiKey,
deleteDeveloperApiKey,
revealDeveloperApiKey,
}
export const listEnvironmentDeployments = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppDeploymentService_ListEnvironmentDeployments',
path: '/enterprise/app-instances/{appInstanceId}/environment-deployments',
tags: ['AppDeploymentService'],
})
.input(z.object({ params: zAppDeploymentServiceListEnvironmentDeploymentsPath }))
.output(zAppDeploymentServiceListEnvironmentDeploymentsResponse)
export const createDeployment = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppDeploymentService_CreateDeployment',
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/deployments',
tags: ['AppDeploymentService'],
})
.input(
z.object({
body: zAppDeploymentServiceCreateDeploymentBody,
params: zAppDeploymentServiceCreateDeploymentPath,
}),
)
.output(zAppDeploymentServiceCreateDeploymentResponse)
export const cancelDeployment = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppDeploymentService_CancelDeployment',
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/deployments/{deploymentId}/cancel',
tags: ['AppDeploymentService'],
})
.input(
z.object({
body: zAppDeploymentServiceCancelDeploymentBody,
params: zAppDeploymentServiceCancelDeploymentPath,
}),
)
.output(zAppDeploymentServiceCancelDeploymentResponse)
export const undeployRuntimeInstance = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppDeploymentService_UndeployRuntimeInstance',
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/undeploy',
tags: ['AppDeploymentService'],
})
.input(
z.object({
body: zAppDeploymentServiceUndeployRuntimeInstanceBody,
params: zAppDeploymentServiceUndeployRuntimeInstancePath,
}),
)
.output(zAppDeploymentServiceUndeployRuntimeInstanceResponse)
export const getDeploymentPlan = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppDeploymentService_GetDeploymentPlan',
path: '/enterprise/app-instances/{appInstanceId}/releases/{releaseId}/deployment-plan',
tags: ['AppDeploymentService'],
})
.input(z.object({ params: zAppDeploymentServiceGetDeploymentPlanPath }))
.output(zAppDeploymentServiceGetDeploymentPlanResponse)
export const appDeploymentService = {
listEnvironmentDeployments,
createDeployment,
cancelDeployment,
undeployRuntimeInstance,
getDeploymentPlan,
}
export const listReleases = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppReleaseService_ListReleases',
path: '/enterprise/app-instances/{appInstanceId}/releases',
tags: ['AppReleaseService'],
})
.input(
z.object({
params: zAppReleaseServiceListReleasesPath,
query: zAppReleaseServiceListReleasesQuery.optional(),
}),
)
.output(zAppReleaseServiceListReleasesResponse)
export const createRelease = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppReleaseService_CreateRelease',
path: '/enterprise/app-instances/{appInstanceId}/releases',
tags: ['AppReleaseService'],
})
.input(
z.object({
body: zAppReleaseServiceCreateReleaseBody,
params: zAppReleaseServiceCreateReleasePath,
}),
)
.output(zAppReleaseServiceCreateReleaseResponse)
export const previewRelease = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AppReleaseService_PreviewRelease',
path: '/enterprise/app-instances/{appInstanceId}/releases/preview',
tags: ['AppReleaseService'],
})
.input(
z.object({
body: zAppReleaseServicePreviewReleaseBody,
params: zAppReleaseServicePreviewReleasePath,
}),
)
.output(zAppReleaseServicePreviewReleaseResponse)
export const appReleaseService = {
listReleases,
createRelease,
previewRelease,
}
export const oAuth2Login = oc
.route({
inputStructure: 'detailed',
@ -556,11 +133,6 @@ export const webAppAuth = {
}
export const contract = {
accessSubjectService,
appInstanceService,
appDeployAccessService,
appDeploymentService,
appReleaseService,
consoleSso,
webAppAuth,
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -37,13 +37,6 @@ const stripSchemaNamePrefix = (schemaName: string) => {
.replace(/^pagination\./, '')
}
const contractTagSegment = (tag?: string) => {
if (tag === 'EnterpriseAppDeployConsole')
return 'AppDeploy'
return tag || 'default'
}
const contractNameSegments = (operation: ContractOperation) => {
const operationId = operation.operationId || operation.id
const tag = operation.tags?.[0]
@ -55,7 +48,7 @@ const contractNameSegments = (operation: ContractOperation) => {
}
const contractPathSegments = (operation: ContractOperation) => {
return [contractTagSegment(operation.tags?.[0]), ...contractNameSegments(operation)]
return [operation.tags?.[0] || 'default', ...contractNameSegments(operation)]
}
const normalizeEnterpriseOpenApi = () => {

View File

@ -1,8 +0,0 @@
import { DeployTab } from '@/features/deployments/detail/deploy-tab'
export default async function InstanceDetailDeployPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <DeployTab appInstanceId={appInstanceId} />
}

View File

@ -1,15 +0,0 @@
import type { ReactNode } from 'react'
import { InstanceDetail } from '@/features/deployments/detail'
export default async function InstanceDetailLayout({ children, params }: {
children: ReactNode
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return (
<InstanceDetail appInstanceId={appInstanceId}>
{children}
</InstanceDetail>
)
}

View File

@ -1,8 +0,0 @@
import { OverviewTab } from '@/features/deployments/detail/overview-tab'
export default async function InstanceDetailOverviewPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <OverviewTab appInstanceId={appInstanceId} />
}

View File

@ -1,8 +0,0 @@
import { redirect } from '@/next/navigation'
export default async function InstanceDetailPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
redirect(`/deployments/${appInstanceId}/overview`)
}

View File

@ -1,8 +0,0 @@
import { VersionsTab } from '@/features/deployments/detail/versions-tab'
export default async function InstanceDetailReleasesPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <VersionsTab appInstanceId={appInstanceId} />
}

View File

@ -1,8 +0,0 @@
import { SettingsTab } from '@/features/deployments/detail/settings-tab'
export default async function InstanceDetailSettingsPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return <SettingsTab appInstanceId={appInstanceId} />
}

View File

@ -1,12 +0,0 @@
'use client'
import { useTranslation } from 'react-i18next'
import { CreateDeploymentGuide } from '@/features/deployments/create-guide'
import useDocumentTitle from '@/hooks/use-document-title'
export default function CreateDeploymentPage() {
const { t } = useTranslation('deployments')
useDocumentTitle(t('documentTitle.create'))
return <CreateDeploymentGuide />
}

View File

@ -1,10 +0,0 @@
'use client'
import { useTranslation } from 'react-i18next'
import { DeploymentsList } from '@/features/deployments/list'
import useDocumentTitle from '@/hooks/use-document-title'
export default function DeploymentsPage() {
const { t } = useTranslation('deployments')
useDocumentTitle(t('documentTitle.list'))
return <DeploymentsList />
}

View File

@ -6,7 +6,7 @@ import AmplitudeProvider from '@/app/components/base/amplitude'
import GA, { GaType } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { GotoAnything } from '@/app/components/goto-anything'
import { Header } from '@/app/components/header'
import Header from '@/app/components/header'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import { AppContextProvider } from '@/context/app-context-provider'

View File

@ -1,6 +1,5 @@
import { screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import RoleRouteGuard from './role-route-guard'
const mockReplace = vi.fn()
@ -35,16 +34,6 @@ const setAppContext = (overrides: Partial<AppContextMock> = {}) => {
})
}
const renderRoleRouteGuard = (systemFeatures: { enable_app_deploy?: boolean } = {}) =>
renderWithSystemFeatures(
(
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
),
{ systemFeatures },
)
describe('RoleRouteGuard', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -57,7 +46,11 @@ describe('RoleRouteGuard', () => {
isLoadingCurrentWorkspace: true,
})
renderRoleRouteGuard()
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByText('content')).not.toBeInTheDocument()
@ -69,7 +62,11 @@ describe('RoleRouteGuard', () => {
isCurrentWorkspaceDatasetOperator: true,
})
renderRoleRouteGuard()
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
expect(screen.queryByText('content')).not.toBeInTheDocument()
await waitFor(() => {
@ -83,7 +80,11 @@ describe('RoleRouteGuard', () => {
isCurrentWorkspaceDatasetOperator: true,
})
renderRoleRouteGuard()
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
expect(screen.getByText('content')).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
@ -95,30 +96,14 @@ describe('RoleRouteGuard', () => {
isLoadingCurrentWorkspace: true,
})
renderRoleRouteGuard()
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
expect(screen.getByText('content')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
it('should redirect deployments routes when app deploy is disabled', async () => {
mockPathname = '/deployments'
renderRoleRouteGuard({ enable_app_deploy: false })
expect(screen.queryByText('content')).not.toBeInTheDocument()
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/apps')
})
})
it('should allow deployments routes when app deploy is enabled', () => {
mockPathname = '/deployments/app-1/overview'
renderRoleRouteGuard({ enable_app_deploy: true })
expect(screen.getByText('content')).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
})

View File

@ -1,12 +1,10 @@
'use client'
import type { ReactNode } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { usePathname, useRouter } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
@ -14,19 +12,15 @@ const isPathUnderRoute = (pathname: string, route: string) => pathname === route
export default function RoleRouteGuard({ children }: { children: ReactNode }) {
const { isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const pathname = usePathname()
const router = useRouter()
const shouldGuardRoute = datasetOperatorRedirectRoutes.some(route => isPathUnderRoute(pathname, route))
const shouldRedirectDatasetOperator = shouldGuardRoute && !isLoadingCurrentWorkspace && isCurrentWorkspaceDatasetOperator
const shouldRedirectAppDeploy = isPathUnderRoute(pathname, '/deployments') && !systemFeatures.enable_app_deploy
const shouldRedirect = shouldRedirectDatasetOperator || shouldRedirectAppDeploy
const redirectPath = shouldRedirectAppDeploy ? '/apps' : '/datasets'
const shouldRedirect = shouldGuardRoute && !isLoadingCurrentWorkspace && isCurrentWorkspaceDatasetOperator
useEffect(() => {
if (shouldRedirect)
router.replace(redirectPath)
}, [redirectPath, shouldRedirect, router])
router.replace('/datasets')
}, [shouldRedirect, router])
// Block rendering only for guarded routes to avoid permission flicker.
if (shouldGuardRoute && isLoadingCurrentWorkspace)

View File

@ -59,7 +59,6 @@ const defaultProviderContext = {
const defaultModalContext: ModalContextState = {
setShowAccountSettingModal: noop,
setShowApiBasedExtensionModal: noop,
setShowModerationSettingModal: noop,
setShowExternalDataToolModal: noop,
setShowPricingModal: noop,

View File

@ -55,7 +55,6 @@ const mockUseProviderContext = vi.fn<() => ProviderContextState>()
const buildModalContext = (): ModalContextState => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
setShowApiBasedExtensionModal: vi.fn(),
setShowModerationSettingModal: vi.fn(),
setShowExternalDataToolModal: vi.fn(),
setShowPricingModal: mockSetShowPricingModal,

View File

@ -2,7 +2,7 @@ import type { ReactElement } from 'react'
import { fireEvent, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { Header } from '../index'
import Header from '../index'
function createMockComponent(testId: string) {
return () => <div data-testid={testId} />
@ -44,10 +44,6 @@ vi.mock('@/app/components/header/tools-nav', () => ({
default: createMockComponent('tools-nav'),
}))
vi.mock('@/features/deployments/nav', () => ({
DeploymentsNav: createMockComponent('deployments-nav'),
}))
vi.mock('@/app/components/header/plan-badge', () => ({
PlanBadge: ({ onClick, plan }: { onClick?: () => void, plan?: string }) => (
<button data-testid="plan-badge" onClick={onClick} data-plan={plan} />
@ -70,7 +66,6 @@ let mockPlanType = 'sandbox'
let mockBrandingEnabled = false
let mockBrandingTitle: string | null = null
let mockBrandingLogo: string | null = null
let mockEnableAppDeploy = false
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
@ -108,7 +103,6 @@ const renderHeader = (ui: ReactElement = <Header />) =>
application_title: mockBrandingTitle ?? '',
workspace_logo: mockBrandingLogo ?? '',
},
enable_app_deploy: mockEnableAppDeploy,
},
})
@ -123,7 +117,6 @@ describe('Header', () => {
mockBrandingEnabled = false
mockBrandingTitle = null
mockBrandingLogo = null
mockEnableAppDeploy = false
})
it('should render header with main nav components', () => {
@ -221,24 +214,6 @@ describe('Header', () => {
expect(screen.getByTestId('app-nav')).toBeInTheDocument()
})
it('should hide deployments nav when app deploy is disabled', () => {
mockIsWorkspaceEditor = true
mockEnableAppDeploy = false
renderHeader()
expect(screen.queryByTestId('deployments-nav')).not.toBeInTheDocument()
})
it('should show deployments nav for editors when app deploy is enabled', () => {
mockIsWorkspaceEditor = true
mockEnableAppDeploy = true
renderHeader()
expect(screen.getByTestId('deployments-nav')).toBeInTheDocument()
})
it('should hide dataset nav when neither editor nor dataset operator', () => {
mockIsWorkspaceEditor = false
mockIsDatasetOperator = false

View File

@ -1,8 +1,6 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { SetStateAction } from 'react'
import type { ModalContextState, ModalState } from '@/context/modal-context'
import { fireEvent, render, screen } from '@testing-library/react'
import { useModalContext } from '@/context/modal-context'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
import { useApiBasedExtensions } from '@/service/use-common'
import ApiBasedExtensionPage from '../index'
@ -10,19 +8,16 @@ vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
vi.mock('@/service/common', () => ({
addApiBasedExtension: vi.fn(),
updateApiBasedExtension: vi.fn(),
}))
describe('ApiBasedExtensionPage', () => {
const mockRefetch = vi.fn<() => void>()
const mockSetShowApiBasedExtensionModal = vi.fn<(value: SetStateAction<ModalState<Partial<ApiBasedExtensionResponse>> | null>) => void>()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useModalContext).mockReturnValue({
setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
} as unknown as ModalContextState)
})
describe('Rendering', () => {
@ -128,13 +123,17 @@ describe('ApiBasedExtensionPage', () => {
fireEvent.click(screen.getByText('common.apiBasedExtension.add'))
// Assert
expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
payload: {},
}))
expect(screen.getByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).toBeInTheDocument()
})
it('should call refetch when onSaveCallback is executed from the modal', () => {
it('should call refetch when add modal saves successfully', async () => {
// Arrange
vi.mocked(addApiBasedExtension).mockResolvedValue({
id: 'new-id',
name: 'New Ext',
api_endpoint: 'https://api.test',
api_key: 'secret-key',
})
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: [],
isPending: false,
@ -144,25 +143,23 @@ describe('ApiBasedExtensionPage', () => {
// Act
render(<ApiBasedExtensionPage />)
fireEvent.click(screen.getByText('common.apiBasedExtension.add'))
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Trigger callback manually from the mock call
const callArgs = mockSetShowApiBasedExtensionModal.mock.calls[0]![0]
if (typeof callArgs === 'object' && callArgs !== null && 'onSaveCallback' in callArgs) {
if (callArgs.onSaveCallback) {
callArgs.onSaveCallback()
// Assert
expect(mockRefetch).toHaveBeenCalled()
}
}
// Assert
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
})
it('should call refetch when an item is updated', () => {
it('should call refetch when an item is updated', async () => {
// Arrange
const mockData: ApiBasedExtensionResponse[] = [
{ id: '1', name: 'Extension 1', api_endpoint: 'url1', api_key: 'key1' },
]
const extension: ApiBasedExtensionResponse = { id: '1', name: 'Extension 1', api_endpoint: 'url1', api_key: 'long-api-key' }
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...extension, name: 'Updated' })
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: mockData,
data: [extension],
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
@ -171,16 +168,12 @@ describe('ApiBasedExtensionPage', () => {
// Act - Click edit on the rendered item
fireEvent.click(screen.getByText('common.operation.edit'))
// Retrieve the onSaveCallback from the modal call and execute it
const callArgs = mockSetShowApiBasedExtensionModal.mock.calls[0]![0]
if (typeof callArgs === 'object' && callArgs !== null && 'onSaveCallback' in callArgs) {
if (callArgs.onSaveCallback)
callArgs.onSaveCallback()
}
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
expect(mockRefetch).toHaveBeenCalled()
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
})
})
})

View File

@ -1,17 +1,10 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { TFunction } from 'i18next'
import type { ModalContextState } from '@/context/modal-context'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import * as reactI18next from 'react-i18next'
import { useModalContext } from '@/context/modal-context'
import { deleteApiBasedExtension } from '@/service/common'
import Item from '../item'
// Mock dependencies
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
vi.mock('@/service/common', () => ({
deleteApiBasedExtension: vi.fn(),
}))
@ -24,19 +17,16 @@ describe('Item Component', () => {
api_key: 'test-api-key',
}
const mockOnUpdate = vi.fn()
const mockSetShowApiBasedExtensionModal = vi.fn()
const mockOnEdit = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useModalContext).mockReturnValue({
setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
} as unknown as ModalContextState)
})
describe('Rendering', () => {
it('should render extension data correctly', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
// Assert
// Assert
@ -54,7 +44,7 @@ describe('Item Component', () => {
}
// Act
render(<Item data={minimalData} onUpdate={mockOnUpdate} />)
render(<Item data={minimalData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
// Assert
// Assert
@ -64,41 +54,20 @@ describe('Item Component', () => {
})
describe('Modal Interactions', () => {
it('should open edit modal with correct payload when clicking edit button', () => {
it('should request editing with the current extension when clicking edit button', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.edit'))
// Assert
expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
payload: mockData,
}))
const lastCall = mockSetShowApiBasedExtensionModal.mock.calls[0]![0]
if (typeof lastCall === 'object' && lastCall !== null && 'onSaveCallback' in lastCall)
expect(lastCall.onSaveCallback).toBeInstanceOf(Function)
})
it('should execute onUpdate callback when edit modal save callback is invoked', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.edit'))
// Assert
const modalCallArg = mockSetShowApiBasedExtensionModal.mock.calls[0]![0]
if (typeof modalCallArg === 'object' && modalCallArg !== null && 'onSaveCallback' in modalCallArg) {
const onSaveCallback = modalCallArg.onSaveCallback
if (onSaveCallback) {
onSaveCallback()
expect(mockOnUpdate).toHaveBeenCalledTimes(1)
}
}
expect(mockOnEdit).toHaveBeenCalledWith(mockData)
})
})
describe('Deletion', () => {
it('should show delete confirmation dialog when clicking delete button', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.delete'))
// Assert
@ -109,7 +78,7 @@ describe('Item Component', () => {
it('should call delete API and triggers onUpdate when confirming deletion', async () => {
// Arrange
vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
// Act
fireEvent.click(screen.getByText('common.operation.delete'))
@ -129,7 +98,7 @@ describe('Item Component', () => {
it('should hide delete confirmation dialog after successful deletion', async () => {
// Arrange
vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
// Act
fireEvent.click(screen.getByText('common.operation.delete'))
@ -147,7 +116,7 @@ describe('Item Component', () => {
it('should close delete confirmation when clicking cancel button', async () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.delete'))
fireEvent.click(screen.getByText('common.operation.cancel'))
@ -159,7 +128,7 @@ describe('Item Component', () => {
it('should not call delete API when canceling deletion', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.delete'))
fireEvent.click(screen.getByText('common.operation.cancel'))
@ -188,7 +157,7 @@ describe('Item Component', () => {
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
const allButtons = screen.getAllByRole('button')
const editBtn = screen.getByText('operation.edit')
const deleteBtn = allButtons.find(btn => btn !== editBtn)

View File

@ -1,6 +1,6 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { TFunction } from 'i18next'
import type { ReactElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { fireEvent, render as RTLRender, screen, waitFor } from '@testing-library/react'
import * as reactI18next from 'react-i18next'
import { useDocLink } from '@/context/i18n'
@ -34,7 +34,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
}))
describe('ApiBasedExtensionModal', () => {
const mockOnCancel = vi.fn()
const mockOnOpenChange = vi.fn()
const mockOnSave = vi.fn()
const mockDocLink = vi.fn((path?: string) => `https://docs.dify.ai${path || ''}`)
const mockExtension = (overrides: Partial<ApiBasedExtensionResponse> = {}): ApiBasedExtensionResponse => ({
@ -46,6 +46,19 @@ describe('ApiBasedExtensionModal', () => {
})
const render = (ui: ReactElement) => RTLRender(ui)
const renderModal = (props: Partial<ComponentProps<typeof ApiBasedExtensionModal>> = {}) => render(
<ApiBasedExtensionModal
open
extension={{}}
onOpenChange={mockOnOpenChange}
onSave={mockOnSave}
{...props}
/>,
)
const expectCloseRequested = () => {
const calls = mockOnOpenChange.mock.calls
expect(calls[calls.length - 1]?.[0]).toBe(false)
}
beforeEach(() => {
vi.clearAllMocks()
@ -55,9 +68,10 @@ describe('ApiBasedExtensionModal', () => {
describe('Rendering', () => {
it('should render correctly for adding a new extension', () => {
// Act
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
renderModal()
// Assert
expect(screen.getByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).toBeInTheDocument()
expect(screen.getByText('common.apiBasedExtension.modal.title')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder')).toBeInTheDocument()
@ -69,7 +83,7 @@ describe('ApiBasedExtensionModal', () => {
const data = mockExtension()
// Act
render(<ApiBasedExtensionModal data={data} onCancel={mockOnCancel} onSave={mockOnSave} />)
renderModal({ extension: data })
// Assert
expect(screen.getByText('common.apiBasedExtension.modal.editTitle')).toBeInTheDocument()
@ -77,6 +91,14 @@ describe('ApiBasedExtensionModal', () => {
expect(screen.getByDisplayValue('url')).toBeInTheDocument()
expect(screen.getByDisplayValue('key')).toBeInTheDocument()
})
it('should not render dialog content when closed', () => {
// Act
renderModal({ open: false })
// Assert
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
})
describe('Form Submissions', () => {
@ -89,7 +111,7 @@ describe('ApiBasedExtensionModal', () => {
api_key: 'secret-key',
})
vi.mocked(addApiBasedExtension).mockResolvedValue(newExtension)
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
renderModal()
// Act
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
@ -115,7 +137,7 @@ describe('ApiBasedExtensionModal', () => {
// Arrange
const data = mockExtension({ api_key: 'long-secret-key' })
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, name: 'Updated' })
render(<ApiBasedExtensionModal data={data} onCancel={mockOnCancel} onSave={mockOnSave} />)
renderModal({ extension: data })
// Act
fireEvent.change(screen.getByDisplayValue('Existing'), { target: { value: 'Updated' } })
@ -125,12 +147,11 @@ describe('ApiBasedExtensionModal', () => {
await waitFor(() => {
expect(updateApiBasedExtension).toHaveBeenCalledWith({
url: '/api-based-extension/1',
body: expect.objectContaining({
id: '1',
body: {
name: 'Updated',
api_endpoint: 'url',
api_key: '[__HIDDEN__]',
}),
},
})
expect(mockToast.success).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully')
expect(mockOnSave).toHaveBeenCalled()
@ -141,7 +162,7 @@ describe('ApiBasedExtensionModal', () => {
// Arrange
const data = mockExtension({ api_key: 'old-key' })
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, api_key: 'new-longer-key' })
render(<ApiBasedExtensionModal data={data} onCancel={mockOnCancel} onSave={mockOnSave} />)
renderModal({ extension: data })
// Act
fireEvent.change(screen.getByDisplayValue('old-key'), { target: { value: 'new-longer-key' } })
@ -151,9 +172,11 @@ describe('ApiBasedExtensionModal', () => {
await waitFor(() => {
expect(updateApiBasedExtension).toHaveBeenCalledWith({
url: '/api-based-extension/1',
body: expect.objectContaining({
body: {
name: 'Existing',
api_endpoint: 'url',
api_key: 'new-longer-key',
}),
},
})
})
})
@ -162,7 +185,7 @@ describe('ApiBasedExtensionModal', () => {
describe('Validation', () => {
it('should show error if api key is too short', async () => {
// Arrange
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
renderModal()
// Act
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'Ext' } })
@ -180,7 +203,7 @@ describe('ApiBasedExtensionModal', () => {
it('should work when onSave is not provided', async () => {
// Arrange
vi.mocked(addApiBasedExtension).mockResolvedValue(mockExtension({ id: 'new-id' }))
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} />)
renderModal({ onSave: undefined })
// Act
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
@ -194,15 +217,56 @@ describe('ApiBasedExtensionModal', () => {
})
})
it('should call onCancel when clicking cancel button', () => {
it('should request closing when clicking cancel button', () => {
// Arrange
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
renderModal()
// Act
fireEvent.click(screen.getByText('common.operation.cancel'))
// Assert
expect(mockOnCancel).toHaveBeenCalled()
expectCloseRequested()
})
it('should request closing when clicking close button', async () => {
// Arrange
renderModal()
// Act
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
// Assert
await waitFor(() => {
expectCloseRequested()
})
})
it('should request closing when pressing Escape', async () => {
// Arrange
renderModal()
// Act
fireEvent.keyDown(document, { key: 'Escape' })
// Assert
await waitFor(() => {
expectCloseRequested()
})
})
it('should keep open when clicking outside the dialog', () => {
// Arrange
renderModal()
// Act
const backdrop = document.querySelector('.bg-background-overlay')
expect(backdrop).toBeInTheDocument()
fireEvent.pointerDown(backdrop!)
fireEvent.pointerUp(backdrop!)
fireEvent.click(backdrop!)
// Assert
expect(mockOnOpenChange).not.toHaveBeenCalled()
})
})
@ -230,7 +294,7 @@ describe('ApiBasedExtensionModal', () => {
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
// Act
const { container } = render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} />)
const { container } = renderModal({ onSave: undefined })
// Assert
const inputs = container.querySelectorAll('input')

View File

@ -1,9 +1,10 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { UseQueryResult } from '@tanstack/react-query'
import type { ModalContextState } from '@/context/modal-context'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { addApiBasedExtension } from '@/service/common'
import { useApiBasedExtensions } from '@/service/use-common'
import ApiBasedExtensionSelector from '../selector'
@ -15,12 +16,15 @@ vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(),
}))
vi.mock('@/service/common', () => ({
addApiBasedExtension: vi.fn(),
}))
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
describe('ApiBasedExtensionSelector', () => {
const mockOnChange = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockSetShowApiBasedExtensionModal = vi.fn()
const mockRefetch = vi.fn()
const mockData: ApiBasedExtensionResponse[] = [
@ -32,7 +36,6 @@ describe('ApiBasedExtensionSelector', () => {
vi.clearAllMocks()
vi.mocked(useModalContext).mockReturnValue({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
} as unknown as ModalContextState)
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: mockData,
@ -103,26 +106,29 @@ describe('ApiBasedExtensionSelector', () => {
})
it('should open add modal when clicking add button and refetches on save', async () => {
// Arrange
vi.mocked(addApiBasedExtension).mockResolvedValue({
id: 'new-id',
name: 'New Ext',
api_endpoint: 'https://api.test',
api_key: 'secret-key',
})
// Act
render(<ApiBasedExtensionSelector value="" onChange={mockOnChange} />)
fireEvent.click(screen.getByText('common.apiBasedExtension.selector.placeholder'))
const addButton = await screen.findByText('common.operation.add')
fireEvent.click(addButton)
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
payload: {},
}))
// Trigger callback
const lastCall = mockSetShowApiBasedExtensionModal.mock.calls[0]![0]
if (typeof lastCall === 'object' && lastCall !== null && 'onSaveCallback' in lastCall) {
if (lastCall.onSaveCallback) {
lastCall.onSaveCallback()
expect(mockRefetch).toHaveBeenCalled()
}
}
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
})
})
})

View File

@ -1,24 +1,42 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import {
RiAddLine,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useModalContext } from '@/context/modal-context'
import { useApiBasedExtensions } from '@/service/use-common'
import Empty from './empty'
import Item from './item'
import ApiBasedExtensionModal from './modal'
type ApiBasedExtensionDialogState = {
extension: Partial<ApiBasedExtensionResponse>
onSave: () => void
} | null
const ApiBasedExtensionPage = () => {
const { t } = useTranslation()
const { setShowApiBasedExtensionModal } = useModalContext()
const { data, refetch: mutate, isPending: isLoading } = useApiBasedExtensions()
const [dialogState, setDialogState] = useState<ApiBasedExtensionDialogState>(null)
const handleOpenApiBasedExtensionModal = () => {
setShowApiBasedExtensionModal({
payload: {},
onSaveCallback: () => mutate(),
setDialogState({
extension: {},
onSave: () => mutate(),
})
}
const handleEditApiBasedExtension = (extension: ApiBasedExtensionResponse) => {
setDialogState({
extension,
onSave: () => mutate(),
})
}
const handleSaveApiBasedExtension = () => {
dialogState?.onSave()
setDialogState(null)
}
const handleApiBasedExtensionModalOpenChange = (open: boolean) => {
if (!open)
setDialogState(null)
}
return (
<div>
@ -33,6 +51,7 @@ const ApiBasedExtensionPage = () => {
<Item
key={item.id}
data={item}
onEdit={handleEditApiBasedExtension}
onUpdate={() => mutate()}
/>
))
@ -43,9 +62,19 @@ const ApiBasedExtensionPage = () => {
className="w-full"
onClick={handleOpenApiBasedExtensionModal}
>
<RiAddLine className="mr-1 h-4 w-4" />
<span className="mr-1 i-ri-add-line h-4 w-4" aria-hidden="true" />
{t('apiBasedExtension.add', { ns: 'common' })}
</Button>
{
dialogState && (
<ApiBasedExtensionModal
open
extension={dialogState.extension}
onOpenChange={handleApiBasedExtensionModalOpenChange}
onSave={handleSaveApiBasedExtension}
/>
)
}
</div>
)
}

View File

@ -1,5 +1,4 @@
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { FC } from 'react'
import {
AlertDialog,
AlertDialogActions,
@ -9,32 +8,25 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import {
RiDeleteBinLine,
RiEditLine,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useModalContext } from '@/context/modal-context'
import { deleteApiBasedExtension } from '@/service/common'
type ItemProps = {
data: ApiBasedExtensionResponse
onEdit: (extension: ApiBasedExtensionResponse) => void
onUpdate: () => void
}
const Item: FC<ItemProps> = ({
const Item = ({
data,
onEdit,
onUpdate,
}) => {
}: ItemProps) => {
const { t } = useTranslation()
const { setShowApiBasedExtensionModal } = useModalContext()
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const handleOpenApiBasedExtensionModal = () => {
setShowApiBasedExtensionModal({
payload: data,
onSaveCallback: () => onUpdate(),
})
onEdit(data)
}
const handleDeleteApiBasedExtension = async () => {
await deleteApiBasedExtension(`/api-based-extension/${data.id}`)
@ -44,28 +36,27 @@ const Item: FC<ItemProps> = ({
}
return (
<div className="group mb-2 flex items-center rounded-xl border-[0.5px] border-transparent bg-components-input-bg-normal px-4 py-2 hover:border-components-input-border-active hover:shadow-xs">
<div className="grow">
<div className="group mb-2 flex items-center rounded-xl border-[0.5px] border-transparent bg-components-input-bg-normal px-4 py-2 focus-within:border-components-input-border-active focus-within:shadow-xs hover:border-components-input-border-active hover:shadow-xs">
<div className="min-w-0 grow">
<div className="mb-0.5 text-[13px] font-medium text-text-secondary">{data.name}</div>
<div className="text-xs text-text-tertiary">{data.api_endpoint}</div>
<div className="truncate text-xs text-text-tertiary">{data.api_endpoint}</div>
</div>
<div className="hidden items-center group-hover:flex">
<div className="pointer-events-none flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
<Button
className="mr-1"
onClick={handleOpenApiBasedExtensionModal}
>
<RiEditLine className="mr-1 h-4 w-4" />
<span className="mr-1 i-ri-edit-line h-4 w-4" aria-hidden="true" />
{t('operation.edit', { ns: 'common' })}
</Button>
<Button
onClick={() => setShowDeleteConfirm(true)}
>
<RiDeleteBinLine className="mr-1 h-4 w-4" />
<span className="mr-1 i-ri-delete-bin-line h-4 w-4" aria-hidden="true" />
{t('operation.delete', { ns: 'common' })}
</Button>
</div>
<AlertDialog open={showDeleteConfirm} onOpenChange={open => !open && setShowDeleteConfirm(false)}>
<AlertDialogContent>
<AlertDialogContent backdropProps={{ forceRender: true }}>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{`${t('operation.delete', { ns: 'common' })} \u201C${data.name}\u201D?`}

View File

@ -2,51 +2,57 @@ import type {
ApiBasedExtensionPayload,
ApiBasedExtensionResponse,
} from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
import { useDocLink } from '@/context/i18n'
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
type ApiBasedExtensionField = 'name' | 'api_endpoint' | 'api_key'
type ApiBasedExtensionModalProps = {
data: Partial<ApiBasedExtensionResponse>
onCancel: () => void
open: boolean
extension: Partial<ApiBasedExtensionResponse>
onOpenChange: (open: boolean) => void
onSave?: (newData: ApiBasedExtensionResponse) => void
}
const ApiBasedExtensionModal: FC<ApiBasedExtensionModalProps> = ({ data, onCancel, onSave }) => {
const ApiBasedExtensionModal = ({ open, extension, onOpenChange, onSave }: ApiBasedExtensionModalProps) => {
const { t } = useTranslation()
const docLink = useDocLink()
const [localeData, setLocaleData] = useState(data)
const [localData, setLocalData] = useState(extension)
const [loading, setLoading] = useState(false)
const handleDataChange = (type: string, value: string) => {
setLocaleData({ ...localeData, [type]: value })
const handleDataChange = (field: ApiBasedExtensionField, value: string) => {
setLocalData({ ...localData, [field]: value })
}
const handleSave = async () => {
setLoading(true)
if (localeData && localeData.api_key && localeData.api_key?.length < 5) {
if (localData.api_key && localData.api_key.length < 5) {
toast.error(t('apiBasedExtension.modal.apiKey.lengthError', { ns: 'common' }))
setLoading(false)
return
}
try {
const payload: ApiBasedExtensionPayload = {
name: localData.name || '',
api_endpoint: localData.api_endpoint || '',
api_key: localData.api_key || '',
}
let res = {} as ApiBasedExtensionResponse
if (!data.id) {
if (!extension.id) {
res = await addApiBasedExtension({
url: '/api-based-extension',
body: localeData as ApiBasedExtensionPayload,
body: payload,
})
}
else {
res = await updateApiBasedExtension({
url: `/api-based-extension/${data.id}`,
url: `/api-based-extension/${extension.id}`,
body: {
...localeData,
api_key: data.api_key === localeData.api_key ? '[__HIDDEN__]' : localeData.api_key,
} as ApiBasedExtensionPayload,
...payload,
api_key: extension.api_key === localData.api_key ? '[__HIDDEN__]' : payload.api_key,
},
})
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
}
@ -57,44 +63,47 @@ const ApiBasedExtensionModal: FC<ApiBasedExtensionModalProps> = ({ data, onCance
setLoading(false)
}
}
return (
<Dialog open>
<DialogContent className="w-[640px]! max-w-none! border-none p-8! pb-6! text-left align-middle">
<div className="mb-2 text-xl font-semibold text-text-primary">
{data.name
return (
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
<DialogContent
backdropProps={{ forceRender: true }}
className="w-160 border-none p-8 pb-6 text-left"
>
<DialogCloseButton />
<DialogTitle className="mb-2 pr-8 text-xl font-semibold text-text-primary">
{extension.name
? t('apiBasedExtension.modal.editTitle', { ns: 'common' })
: t('apiBasedExtension.modal.title', { ns: 'common' })}
</div>
</DialogTitle>
<div className="py-2">
<div className="text-sm leading-9 font-medium text-text-primary">
{t('apiBasedExtension.modal.name.title', { ns: 'common' })}
</div>
<input value={localeData.name || ''} onChange={e => handleDataChange('name', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.name.placeholder', { ns: 'common' }) || ''} />
<input value={localData.name || ''} onChange={e => handleDataChange('name', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.name.placeholder', { ns: 'common' }) || ''} />
</div>
<div className="py-2">
<div className="flex h-9 items-center justify-between text-sm font-medium text-text-primary">
{t('apiBasedExtension.modal.apiEndpoint.title', { ns: 'common' })}
<a href={docLink('/use-dify/workspace/api-extension/api-extension')} target="_blank" rel="noopener noreferrer" className="group flex items-center text-xs font-normal text-text-accent">
<BookOpen01 className="mr-1 h-3 w-3" />
<a href={docLink('/use-dify/workspace/api-extension/api-extension')} target="_blank" rel="noopener noreferrer" className="flex items-center text-xs font-normal text-text-accent">
<span className="mr-1 i-custom-vender-line-education-book-open-01 h-3 w-3" aria-hidden="true" />
{t('apiBasedExtension.link', { ns: 'common' })}
</a>
</div>
<input value={localeData.api_endpoint || ''} onChange={e => handleDataChange('api_endpoint', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiEndpoint.placeholder', { ns: 'common' }) || ''} />
<input value={localData.api_endpoint || ''} onChange={e => handleDataChange('api_endpoint', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiEndpoint.placeholder', { ns: 'common' }) || ''} />
</div>
<div className="py-2">
<div className="text-sm leading-9 font-medium text-text-primary">
{t('apiBasedExtension.modal.apiKey.title', { ns: 'common' })}
</div>
<div className="flex items-center">
<input value={localeData.api_key || ''} onChange={e => handleDataChange('api_key', e.target.value)} className="mr-2 block h-9 grow appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiKey.placeholder', { ns: 'common' }) || ''} />
</div>
<input value={localData.api_key || ''} onChange={e => handleDataChange('api_key', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiKey.placeholder', { ns: 'common' }) || ''} />
</div>
<div className="mt-6 flex items-center justify-end">
<Button onClick={onCancel} className="mr-2">
<div className="mt-6 flex items-center justify-end gap-2">
<Button onClick={() => onOpenChange(false)}>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button variant="primary" disabled={!localeData.name || !localeData.api_endpoint || !localeData.api_key || loading} onClick={handleSave}>
<Button variant="primary" disabled={!localData.name || !localData.api_endpoint || !localData.api_key || loading} onClick={handleSave}>
{t('operation.save', { ns: 'common' })}
</Button>
</div>

View File

@ -1,32 +1,25 @@
import type { FC } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
RiAddLine,
RiArrowDownSLine,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
ArrowUpRight,
} from '@/app/components/base/icons/src/vender/line/arrows'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { useApiBasedExtensions } from '@/service/use-common'
import ApiBasedExtensionModal from './modal'
type ApiBasedExtensionSelectorProps = {
value: string
onChange: (value: string) => void
}
const ApiBasedExtensionSelector: FC<ApiBasedExtensionSelectorProps> = ({
const ApiBasedExtensionSelector = ({
value,
onChange,
}) => {
}: ApiBasedExtensionSelectorProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [addModalOpen, setAddModalOpen] = useState(false)
const {
setShowAccountSettingModal,
setShowApiBasedExtensionModal,
} = useModalContext()
const { data, refetch: mutate } = useApiBasedExtensions()
const handleSelect = (id: string) => {
@ -36,91 +29,115 @@ const ApiBasedExtensionSelector: FC<ApiBasedExtensionSelectorProps> = ({
const currentItem = data?.find(item => item.id === value)
const handleSaveApiBasedExtension = () => {
mutate()
setAddModalOpen(false)
}
const handleAddModalOpenChange = (nextOpen: boolean) => {
if (!nextOpen)
setAddModalOpen(false)
}
return (
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger
render={(
<button type="button" className="block w-full border-0 bg-transparent p-0 text-left">
{
currentItem
? (
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3">
<div className="text-sm text-text-primary">{currentItem.name}</div>
<div className="flex items-center">
<div className="mr-1.5 w-[270px] truncate text-right text-xs text-text-quaternary">
{currentItem.api_endpoint}
</div>
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
</div>
</div>
)
: (
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3 text-sm text-text-quaternary">
{t('apiBasedExtension.selector.placeholder', { ns: 'common' })}
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
</div>
)
}
</button>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
className="w-[calc(100%-32px)] max-w-[576px]"
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
<>
<Popover
open={open}
onOpenChange={setOpen}
>
<div className="z-10 w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="p-1">
<div className="flex items-center justify-between px-3 pt-2 pb-1">
<div className="text-xs font-medium text-text-tertiary">
{t('apiBasedExtension.selector.title', { ns: 'common' })}
<PopoverTrigger
render={(
<button type="button" className="block w-full border-0 bg-transparent p-0 text-left">
{
currentItem
? (
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3">
<div className="text-sm text-text-primary">{currentItem.name}</div>
<div className="flex items-center">
<div className="mr-1.5 w-[270px] truncate text-right text-xs text-text-quaternary">
{currentItem.api_endpoint}
</div>
<span className={`i-ri-arrow-down-s-line h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} aria-hidden="true" />
</div>
</div>
)
: (
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3 text-sm text-text-quaternary">
{t('apiBasedExtension.selector.placeholder', { ns: 'common' })}
<span className={`i-ri-arrow-down-s-line h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} aria-hidden="true" />
</div>
)
}
</button>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={4}
className="w-[calc(100%-32px)] max-w-[576px]"
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="z-10 w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="p-1">
<div className="flex items-center justify-between px-3 pt-2 pb-1">
<div className="text-xs font-medium text-text-tertiary">
{t('apiBasedExtension.selector.title', { ns: 'common' })}
</div>
<button
type="button"
className="flex cursor-pointer items-center border-none bg-transparent p-0 text-xs text-text-accent"
onClick={() => {
setOpen(false)
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION })
}}
>
{t('apiBasedExtension.selector.manage', { ns: 'common' })}
<span className="ml-0.5 i-custom-vender-line-arrows-arrow-up-right h-3 w-3" aria-hidden="true" />
</button>
</div>
<div
className="flex cursor-pointer items-center text-xs text-text-accent"
<div className="max-h-[250px] overflow-y-auto">
{
data?.map(item => (
<button
type="button"
key={item.id}
className="w-full cursor-pointer rounded-md border-none bg-transparent px-3 py-1.5 text-left hover:bg-state-base-hover"
onClick={() => handleSelect(item.id!)}
>
<div className="text-sm text-text-primary">{item.name}</div>
<div className="text-xs text-text-tertiary">{item.api_endpoint}</div>
</button>
))
}
</div>
</div>
<div className="h-px bg-divider-regular" />
<div className="p-1">
<button
type="button"
className="flex h-8 w-full cursor-pointer items-center border-none bg-transparent px-3 text-left text-sm text-text-accent"
onClick={() => {
setOpen(false)
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION })
setAddModalOpen(true)
}}
>
{t('apiBasedExtension.selector.manage', { ns: 'common' })}
<ArrowUpRight className="ml-0.5 h-3 w-3" />
</div>
</div>
<div className="max-h-[250px] overflow-y-auto">
{
data?.map(item => (
<div
key={item.id}
className="w-full cursor-pointer rounded-md px-3 py-1.5 text-left hover:stroke-state-base-hover"
onClick={() => handleSelect(item.id)}
>
<div className="text-sm text-text-primary">{item.name}</div>
<div className="text-xs text-text-tertiary">{item.api_endpoint}</div>
</div>
))
}
<span className="mr-2 i-ri-add-line h-4 w-4" aria-hidden="true" />
{t('operation.add', { ns: 'common' })}
</button>
</div>
</div>
<div className="h-px bg-divider-regular" />
<div className="p-1">
<div
className="flex h-8 cursor-pointer items-center px-3 text-sm text-text-accent"
onClick={() => {
setOpen(false)
setShowApiBasedExtensionModal({ payload: {}, onSaveCallback: () => mutate() })
}}
>
<RiAddLine className="mr-2 h-4 w-4" />
{t('operation.add', { ns: 'common' })}
</div>
</div>
</div>
</PopoverContent>
</Popover>
</PopoverContent>
</Popover>
{
addModalOpen && (
<ApiBasedExtensionModal
open
extension={{}}
onOpenChange={handleAddModalOpenChange}
onSave={handleSaveApiBasedExtension}
/>
)
}
</>
)
}

View File

@ -1,5 +1,6 @@
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import WorkplaceSelector from '@/app/components/header/account-dropdown/workplace-selector'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
@ -7,7 +8,6 @@ import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { WorkspaceProvider } from '@/context/workspace-context-provider'
import { DeploymentsNav } from '@/features/deployments/nav'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Link from '@/next/link'
import { systemFeaturesQueryOptions } from '@/service/system-features'
@ -28,7 +28,7 @@ const navClassName = `
cursor-pointer
`
export function Header() {
const Header = () => {
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
@ -37,33 +37,29 @@ export function Header() {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isFreePlan = plan.type === Plan.sandbox
const isBrandingEnabled = systemFeatures.branding.enabled
const canUseAppDeploy = isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy
function handlePlanClick() {
const handlePlanClick = useCallback(() => {
if (isFreePlan)
setShowPricingModal()
else
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}
}, [isFreePlan, setShowAccountSettingModal, setShowPricingModal])
function renderLogo() {
return (
<h1>
<Link href="/apps" className="flex h-8 shrink-0 items-center justify-center overflow-hidden px-0.5 indent-[-9999px] whitespace-nowrap">
{isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? (
<img
src={systemFeatures.branding.workspace_logo}
className="block h-[22px] w-auto object-contain"
alt="logo"
/>
)
: <DifyLogo />}
</Link>
</h1>
)
}
const renderLogo = () => (
<h1>
<Link href="/apps" className="flex h-8 shrink-0 items-center justify-center overflow-hidden px-0.5 indent-[-9999px] whitespace-nowrap">
{isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? (
<img
src={systemFeatures.branding.workspace_logo}
className="block h-[22px] w-auto object-contain"
alt="logo"
/>
)
: <DifyLogo />}
</Link>
</h1>
)
if (isMobile) {
return (
@ -77,17 +73,18 @@ export function Header() {
</WorkspaceProvider>
{enableBilling ? <PlanBadge allowHover sandboxAsUpgrade plan={plan.type} onClick={handlePlanClick} /> : <LicenseNav />}
</div>
<div className="flex items-center gap-2">
<PluginsNav />
<div className="flex items-center">
<div className="mr-2">
<PluginsNav />
</div>
<AccountDropdown />
</div>
</div>
<div className="my-1 flex items-center justify-center gap-1">
<div className="my-1 flex items-center justify-center space-x-1">
{!isCurrentWorkspaceDatasetOperator && <ExploreNav className={navClassName} />}
{!isCurrentWorkspaceDatasetOperator && <AppNav />}
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
{!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
{canUseAppDeploy && <DeploymentsNav />}
</div>
</div>
)
@ -103,18 +100,20 @@ export function Header() {
</WorkspaceProvider>
{enableBilling ? <PlanBadge allowHover sandboxAsUpgrade plan={plan.type} onClick={handlePlanClick} /> : <LicenseNav />}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center space-x-2">
{!isCurrentWorkspaceDatasetOperator && <ExploreNav className={navClassName} />}
{!isCurrentWorkspaceDatasetOperator && <AppNav />}
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
{!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
{canUseAppDeploy && <DeploymentsNav />}
</div>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 pr-3 pl-2 min-[1280px]:pl-3">
<div className="flex min-w-0 flex-1 items-center justify-end pr-3 pl-2 min-[1280px]:pl-3">
<EnvNav />
<PluginsNav />
<div className="mr-2">
<PluginsNav />
</div>
<AccountDropdown />
</div>
</div>
)
}
export default Header

View File

@ -26,7 +26,6 @@ const mockSetShowAccountSettingModal = vi.fn()
vi.mock('@/context/modal-context', () => ({
useModalContext: (): ModalContextState => ({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
setShowApiBasedExtensionModal: vi.fn(),
setShowModerationSettingModal: vi.fn(),
setShowExternalDataToolModal: vi.fn(),
setShowPricingModal: mockSetShowPricingModal,

View File

@ -1,6 +1,5 @@
'use client'
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { ReactNode, SetStateAction } from 'react'
import type { ModalState, ModelModalType } from './modal-context'
import type { OpeningStatement } from '@/app/components/base/features/types'
@ -36,9 +35,6 @@ import {
const AccountSetting = dynamic(() => import('@/app/components/header/account-setting'), {
ssr: false,
})
const ApiBasedExtensionModal = dynamic(() => import('@/app/components/header/account-setting/api-based-extension-page/modal'), {
ssr: false,
})
const ModerationSettingModal = dynamic(() => import('@/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal'), {
ssr: false,
})
@ -90,7 +86,6 @@ export const ModalContextProvider = ({
? urlAccountModalState.payload
: DEFAULT_ACCOUNT_SETTING_TAB)
: null
const [showApiBasedExtensionModal, setShowApiBasedExtensionModal] = useState<ModalState<Partial<ApiBasedExtensionResponse>> | null>(null)
const [showModerationSettingModal, setShowModerationSettingModal] = useState<ModalState<ModerationConfig> | null>(null)
const [showExternalDataToolModal, setShowExternalDataToolModal] = useState<ModalState<ExternalDataTool> | null>(null)
const [showModelModal, setShowModelModal] = useState<ModalState<ModelModalType> | null>(null)
@ -206,12 +201,6 @@ export const ModalContextProvider = ({
showOpeningModal.onCancelCallback()
}, [showOpeningModal])
const handleSaveApiBasedExtension = (newApiBasedExtension: ApiBasedExtensionResponse) => {
if (showApiBasedExtensionModal?.onSaveCallback)
showApiBasedExtensionModal.onSaveCallback(newApiBasedExtension)
setShowApiBasedExtensionModal(null)
}
const handleSaveModeration = (newModerationConfig: ModerationConfig) => {
if (showModerationSettingModal?.onSaveCallback)
showModerationSettingModal.onSaveCallback(newModerationConfig)
@ -247,7 +236,6 @@ export const ModalContextProvider = ({
return (
<ModalContext.Provider value={{
setShowAccountSettingModal,
setShowApiBasedExtensionModal,
setShowModerationSettingModal,
setShowExternalDataToolModal,
setShowPricingModal: handleShowPricingModal,
@ -273,15 +261,6 @@ export const ModalContextProvider = ({
)
}
{
!!showApiBasedExtensionModal && (
<ApiBasedExtensionModal
data={showApiBasedExtensionModal.payload}
onCancel={() => setShowApiBasedExtensionModal(null)}
onSave={handleSaveApiBasedExtension}
/>
)
}
{
!!showModerationSettingModal && (
<ModerationSettingModal

View File

@ -1,6 +1,5 @@
'use client'
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
import type { Dispatch, SetStateAction } from 'react'
import type { TriggerEventsLimitModalPayload } from './hooks/use-trigger-events-limit-modal'
import type { OpeningStatement } from '@/app/components/base/features/types'
@ -48,7 +47,6 @@ export type ModelModalType = {
export type ModalContextState = {
setShowAccountSettingModal: Dispatch<SetStateAction<ModalState<AccountSettingTab> | null>>
setShowApiBasedExtensionModal: Dispatch<SetStateAction<ModalState<Partial<ApiBasedExtensionResponse>> | null>>
setShowModerationSettingModal: Dispatch<SetStateAction<ModalState<ModerationConfig> | null>>
setShowExternalDataToolModal: Dispatch<SetStateAction<ModalState<ExternalDataTool> | null>>
setShowPricingModal: () => void
@ -68,7 +66,6 @@ export type ModalContextState = {
export const ModalContext = createContext<ModalContextState>({
setShowAccountSettingModal: noop,
setShowApiBasedExtensionModal: noop,
setShowModerationSettingModal: noop,
setShowExternalDataToolModal: noop,
setShowPricingModal: noop,

View File

@ -1,113 +0,0 @@
import type { ReleaseRow, ReleaseSummary } from '@dify/contracts/enterprise/types.gen'
import { describe, expect, it } from 'vitest'
import { releaseDeploymentAction } from '../release-action'
function release(overrides: ReleaseRow): ReleaseRow {
return overrides
}
function currentRelease(overrides: ReleaseSummary): ReleaseSummary {
return overrides
}
describe('releaseDeploymentAction', () => {
describe('deploy actions', () => {
it('should return deploy when the target environment has no current release', () => {
// Arrange
const releases = [
release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
]
// Act
const action = releaseDeploymentAction({
targetRelease: releases[0],
releaseRows: releases,
})
// Assert
expect(action).toBe('deploy')
})
it('should return deployExistingRelease when a preset release is deployed to a new environment', () => {
// Arrange
const releases = [
release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
]
// Act
const action = releaseDeploymentAction({
targetRelease: releases[0],
releaseRows: releases,
isExistingRelease: true,
})
// Assert
expect(action).toBe('deployExistingRelease')
})
})
describe('release direction', () => {
it('should return promote when the target release is newer than the current release', () => {
// Arrange
const releases = [
release({ id: 'release-3', createdAt: '2026-01-03T00:00:00Z' }),
release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
]
// Act
const action = releaseDeploymentAction({
targetRelease: releases[0],
currentRelease: currentRelease({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
releaseRows: releases,
isExistingRelease: true,
})
// Assert
expect(action).toBe('promote')
})
it('should return rollback when the target release is older than the current release', () => {
// Arrange
const releases = [
release({ id: 'release-3', createdAt: '2026-01-03T00:00:00Z' }),
release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
]
// Act
const action = releaseDeploymentAction({
targetRelease: releases[1],
currentRelease: currentRelease({ id: 'release-3', createdAt: '2026-01-03T00:00:00Z' }),
releaseRows: releases,
isExistingRelease: true,
})
// Assert
expect(action).toBe('rollback')
})
it('should fall back to release list order when release timestamps are unavailable', () => {
// Arrange
const releases = [
release({ id: 'release-3' }),
release({ id: 'release-2' }),
release({ id: 'release-1' }),
]
// Act
const rollbackAction = releaseDeploymentAction({
targetRelease: releases[2],
currentRelease: currentRelease({ id: 'release-2' }),
releaseRows: releases,
})
const promoteAction = releaseDeploymentAction({
targetRelease: releases[0],
currentRelease: currentRelease({ id: 'release-2' }),
releaseRows: releases,
})
// Assert
expect(rollbackAction).toBe('rollback')
expect(promoteAction).toBe('promote')
})
})
})

View File

@ -1,7 +0,0 @@
import { AppModeEnum } from '@/types/app'
const appModeValues = new Set<string>(Object.values(AppModeEnum))
export function toAppMode(mode?: string): AppModeEnum {
return appModeValues.has(mode ?? '') ? (mode as AppModeEnum) : AppModeEnum.WORKFLOW
}

View File

@ -1,349 +0,0 @@
'use client'
import type { App } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxItem,
ComboboxItemText,
ComboboxList,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { keepPreviousData, useInfiniteQuery, useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Input from '@/app/components/base/input'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
const SOURCE_APP_PAGE_SIZE = 20
const SOURCE_APP_PICKER_SKELETON_KEYS = ['first-source-app', 'second-source-app', 'third-source-app']
function sourceAppSearchText(app: App) {
return `${app.name} ${app.id} ${app.mode}`.toLowerCase()
}
function SourceAppTrigger({ open, app }: {
open: boolean
app?: App
}) {
const { t } = useTranslation('deployments')
return (
<span
className={cn(
'group flex cursor-pointer items-center gap-2 rounded-lg bg-components-input-bg-normal p-2 pl-3 hover:bg-state-base-hover-alt',
open && 'bg-state-base-hover-alt',
app && 'py-1.5 pl-1.5',
)}
>
{app && (
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
)}
<span
title={app?.name}
className={cn(
'min-w-0 grow truncate',
app
? 'system-sm-medium text-components-input-text-filled'
: 'system-sm-regular text-components-input-text-placeholder',
)}
>
{app?.name ?? t('createModal.appPickerPlaceholder')}
</span>
<span
className={cn(
'i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
open && 'text-text-secondary',
)}
aria-hidden="true"
/>
</span>
)
}
function SourceAppOption({ app }: {
app: App
}) {
const { t } = useTranslation('deployments')
const modeLabel = t(`appMode.${app.mode}`, { defaultValue: app.mode })
return (
<ComboboxItem
value={app}
className="mx-0 grid-cols-[minmax(0,1fr)_auto] gap-3 py-1 pr-3 pl-2"
>
<ComboboxItemText className="flex min-w-0 items-center gap-3 px-0">
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<span title={`${app.name} (${app.id})`} className="flex min-w-0 grow items-center gap-1 truncate system-sm-medium text-components-input-text-filled">
<span className="truncate">{app.name}</span>
<span className="shrink-0 text-text-tertiary">
(
{app.id.slice(0, 8)}
)
</span>
</span>
</ComboboxItemText>
<span className="shrink-0 system-2xs-medium-uppercase text-text-tertiary">{modeLabel}</span>
</ComboboxItem>
)
}
function SourceAppPickerSkeleton() {
return (
<div className="flex flex-col gap-2 px-3 py-3">
{SOURCE_APP_PICKER_SKELETON_KEYS.map(key => (
<SkeletonRow key={key} className="h-7 gap-3">
<SkeletonRectangle className="my-0 size-5 animate-pulse rounded-md" />
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
</SkeletonRow>
))}
</div>
)
}
function SourceAppPicker({ value, onChange }: {
value?: App
onChange: (app: App) => void
}) {
const { t } = useTranslation('deployments')
const [isShow, setIsShow] = useState(false)
const [searchText, setSearchText] = useState('')
const {
data,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery({
...consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
page: Number(pageParam),
limit: SOURCE_APP_PAGE_SIZE,
name: searchText,
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
})
const apps = data?.pages.flatMap(page => page.data) ?? []
return (
<Combobox<App>
items={apps}
open={isShow}
inputValue={searchText}
onOpenChange={setIsShow}
onInputValueChange={setSearchText}
onValueChange={(app) => {
if (!app)
return
onChange(app)
setIsShow(false)
}}
itemToStringLabel={app => app?.name ?? ''}
itemToStringValue={app => app?.id ?? ''}
filter={(app, query) => sourceAppSearchText(app).includes(query.toLowerCase())}
disabled={false}
>
<ComboboxTrigger
aria-label={t('createModal.sourceApp')}
icon={false}
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-open:bg-transparent"
>
<SourceAppTrigger open={isShow} app={value} />
</ComboboxTrigger>
<ComboboxContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="relative flex max-h-100 min-h-20 w-89 flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span className="i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<ComboboxInput
aria-label={t('createModal.appSearchPlaceholder')}
placeholder={t('createModal.appSearchPlaceholder')}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1">
{(isLoading || isFetchingNextPage) && apps.length === 0 && <SourceAppPickerSkeleton />}
<ComboboxList className="max-h-none p-0">
{(app: App) => (
<SourceAppOption key={app.id} app={app} />
)}
</ComboboxList>
{!(isLoading || isFetchingNextPage) && (
<ComboboxEmpty>
{t('createModal.appSearchEmpty')}
</ComboboxEmpty>
)}
{hasNextPage && (
<div className="flex justify-center px-3 py-2">
<Button
type="button"
size="small"
disabled={isFetchingNextPage}
onClick={() => {
void fetchNextPage()
}}
>
{isFetchingNextPage ? t('common.loading') : t('createModal.loadMoreApps')}
</Button>
</div>
)}
</div>
</div>
</ComboboxContent>
</Combobox>
)
}
function CreateInstanceForm({ onClose }: {
onClose: () => void
}) {
const { t } = useTranslation('deployments')
const router = useRouter()
const createInstance = useMutation(consoleQuery.enterprise.appInstanceService.createAppInstance.mutationOptions())
const [sourceApp, setSourceApp] = useState<App>()
const canCreate = Boolean(sourceApp?.id && !createInstance.isPending)
const handleCreate = async (form: HTMLFormElement) => {
if (!canCreate || !sourceApp?.id)
return
const formData = new FormData(form)
const name = String(formData.get('name') ?? '').trim()
const description = String(formData.get('description') ?? '').trim()
if (!name)
return
try {
const result = await createInstance.mutateAsync({
body: {
sourceAppId: sourceApp.id,
name: name.trim(),
description: description.trim() || undefined,
},
})
if (!result.appInstanceId)
throw new Error('Create app instance did not return an appInstanceId.')
onClose()
router.push(`/deployments/${result.appInstanceId}/overview`)
}
catch {
toast.error(t('createModal.createFailed'))
}
}
return (
<form
className="flex flex-col gap-5"
onSubmit={(event) => {
event.preventDefault()
void handleCreate(event.currentTarget)
}}
>
<div>
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('createModal.title')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('createModal.description')}
</DialogDescription>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary">{t('createModal.sourceApp')}</label>
<SourceAppPicker
value={sourceApp}
onChange={setSourceApp}
/>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="instance-name">
{t('createModal.nameLabel')}
</label>
<Input
id="instance-name"
name="name"
type="text"
placeholder={sourceApp?.name ?? t('createModal.namePlaceholder')}
required
className="h-8"
/>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="instance-desc">
{t('createModal.descriptionLabel')}
</label>
<textarea
id="instance-desc"
name="description"
placeholder={t('createModal.descriptionPlaceholder')}
className="min-h-20 w-full appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
{t('createModal.cancel')}
</Button>
<Button type="submit" variant="primary" disabled={!canCreate}>
{t('createModal.create')}
</Button>
</div>
</form>
)
}
export function CreateInstanceModal({ open, onOpenChange }: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
>
<DialogContent className="w-130 max-w-[90vw]">
<DialogCloseButton />
{open && <CreateInstanceForm onClose={() => onOpenChange(false)} />}
</DialogContent>
</Dialog>
)
}

View File

@ -1,44 +0,0 @@
'use client'
import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import {
closeDeployDrawerAtom,
deployDrawerAppInstanceIdAtom,
deployDrawerEnvironmentIdAtom,
deployDrawerOpenAtom,
deployDrawerReleaseIdAtom,
} from '../store'
import { DeployForm } from './deploy-drawer/form'
export function DeployDrawer() {
const { t } = useTranslation('deployments')
const open = useAtomValue(deployDrawerOpenAtom)
const drawerAppInstanceId = useAtomValue(deployDrawerAppInstanceIdAtom)
const drawerEnvironmentId = useAtomValue(deployDrawerEnvironmentIdAtom)
const drawerReleaseId = useAtomValue(deployDrawerReleaseIdAtom)
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
const formKey = `${drawerAppInstanceId ?? 'none'}-${drawerEnvironmentId ?? 'any'}-${drawerReleaseId ?? 'new'}-${open ? '1' : '0'}`
return (
<Dialog
open={open}
onOpenChange={next => !next && closeDeployDrawer()}
>
<DialogContent className="w-140 max-w-[90vw]">
<DialogCloseButton />
{!drawerAppInstanceId
? <div className="p-4 text-text-tertiary">{t('deployDrawer.notFound')}</div>
: (
<DeployForm
key={formKey}
appInstanceId={drawerAppInstanceId}
lockedEnvId={drawerEnvironmentId}
presetReleaseId={drawerReleaseId}
/>
)}
</DialogContent>
</Dialog>
)
}

View File

@ -1,500 +0,0 @@
'use client'
import type { AppDeployEnvironment, DeploymentBindingSlot, DeploymentRuntimeBinding, EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { DEPLOYMENT_PAGE_SIZE } from '../../data'
import { environmentId, environmentMode, environmentName } from '../../environment'
import { releaseCommit, releaseLabel } from '../../release'
import { releaseDeploymentAction } from '../../release-action'
import { isUndeployedDeploymentRow } from '../../runtime-status'
import { closeDeployDrawerAtom } from '../../store'
import {
DeploymentSelect,
EnvironmentRow,
Field,
} from './select'
type DeployFormProps = {
appInstanceId: string
lockedEnvId?: string
presetReleaseId?: string
}
type DeployReadyFormProps = DeployFormProps & {
environments: EnvironmentOption[]
releases: ReleaseRow[]
defaultReleaseId?: string
runtimeRows: EnvironmentDeployment[]
}
type EnvironmentOption = AppDeployEnvironment & { id: string }
const DEPLOY_FORM_FIELD_SKELETON_KEYS = ['environment', 'release']
type BindingSelections = Record<string, string>
type BindingSelectOption = {
value: string
label: string
}
type BindingOptionsPanelProps = {
slots: DeploymentBindingSlot[]
selections: BindingSelections
isLoading: boolean
hasError: boolean
onChange: (slot: string, value: string) => void
}
function isEnvBindingSlot(slot: DeploymentBindingSlot) {
return (slot.kind?.toLowerCase() ?? '').includes('env')
}
function bindingSlotKey(slot: DeploymentBindingSlot) {
return slot.slot ?? ''
}
function bindingCandidateOptions(slot: DeploymentBindingSlot): BindingSelectOption[] {
if (isEnvBindingSlot(slot)) {
return (slot.envVarCandidates ?? [])
.filter(candidate => candidate.envVarId)
.map(candidate => ({
value: candidate.envVarId!,
label: [
candidate.name,
candidate.displayValue,
].filter(Boolean).join(' · ') || candidate.envVarId!,
}))
}
return (slot.credentialCandidates ?? [])
.filter(candidate => candidate.credentialId)
.map(candidate => ({
value: candidate.credentialId!,
label: [
candidate.displayName,
candidate.pluginName || candidate.pluginId,
candidate.pluginVersion,
].filter(Boolean).join(' · ') || candidate.credentialId!,
}))
}
function hasMissingRequiredBinding(slot: DeploymentBindingSlot, selectedValue?: string) {
return Boolean(slot.required && !selectedValue)
}
function selectedDeploymentBindings(slots: DeploymentBindingSlot[], selections: BindingSelections): DeploymentRuntimeBinding[] {
return slots
.map((slot): DeploymentRuntimeBinding | undefined => {
const slotKey = bindingSlotKey(slot)
const selectedValue = selections[slotKey]
if (!slotKey || !selectedValue)
return undefined
return isEnvBindingSlot(slot)
? { slot: slotKey, envVarId: selectedValue }
: { slot: slotKey, credentialId: selectedValue }
})
.filter((binding): binding is DeploymentRuntimeBinding => Boolean(binding))
}
function selectedBindingSelections(slots: DeploymentBindingSlot[], manualBindings: BindingSelections): BindingSelections {
const next: BindingSelections = {}
for (const slot of slots) {
const slotKey = bindingSlotKey(slot)
const candidates = bindingCandidateOptions(slot)
const existing = manualBindings[slotKey]
if (existing && candidates.some(candidate => candidate.value === existing))
next[slotKey] = existing
else if (candidates.length === 1 && candidates[0])
next[slotKey] = candidates[0].value
}
return next
}
function BindingOptionsPanel({
slots,
selections,
isLoading,
hasError,
onChange,
}: BindingOptionsPanelProps) {
const { t } = useTranslation('deployments')
if (isLoading) {
return (
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
</div>
)
}
if (hasError) {
return (
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4 system-sm-regular text-text-destructive">
{t('deployDrawer.bindingOptionsFailed')}
</div>
)
}
return (
<div className="overflow-hidden rounded-xl border border-divider-subtle bg-background-default-subtle">
<div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('deployDrawer.runtimeCredentials')}</div>
<span className="system-xs-regular text-text-quaternary">{t('deployDrawer.bindingSelectionHint')}</span>
</div>
{slots.length === 0
? (
<div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{t('deployDrawer.noBindingRequired')}
</div>
)
: slots.map((slot) => {
const slotKey = bindingSlotKey(slot)
const candidates = bindingCandidateOptions(slot)
const selectedValue = selections[slotKey] ?? ''
const missing = hasMissingRequiredBinding(slot, selectedValue)
return (
<div key={slotKey} className="flex flex-col gap-2 border-t border-divider-subtle px-3 py-3">
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(220px,0.9fr)] sm:items-start">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate system-sm-medium text-text-secondary" title={slot.name || slotKey}>
{slot.name || slotKey}
</span>
{slot.required && (
<span className="shrink-0 rounded-md bg-background-default px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{t('deployDrawer.requiredBinding')}
</span>
)}
</div>
<span className="font-mono system-xs-regular break-all text-text-quaternary" title={slotKey}>
{slotKey}
</span>
</div>
{candidates.length === 0
? (
<div className="rounded-lg border border-divider-subtle bg-background-default px-2 py-1.5 system-sm-regular text-text-quaternary">
{t('deployDrawer.noCredentialCandidates')}
</div>
)
: (
<DeploymentSelect
value={selectedValue}
onChange={value => onChange(slotKey, value)}
options={candidates}
placeholder={t('deployDrawer.selectCredential')}
/>
)}
</div>
{missing && (
<div className="system-xs-regular text-text-destructive">
{t('deployDrawer.missingRequiredBinding')}
</div>
)}
</div>
)
})}
</div>
)
}
function DeployFormSkeleton() {
return (
<div className="flex flex-col gap-5">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-5 w-44 animate-pulse" />
<SkeletonRectangle className="h-3 w-72 animate-pulse" />
</SkeletonContainer>
{DEPLOY_FORM_FIELD_SKELETON_KEYS.map(key => (
<SkeletonContainer key={key} className="gap-2">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRectangle className="my-0 h-9 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
))}
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
</div>
<SkeletonRow className="justify-end">
<SkeletonRectangle className="my-0 h-8 w-18 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-8 w-22 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
)
}
function DeployReadyForm({
appInstanceId,
environments,
releases,
defaultReleaseId,
lockedEnvId,
presetReleaseId,
runtimeRows,
}: DeployReadyFormProps) {
const { t } = useTranslation('deployments')
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
const startDeploy = useMutation(consoleQuery.enterprise.appDeploymentService.createDeployment.mutationOptions())
const presetRelease = presetReleaseId ? releases.find(r => r.id === presetReleaseId) : undefined
const displayedRelease: ReleaseRow | undefined = presetRelease ?? (presetReleaseId ? { id: presetReleaseId } : undefined)
const isExistingRelease = Boolean(presetReleaseId)
const [selectedEnvId, setSelectedEnvId] = useState<string>(
() => lockedEnvId ?? environments[0]?.id ?? '',
)
const selectedEnvironmentId = selectedEnvId || lockedEnvId || environments[0]?.id || ''
const selectedEnvironment = environments.find(env => env.id === selectedEnvironmentId)
const [selectedReleaseId, setSelectedReleaseId] = useState<string>(
() => displayedRelease?.id ?? defaultReleaseId ?? '',
)
const selectedRelease = releases.find(release => release.id === selectedReleaseId)
const targetReleaseId = displayedRelease?.id ?? selectedRelease?.id ?? selectedReleaseId
const targetRelease = displayedRelease ?? selectedRelease ?? (targetReleaseId ? { id: targetReleaseId } : undefined)
const deploymentRows = runtimeRows.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row))
const selectedDeploymentRow = deploymentRows.find(row => environmentId(row.environment) === selectedEnvironmentId)
const action = releaseDeploymentAction({
targetRelease,
currentRelease: selectedDeploymentRow?.currentRelease,
releaseRows: releases,
isExistingRelease,
})
const bindingOptions = useQuery(consoleQuery.enterprise.appDeploymentService.getDeploymentPlan.queryOptions({
input: {
params: {
appInstanceId,
releaseId: targetReleaseId || '',
},
},
enabled: Boolean(appInstanceId && targetReleaseId),
}))
const bindingSlots = bindingOptions.data?.plan?.slots?.filter(slot => slot.slot) ?? []
const [manualBindings, setManualBindings] = useState<BindingSelections>({})
const selectedBindings = selectedBindingSelections(bindingSlots, manualBindings)
const deploymentBindings = selectedDeploymentBindings(bindingSlots, selectedBindings)
const bindingOptionsLoading = Boolean(targetReleaseId && (bindingOptions.isLoading || bindingOptions.isFetching))
const bindingOptionsReady = Boolean(targetReleaseId && bindingOptions.data && !bindingOptionsLoading && !bindingOptions.isError)
const requiredBindingsReady = bindingSlots.every(slot => !hasMissingRequiredBinding(slot, selectedBindings[bindingSlotKey(slot)]))
const isSubmitting = startDeploy.isPending
const canDeploy = Boolean(
selectedEnvironmentId
&& selectedEnvironment
&& targetReleaseId
&& bindingOptionsReady
&& requiredBindingsReady
&& !isSubmitting,
)
const lockedEnv = lockedEnvId ? environments.find(e => e.id === lockedEnvId) : undefined
const actionTitle = action === 'rollback'
? t('deployDrawer.rollbackTitle')
: action === 'promote'
? t('deployDrawer.promoteTitle')
: action === 'deployExistingRelease'
? t('deployDrawer.deployExistingReleaseTitle')
: t('deployDrawer.title')
const actionDescription = action === 'rollback'
? t('deployDrawer.rollbackDescription')
: action === 'promote'
? t('deployDrawer.promoteDescription')
: action === 'deployExistingRelease'
? t('deployDrawer.deployExistingReleaseDescription')
: t('deployDrawer.description')
const submitLabel = isSubmitting
? t('deployDrawer.deploying')
: action === 'rollback'
? t('deployDrawer.rollback')
: action === 'promote'
? t('deployDrawer.promote')
: action === 'deployExistingRelease'
? t('deployDrawer.deployExistingRelease')
: t('deployDrawer.deploy')
const handleDeploy = () => {
if (!canDeploy || !targetReleaseId)
return
startDeploy.mutate(
{
params: {
appInstanceId,
environmentId: selectedEnvironmentId,
},
body: {
appInstanceId,
environmentId: selectedEnvironmentId,
releaseId: targetReleaseId,
bindings: deploymentBindings,
},
},
{
onSuccess: () => {
closeDeployDrawer()
},
onError: () => {
toast.error(t('deployDrawer.deployFailed'))
},
},
)
}
return (
<div className="flex flex-col gap-5">
<div>
<DialogTitle className="title-xl-semi-bold text-text-primary">
{actionTitle}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{actionDescription}
</DialogDescription>
</div>
<Field label={t('deployDrawer.releaseLabel')}>
{isExistingRelease && displayedRelease
? (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-mono system-sm-semibold text-text-primary">{releaseLabel(displayedRelease)}</span>
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">{releaseCommit(displayedRelease)}</span>
</div>
<span className="shrink-0 system-xs-regular text-text-quaternary">{displayedRelease.createdAt}</span>
</div>
<span className="system-xs-regular text-text-tertiary">
{t('deployDrawer.existingReleaseHint')}
</span>
</div>
)
: releases.length === 0
? (
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-3 py-3 system-sm-regular text-text-tertiary">
{t('deployDrawer.noReleaseAvailable')}
</div>
)
: (
<DeploymentSelect
value={selectedReleaseId}
onChange={setSelectedReleaseId}
options={releases.filter(release => release.id).map(release => ({
value: release.id!,
label: `${releaseLabel(release)} · ${releaseCommit(release)}`,
}))}
placeholder={t('deployDrawer.selectRelease')}
/>
)}
</Field>
<Field
label={t('deployDrawer.targetEnv')}
hint={lockedEnvId ? t('deployDrawer.lockedHint') : undefined}
>
{lockedEnv
? <EnvironmentRow env={lockedEnv} />
: (
<DeploymentSelect
value={selectedEnvironmentId}
onChange={setSelectedEnvId}
options={environments.filter(env => env.id).map(env => ({
value: env.id!,
label: `${environmentName(env)} · ${t(environmentMode(env) === 'isolated' ? 'mode.isolated' : 'mode.shared')} · ${(env.type ?? 'env').toUpperCase()}`,
}))}
placeholder={t('deployDrawer.selectEnv')}
/>
)}
</Field>
{targetReleaseId && (
<BindingOptionsPanel
slots={bindingSlots}
selections={selectedBindings}
isLoading={bindingOptionsLoading}
hasError={bindingOptions.isError}
onChange={(slot, value) => setManualBindings(prev => ({ ...prev, [slot]: value }))}
/>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={closeDeployDrawer}>
{t('deployDrawer.cancel')}
</Button>
<Button variant="primary" disabled={!canDeploy} onClick={handleDeploy}>
{submitLabel}
</Button>
</div>
</div>
)
}
export function DeployForm({
appInstanceId,
lockedEnvId,
presetReleaseId,
}: DeployFormProps) {
const { t } = useTranslation('deployments')
const releaseHistoryQuery = useQuery(consoleQuery.enterprise.appReleaseService.listReleases.queryOptions({
input: {
params: { appInstanceId },
query: {
pageNumber: 1,
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
},
},
}))
const runtimeInstancesQuery = useQuery(consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
}))
if (releaseHistoryQuery.isLoading || runtimeInstancesQuery.isLoading) {
return <DeployFormSkeleton />
}
if (releaseHistoryQuery.isError || runtimeInstancesQuery.isError) {
return (
<div className="p-4 system-sm-regular text-text-destructive">
{t('common.loadFailed')}
</div>
)
}
const environments = runtimeInstancesQuery.data?.data
?.map(row => row.environment)
.filter((environment): environment is EnvironmentOption => Boolean(environment?.id)) ?? []
const releases = releaseHistoryQuery.data?.data?.filter(release => release.id) ?? []
const defaultReleaseId = releases[0]?.id
const runtimeRows = runtimeInstancesQuery.data?.data ?? []
const formKey = `${appInstanceId}-${lockedEnvId ?? 'any'}-${presetReleaseId ?? 'new'}-${defaultReleaseId ?? 'none'}`
return (
<DeployReadyForm
key={formKey}
appInstanceId={appInstanceId}
environments={environments}
releases={releases}
defaultReleaseId={defaultReleaseId}
lockedEnvId={lockedEnvId}
presetReleaseId={presetReleaseId}
runtimeRows={runtimeRows}
/>
)
}

View File

@ -1,94 +0,0 @@
'use client'
import type { AppDeployEnvironment } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useTranslation } from 'react-i18next'
import { environmentHealth, environmentMode, environmentName } from '../../environment'
import { HealthBadge, ModeBadge } from '../status-badge'
type EnvironmentOption = AppDeployEnvironment & {
disabled?: boolean
}
export function Field({ label, hint, children }: {
label: string
hint?: string
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="system-xs-medium-uppercase text-text-tertiary">{label}</div>
{hint && <span className="system-xs-regular text-text-quaternary">{hint}</span>}
</div>
{children}
</div>
)
}
type SelectOption = {
value: string
label: string
disabled?: boolean
disabledReason?: string
}
type SelectProps = {
value: string
onChange: (value: string) => void
options: SelectOption[]
placeholder?: string
}
export function DeploymentSelect({ value, onChange, options, placeholder }: SelectProps) {
const { t } = useTranslation('deployments')
const selectedOption = options.find(option => option.value === value)
return (
<Select
value={value || null}
onValueChange={(next) => {
if (!next)
return
onChange(next)
}}
disabled={options.length === 0}
>
<SelectTrigger
className={cn(
'h-8 min-w-0 border border-components-input-border-active px-2 text-left system-sm-medium',
!selectedOption && 'text-text-quaternary',
)}
>
{selectedOption?.label ?? placeholder ?? t('deployDrawer.defaultSelect')}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{options.map(opt => (
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.disabled}
title={opt.disabled ? opt.disabledReason : undefined}
>
<SelectItemText>{opt.label}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
export function EnvironmentRow({ env }: { env: EnvironmentOption }) {
return (
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
<div className="flex items-center gap-2">
<span className="system-sm-semibold text-text-primary">{environmentName(env)}</span>
<ModeBadge mode={environmentMode(env)} />
<HealthBadge health={environmentHealth(env)} />
</div>
<span className="system-xs-regular text-text-tertiary uppercase">{env.type ?? 'env'}</span>
</div>
)
}

View File

@ -1,68 +0,0 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
type DeployStatus = 'ready' | 'deploying' | 'deploy_failed' | 'unknown'
type EnvironmentMode = 'shared' | 'isolated'
type EnvironmentHealth = 'ready' | 'degraded'
const statusStyles: Record<DeployStatus, string> = {
ready: 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700',
deploying: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
deploy_failed: 'border-util-colors-red-red-200 bg-util-colors-red-red-50 text-util-colors-red-red-700',
unknown: 'border-divider-subtle bg-background-default-subtle text-text-tertiary',
}
const statusKey = {
ready: 'status.ready',
deploying: 'status.deploying',
deploy_failed: 'status.deployFailed',
unknown: 'status.unknown',
} as const satisfies Record<DeployStatus, string>
const baseBadge = 'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 system-xs-medium whitespace-nowrap'
export function StatusBadge({ status, className }: {
status: DeployStatus
className?: string
}) {
const { t } = useTranslation('deployments')
return (
<span className={cn(baseBadge, statusStyles[status], className)}>
{status === 'deploying' && (
<span className="size-1.5 animate-pulse rounded-full bg-current" />
)}
{t(statusKey[status])}
</span>
)
}
export function ModeBadge({ mode, className }: {
mode: EnvironmentMode
className?: string
}) {
const { t } = useTranslation('deployments')
const style = mode === 'shared'
? 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700'
: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700'
return (
<span className={cn(baseBadge, style, className)}>
{t(mode === 'shared' ? 'mode.shared' : 'mode.isolated')}
</span>
)
}
export function HealthBadge({ health, className }: {
health: EnvironmentHealth
className?: string
}) {
const { t } = useTranslation('deployments')
const style = health === 'ready'
? 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700'
: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700'
return (
<span className={cn(baseBadge, style, className)}>
{t(health === 'ready' ? 'health.ready' : 'health.degraded')}
</span>
)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +0,0 @@
import type { Pagination } from '@dify/contracts/enterprise/types.gen'
export const DEPLOYMENT_PAGE_SIZE = 100
export const RELEASE_HISTORY_PAGE_SIZE = 20
export const SOURCE_APPS_PAGE_SIZE = 100
export function getNextPageParamFromPagination(pagination?: Pagination) {
const currentPage = pagination?.currentPage ?? 1
const totalPages = pagination?.totalPages ?? 1
return currentPage < totalPages ? currentPage + 1 : undefined
}

View File

@ -1,113 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
type SectionProps = {
title: string
description?: string
action?: ReactNode
children: ReactNode
layout?: 'block' | 'row'
tone?: 'default' | 'destructive'
}
export function SectionState({ children }: {
children: ReactNode
}) {
return (
<div className="flex min-h-24 items-center justify-center border-y border-dashed border-divider-subtle px-4 py-6 text-center system-sm-regular text-text-tertiary">
{children}
</div>
)
}
export function DetailListState({ children }: {
children: ReactNode
}) {
return (
<div className="flex min-h-36 items-center justify-center border-y border-dashed border-divider-subtle px-4 py-12 text-center system-sm-regular text-text-tertiary">
{children}
</div>
)
}
export function Section({
title,
description,
action,
children,
layout = 'block',
tone = 'default',
}: SectionProps) {
const titleClassName = cn(
'system-sm-semibold',
tone === 'destructive'
? 'text-util-colors-red-red-700'
: layout === 'row'
? 'text-text-secondary'
: 'text-text-primary',
)
const descriptionClassName = cn(
'mt-1 body-xs-regular',
tone === 'destructive' ? 'text-util-colors-red-red-600' : 'text-text-tertiary',
)
if (layout === 'row') {
return (
<section className="border-b border-divider-subtle py-4 first:pt-0 last:border-b-0 last:pb-0">
<div className="flex flex-col gap-3 sm:flex-row sm:gap-x-6">
<div className="flex min-w-0 shrink-0 flex-col sm:w-40 sm:pt-1">
<div className={titleClassName}>
{title}
</div>
{description && (
<p className={descriptionClassName}>
{description}
</p>
)}
</div>
<div className="min-w-0 grow">
{action
? (
<div className="flex min-w-0 items-start gap-3">
<div className="min-w-0 grow">
{children}
</div>
<div className="shrink-0">
{action}
</div>
</div>
)
: children}
</div>
</div>
</section>
)
}
return (
<section className="border-b border-divider-subtle py-6 first:pt-0 last:border-b-0 last:pb-0">
<div className="mb-3 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className={titleClassName}>
{title}
</div>
{description && (
<p className={cn(descriptionClassName, 'max-w-150')}>
{description}
</p>
)}
</div>
{Boolean(action) && (
<div className="shrink-0">
{action}
</div>
)}
</div>
<div className="min-w-0">
{children}
</div>
</section>
)
}

View File

@ -1,143 +0,0 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { deploymentStatusPollingInterval } from '../runtime-status'
import { openDeployDrawerAtom } from '../store'
import {
DetailListState,
} from './common'
import { DeploymentEnvironmentList } from './deploy-tab/deployment-environment-list'
import {
DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME,
DETAIL_LIST_CLASS_NAME,
DETAIL_LIST_DESKTOP_ROW_CLASS_NAME,
DETAIL_LIST_HEADER_ROW_CLASS_NAME,
DETAIL_LIST_ROW_CLASS_NAME,
} from './list-styles'
function NewDeploymentButton({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
return (
<Button
size="medium"
variant="primary"
className="gap-1.5"
onClick={() => openDeployDrawer({ appInstanceId })}
>
{t('deployTab.newDeployment')}
</Button>
)
}
const DEPLOYMENT_TABLE_ROW_SKELETON_KEYS = ['production', 'staging']
function DeploymentEnvironmentListSkeleton() {
const { t } = useTranslation('deployments')
return (
<>
<div className={`${DETAIL_LIST_CLASS_NAME} pc:hidden`}>
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
<div className="flex flex-col gap-3 p-4">
<div className="flex min-w-0 flex-col gap-1.5">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="my-0 h-4 w-18 animate-pulse rounded-md" />
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<SkeletonRectangle className="h-2.5 w-24 animate-pulse" />
<SkeletonRow className="gap-2">
<SkeletonRectangle className="h-3 w-16 animate-pulse" />
<SkeletonRectangle className="h-2.5 w-18 animate-pulse" />
</SkeletonRow>
</div>
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</div>
))}
</div>
<div className="hidden pc:block">
<div className={DETAIL_LIST_CLASS_NAME}>
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div>{t('deployTab.col.environment')}</div>
<div>{t('deployTab.col.status')}</div>
<div>{t('deployTab.col.currentRelease')}</div>
<div className="text-right">{t('deployTab.col.actions')}</div>
</div>
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div className="min-w-0">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
</div>
<div className="min-w-0">
<SkeletonRectangle className="my-0 h-4 w-18 animate-pulse rounded-md" />
</div>
<div className="min-w-0">
<SkeletonRow className="gap-2">
<SkeletonRectangle className="h-3 w-16 animate-pulse" />
<SkeletonRectangle className="h-2.5 w-18 animate-pulse" />
</SkeletonRow>
</div>
<div className="flex justify-end">
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</div>
</div>
))}
</div>
</div>
</>
)
}
export function DeployTab({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
refetchInterval: query => deploymentStatusPollingInterval(query.state.data),
}))
const environmentDeployments = environmentDeploymentsQuery.data
const rows = environmentDeployments?.data?.filter(row => row.environment?.id) ?? []
const isLoading = environmentDeploymentsQuery.isLoading
const hasError = environmentDeploymentsQuery.isError
return (
<div className="mx-auto flex w-full max-w-[1080px] min-w-0 flex-col gap-4 px-6 py-6">
<div className="flex items-center justify-between">
<div className="system-sm-semibold text-text-primary">
{t('deployTab.envCount')}
{' '}
<span className="system-sm-regular text-text-tertiary">
(
{rows.length}
)
</span>
</div>
<NewDeploymentButton appInstanceId={appInstanceId} />
</div>
{isLoading
? <DeploymentEnvironmentListSkeleton />
: hasError
? <DetailListState>{t('common.loadFailed')}</DetailListState>
: rows.length === 0
? <DetailListState>{t('deployTab.empty')}</DetailListState>
: (
<DeploymentEnvironmentList appInstanceId={appInstanceId} rows={rows} />
)}
</div>
)
}

View File

@ -1,334 +0,0 @@
'use client'
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useMutation } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import {
environmentId,
environmentName,
} from '../../environment'
import { releaseCommit, releaseLabel } from '../../release'
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import {
DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME,
DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME,
DETAIL_LIST_CLASS_NAME,
DETAIL_LIST_DESKTOP_ROW_CLASS_NAME,
DETAIL_LIST_HEADER_ROW_CLASS_NAME,
DETAIL_LIST_ROW_CLASS_NAME,
} from '../list-styles'
import { DeploymentStatusSummary } from './deployment-status-summary'
function EnvironmentSummary({ environment }: {
environment: EnvironmentDeployment['environment']
}) {
return (
<span className="block truncate system-sm-semibold text-text-primary">
{environmentName(environment)}
</span>
)
}
function CurrentReleaseSummary({ release }: {
release: EnvironmentDeployment['currentRelease']
}) {
if (!release?.id && !release?.name)
return <span className="system-sm-regular text-text-quaternary"></span>
const commit = releaseCommit(release)
return (
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 items-baseline gap-1.5">
<span className="truncate font-mono system-sm-medium text-text-primary">
{releaseLabel(release)}
</span>
{commit !== '—' && (
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">
{commit}
</span>
)}
</div>
</div>
)
}
function DeploymentRowActions({ appInstanceId, envId, row }: {
appInstanceId: string
envId: string
row: EnvironmentDeployment
}) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const undeployDeployment = useMutation(consoleQuery.enterprise.appDeploymentService.undeployRuntimeInstance.mutationOptions())
const isUndeployed = isUndeployedDeploymentRow(row)
const status = deploymentStatus(row)
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
const [actionsOpen, setActionsOpen] = useState(false)
const [isUndeploying, setIsUndeploying] = useState(false)
const undeployInFlightRef = useRef(false)
const isUndeployRequesting = undeployDeployment.isPending || isUndeploying
const undeployActionDisabled = isUndeployRequesting || !envId
const isDeploying = status === 'deploying'
const deployActionLabel = isUndeployed
? t('deployDrawer.deploy')
: status === 'deploy_failed'
? t('deployTab.viewError')
: t('deployTab.deployOtherVersion')
function handleDeployAction() {
openDeployDrawer({ appInstanceId, environmentId: envId })
setActionsOpen(false)
}
function handleUndeploy() {
if (!envId || undeployInFlightRef.current)
return
undeployInFlightRef.current = true
setIsUndeploying(true)
undeployDeployment.mutate(
{
params: { appInstanceId, environmentId: envId },
body: { appInstanceId, environmentId: envId },
},
{
onSettled: () => {
undeployInFlightRef.current = false
setIsUndeploying(false)
setShowUndeployConfirm(false)
},
},
)
}
return (
<div
className="flex shrink-0 items-center"
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
>
{!isDeploying && (
<DropdownMenu modal={false} open={actionsOpen} onOpenChange={setActionsOpen}>
<DropdownMenuTrigger
aria-label={t('deployTab.moreActions')}
className={DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
{actionsOpen && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-44">
<DropdownMenuItem
className="gap-2 px-3"
onClick={handleDeployAction}
>
<span aria-hidden className="i-ri-rocket-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{deployActionLabel}</span>
</DropdownMenuItem>
{!isUndeployed && (
<>
<div className="my-1 border-t border-divider-subtle" aria-hidden />
<DropdownMenuItem
disabled={undeployActionDisabled}
aria-disabled={undeployActionDisabled}
className={cn(
'gap-2 px-3 text-util-colors-red-red-600',
undeployActionDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={() => {
if (undeployActionDisabled)
return
setActionsOpen(false)
setShowUndeployConfirm(true)
}}
>
<span aria-hidden className="i-ri-logout-box-line size-4 shrink-0" />
<span className="system-sm-regular">{t('deployTab.undeploy')}</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
)}
</DropdownMenu>
)}
{!isUndeployed && !isDeploying && (
<AlertDialog
open={showUndeployConfirm}
onOpenChange={(open) => {
if (isUndeployRequesting)
return
setShowUndeployConfirm(open)
}}
>
<AlertDialogContent className="w-130">
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('deployTab.undeployConfirmTitle', { name: environmentName(row.environment) })}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-tertiary">
{t('deployTab.undeployConfirmDesc')}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary" disabled={isUndeployRequesting}>
{t('deployDrawer.cancel')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={isUndeployRequesting}
disabled={undeployActionDisabled}
onClick={handleUndeploy}
>
{t('deployTab.confirmUndeploy')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
}
function CurrentReleaseMobileSummary({ release }: {
release: EnvironmentDeployment['currentRelease']
}) {
const { t } = useTranslation('deployments')
if (!release?.id && !release?.name)
return null
return (
<div className="flex min-w-0 flex-col gap-1">
<span className="system-2xs-medium-uppercase text-text-tertiary">
{t('deployTab.col.currentRelease')}
</span>
<CurrentReleaseSummary release={release} />
</div>
)
}
function DeploymentEnvironmentMobileRow({ appInstanceId, row }: {
appInstanceId: string
row: EnvironmentDeployment
}) {
const envId = environmentId(row.environment)
const release = row.currentRelease
const status = deploymentStatus(row)
const showFailureBanner = status === 'deploy_failed' && Boolean(row.status)
return (
<div className="border-b border-divider-subtle last:border-b-0">
<div className="flex flex-col gap-3 p-4 text-left">
<div className="flex min-w-0 flex-col gap-1">
<EnvironmentSummary environment={row.environment} />
<DeploymentStatusSummary row={row} />
</div>
{!isUndeployedDeploymentRow(row) && <CurrentReleaseMobileSummary release={release} />}
<div className="flex min-w-0 items-center justify-start gap-2">
<DeploymentRowActions appInstanceId={appInstanceId} envId={envId} row={row} />
</div>
</div>
{showFailureBanner && (
<div className="flex items-center gap-2 border-l-2 border-util-colors-red-red-500 bg-util-colors-red-red-50 px-3 py-2 system-xs-regular text-util-colors-red-red-700">
<span aria-hidden className="i-ri-alert-line size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{row.status}</span>
</div>
)}
</div>
)
}
function DeploymentEnvironmentDesktopRows({ appInstanceId, rows }: {
appInstanceId: string
rows: EnvironmentDeployment[]
}) {
return (
<>
{rows.map((row, index) => {
const envId = environmentId(row.environment)
const status = deploymentStatus(row)
const showFailureBanner = status === 'deploy_failed' && Boolean(row.status)
const isLast = index === rows.length - 1
return (
<div
key={envId}
className={DETAIL_LIST_ROW_CLASS_NAME}
>
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div className="min-w-0">
<EnvironmentSummary environment={row.environment} />
</div>
<div className="min-w-0">
<DeploymentStatusSummary row={row} />
</div>
<div className="min-w-0">
<CurrentReleaseSummary release={row.currentRelease} />
</div>
<div className="flex w-8 justify-end">
<DeploymentRowActions appInstanceId={appInstanceId} envId={envId} row={row} />
</div>
</div>
{showFailureBanner && (
<div className={cn('flex items-center gap-2 border-t border-l-2 border-divider-subtle border-l-util-colors-red-red-500 bg-util-colors-red-red-50 px-4 py-2 system-xs-regular text-util-colors-red-red-700', isLast && 'rounded-b-lg')}>
<span aria-hidden className="i-ri-alert-line size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{row.status}</span>
</div>
)}
</div>
)
})}
</>
)
}
export function DeploymentEnvironmentList({ appInstanceId, rows }: {
appInstanceId: string
rows: EnvironmentDeployment[]
}) {
const { t } = useTranslation('deployments')
return (
<>
<div className={cn(DETAIL_LIST_CLASS_NAME, 'pc:hidden')}>
{rows.map(row => (
<DeploymentEnvironmentMobileRow
key={environmentId(row.environment)}
appInstanceId={appInstanceId}
row={row}
/>
))}
</div>
<div className="hidden pc:block">
<div className={DETAIL_LIST_CLASS_NAME}>
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div>{t('deployTab.col.environment')}</div>
<div>{t('deployTab.col.status')}</div>
<div>{t('deployTab.col.currentRelease')}</div>
<div className="text-right">{t('deployTab.col.actions')}</div>
</div>
<DeploymentEnvironmentDesktopRows appInstanceId={appInstanceId} rows={rows} />
</div>
</div>
</>
)
}

View File

@ -1,82 +0,0 @@
'use client'
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { releaseLabel } from '../../release'
import {
deploymentStatus,
isUndeployedDeploymentRow,
} from '../../runtime-status'
const StatusIconSlot = ({ children }: { children: ReactNode }) => {
return (
<span className="flex size-3 shrink-0 items-center justify-center">
{children}
</span>
)
}
export function DeploymentStatusSummary({ row }: {
row: EnvironmentDeployment
}) {
const { t } = useTranslation('deployments')
if (isUndeployedDeploymentRow(row)) {
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-text-tertiary">
<StatusIconSlot>
<span className="size-1.5 rounded-full bg-text-quaternary" />
</StatusIconSlot>
{t('status.notDeployed')}
</span>
)
}
const status = deploymentStatus(row)
if (status === 'deploying') {
const hasTargetRelease = !!(row.currentRelease?.name || row.currentRelease?.id)
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-blue-blue-700">
<StatusIconSlot>
<span className="i-ri-loader-4-line size-2 animate-spin" />
</StatusIconSlot>
{hasTargetRelease
? t('deployTab.status.deployingRelease', { release: releaseLabel(row.currentRelease) })
: t('status.deploying')}
</span>
)
}
if (status === 'deploy_failed') {
const hasRunningRelease = !!row.currentRelease?.id
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-red-red-700">
<StatusIconSlot>
<span className="i-ri-alert-line size-3" />
</StatusIconSlot>
{t(hasRunningRelease ? 'deployTab.status.runningWithFailed' : 'deployTab.status.deployFailed')}
</span>
)
}
if (status === 'unknown') {
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-text-tertiary">
<StatusIconSlot>
<span className="i-ri-question-line size-3" />
</StatusIconSlot>
{t('status.unknown')}
</span>
)
}
return (
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-green-green-700">
<StatusIconSlot>
<span className="size-1.5 rounded-full bg-util-colors-green-green-500" />
</StatusIconSlot>
{t('status.ready')}
</span>
)
}

View File

@ -1,269 +0,0 @@
'use client'
import type { ComponentProps, PropsWithoutRef } from 'react'
import type { InstanceDetailTabKey } from './tabs'
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery } from '@tanstack/react-query'
import { useHover, useKeyPress, useLocalStorageState } from 'ahooks'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { getAppModeLabel } from '@/app/components/app-sidebar/app-info/app-mode-labels'
import NavLink from '@/app/components/app-sidebar/nav-link'
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider'
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { toAppMode } from '../app-mode'
type TabDef = {
key: InstanceDetailTabKey
icon: NavIcon
selectedIcon: NavIcon
}
type DeploymentSidebarMode = 'expand' | 'collapse'
const DEPLOYMENT_SIDEBAR_MODE_KEY = 'deployment-sidebar-collapse-or-expand'
type TailwindNavIconProps = PropsWithoutRef<ComponentProps<'svg'>> & {
title?: string
titleId?: string
}
function OverviewIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-dashboard-2-line', className)} />
}
function OverviewSelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-dashboard-2-fill', className)} />
}
function DeployIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-rocket-line', className)} />
}
function DeploySelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-rocket-fill', className)} />
}
function VersionsIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-stack-line', className)} />
}
function VersionsSelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-stack-fill', className)} />
}
function SettingsIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-settings-3-line', className)} />
}
function SettingsSelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-settings-3-fill', className)} />
}
const TABS: TabDef[] = [
{ key: 'overview', icon: OverviewIcon, selectedIcon: OverviewSelectedIcon },
{ key: 'deploy', icon: DeployIcon, selectedIcon: DeploySelectedIcon },
{ key: 'releases', icon: VersionsIcon, selectedIcon: VersionsSelectedIcon },
{ key: 'settings', icon: SettingsIcon, selectedIcon: SettingsSelectedIcon },
]
function isShortcutFromInputArea(target: EventTarget | null) {
if (!(target instanceof HTMLElement))
return false
return target.tagName === 'INPUT'
|| target.tagName === 'TEXTAREA'
|| target.isContentEditable
}
function useDeploymentSidebarMode(isMobile: boolean) {
const [persistedMode, setPersistedMode] = useLocalStorageState<DeploymentSidebarMode>(
DEPLOYMENT_SIDEBAR_MODE_KEY,
{ defaultValue: 'expand' },
)
const sidebarMode = isMobile ? 'collapse' : persistedMode ?? 'expand'
function toggleSidebarMode() {
setPersistedMode(sidebarMode === 'expand' ? 'collapse' : 'expand')
}
return {
sidebarMode,
toggleSidebarMode,
}
}
type DeploymentSidebarProps = {
appInstanceId: string
}
function DeploymentSidebarInstanceInfo({ appInstanceId, expand }: {
appInstanceId: string
expand: boolean
}) {
const { t } = useTranslation('deployments')
const { t: tCommon } = useTranslation()
const overviewQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
input: {
params: { appInstanceId },
},
}))
const app = overviewQuery.data?.overview?.appInstance
const isLoading = !app?.id && overviewQuery.isLoading
const isUnavailable = !app?.id || overviewQuery.isError
const instanceName = app?.name ?? appInstanceId
const appModeLabel = app?.id ? getAppModeLabel(toAppMode(app.mode), tCommon) : ''
const sourceAppLink = app?.sourceAppId && app.sourceAppAvailable !== false ? `/app/${app.sourceAppId}/overview` : undefined
const sourceAppName = app?.sourceAppName ?? t('detail.sourceApp')
return (
<div className={cn('shrink-0', expand ? 'p-2' : 'p-1')}>
<div className={cn('flex flex-col gap-2 rounded-lg', expand ? 'p-1' : 'items-center p-1')}>
{isLoading
? (
<>
<SkeletonRectangle className={cn('my-0 animate-pulse rounded-lg', expand ? 'size-10' : 'size-8')} />
{expand && (
<SkeletonContainer className="w-full gap-1">
<SkeletonRectangle className="my-0 h-5 w-32 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-20 animate-pulse" />
</SkeletonContainer>
)}
</>
)
: isUnavailable
? (
<>
<div className="flex size-8 items-center justify-center rounded-lg bg-components-icon-bg-orange-solid text-text-primary-on-surface">
<span className="i-ri-rocket-line size-4" />
</div>
{expand && (
<div className="flex flex-col items-start gap-1">
<div className="truncate system-md-semibold whitespace-nowrap text-text-secondary">
{t('detail.notFound')}
</div>
<div className="max-w-full truncate font-mono system-2xs-regular text-text-tertiary" title={appInstanceId}>
{appInstanceId}
</div>
</div>
)}
</>
)
: (
<>
<div className="flex items-center gap-1">
<AppIcon
size={expand ? 'large' : 'medium'}
iconType="emoji"
icon={app.icon}
background={app.iconBackground}
/>
</div>
{expand && (
<div className="flex flex-col items-start gap-1">
<div className="flex w-full">
<div className="truncate system-md-semibold whitespace-nowrap text-text-secondary" title={instanceName}>
{instanceName}
</div>
</div>
<div className="flex max-w-full items-center gap-1.5 system-2xs-medium-uppercase text-text-tertiary">
<span className="shrink-0 whitespace-nowrap">{appModeLabel}</span>
{sourceAppLink && (
<>
<span aria-hidden className="shrink-0 text-text-quaternary">·</span>
<Link
href={sourceAppLink}
className="inline-flex min-w-0 items-center gap-0.5 rounded-sm text-text-tertiary transition-colors hover:text-text-secondary"
title={t('detail.openSourceApp', { name: sourceAppName })}
>
<span className="truncate">
{t('detail.sourceAppLink')}
</span>
<span aria-hidden className="i-ri-arrow-right-up-line size-3 shrink-0 text-text-quaternary" />
</Link>
</>
)}
</div>
{app.description && (
<div
className="line-clamp-2 system-xs-regular text-text-tertiary"
title={app.description}
>
{app.description}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
)
}
export function DeploymentSidebar({ appInstanceId }: DeploymentSidebarProps) {
const { t } = useTranslation('deployments')
const sidebarRef = useRef<HTMLDivElement>(null)
const isHoveringSidebar = useHover(sidebarRef)
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { sidebarMode, toggleSidebarMode } = useDeploymentSidebarMode(isMobile)
const expand = sidebarMode === 'expand'
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
if (isShortcutFromInputArea(e.target))
return
e.preventDefault()
toggleSidebarMode()
}, { exactMatch: true, useCapture: true })
return (
<aside
ref={sidebarRef}
className={cn(
'flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all',
expand ? 'w-54' : 'w-14',
)}
>
<DeploymentSidebarInstanceInfo appInstanceId={appInstanceId} expand={expand} />
<div className="relative px-4 py-2">
<Divider
type="horizontal"
bgStyle={expand ? 'gradient' : 'solid'}
className={cn(
'my-0 h-px',
expand
? 'bg-linear-to-r from-divider-subtle to-background-gradient-mask-transparent'
: 'bg-divider-subtle',
)}
/>
{!isMobile && isHoveringSidebar && (
<ToggleButton
className="absolute -top-1 -right-3 z-20"
expand={expand}
handleToggle={toggleSidebarMode}
/>
)}
</div>
<nav
className={cn(
'flex grow flex-col gap-y-0.5',
expand ? 'px-3 py-2' : 'p-3',
)}
>
{TABS.map(tab => (
<NavLink
key={tab.key}
mode={sidebarMode}
iconMap={{ selected: tab.selectedIcon, normal: tab.icon }}
name={t(`tabs.${tab.key}.name`)}
href={`/deployments/${appInstanceId}/${tab.key}`}
/>
))}
</nav>
</aside>
)
}

View File

@ -1,40 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import type { InstanceDetailTabKey } from './tabs'
import { useTranslation } from 'react-i18next'
import useDocumentTitle from '@/hooks/use-document-title'
import { useSelectedLayoutSegment } from '@/next/navigation'
import { DeployDrawer } from '../components/deploy-drawer'
import { DeploymentSidebar } from './deployment-sidebar'
import { isInstanceDetailTabKey } from './tabs'
export function InstanceDetail({ appInstanceId, children }: {
appInstanceId: string
children: ReactNode
}) {
const { t } = useTranslation('deployments')
const selectedSegment = useSelectedLayoutSegment()
const selectedTab = selectedSegment ?? undefined
const activeTab: InstanceDetailTabKey = isInstanceDetailTabKey(selectedTab) ? selectedTab : 'overview'
useDocumentTitle(t('documentTitle.detail'))
return (
<>
<div className="relative flex h-full min-w-0 overflow-hidden rounded-t-2xl shadow-xs">
<DeploymentSidebar appInstanceId={appInstanceId} />
<div className="min-w-0 grow overflow-hidden bg-components-panel-bg">
<div className="h-full min-w-0 overflow-y-auto">
<div className="mx-auto flex w-full max-w-[1280px] flex-col gap-y-0.5 px-6 pt-3 pb-2 2xl:max-w-[1440px]">
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
<div className="system-sm-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
</div>
{children}
</div>
</div>
</div>
<DeployDrawer />
</>
)
}

View File

@ -1,14 +0,0 @@
import { cn } from '@langgenius/dify-ui/cn'
export const DETAIL_LIST_CLASS_NAME = 'overflow-hidden rounded-lg border border-divider-subtle bg-background-default'
export const DETAIL_LIST_ROW_CLASS_NAME = 'border-b border-divider-subtle last:border-b-0 hover:bg-background-default-hover'
export const DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME = cn(
'inline-flex size-8 items-center justify-center rounded-md text-text-tertiary outline-hidden',
'hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary',
'disabled:cursor-not-allowed disabled:opacity-50',
)
export const DETAIL_LIST_HEADER_ROW_CLASS_NAME = 'grid min-h-8 items-center gap-6 border-b border-divider-subtle px-4 py-1.5 system-2xs-medium-uppercase text-text-tertiary'
export const DETAIL_LIST_DESKTOP_ROW_CLASS_NAME = 'grid min-h-12 items-center gap-6 px-4 py-2'
export const DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME = 'grid-cols-[minmax(160px,1fr)_minmax(150px,0.75fr)_minmax(180px,1fr)_auto]'
export const RELEASE_DETAIL_LIST_GRID_CLASS_NAME = 'grid-cols-[minmax(150px,1fr)_minmax(130px,0.75fr)_minmax(140px,0.8fr)_minmax(150px,1fr)_auto]'

View File

@ -1,160 +0,0 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { SectionState } from './common'
import { EnvironmentStrip, EnvironmentStripSkeleton } from './overview-tab/environment-strip'
import { computeOverviewStats } from './overview-tab/overview-drift'
import { ReleaseHero, ReleaseHeroSkeleton } from './overview-tab/release-hero'
import { useSourceAppAvailability } from './source-app-availability'
const OVERVIEW_RELEASE_WINDOW = 20
function OverviewLayout({ children }: { children: React.ReactNode }) {
return (
<div className="mx-auto flex w-full max-w-[1080px] min-w-0 flex-col gap-6 px-6 py-6">
{children}
</div>
)
}
function SourceAppDeletedNotice() {
const { t } = useTranslation('deployments')
return (
<section
role="status"
className="flex items-start gap-3 rounded-lg border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 px-4 py-3 text-util-colors-warning-warning-700"
>
<span aria-hidden className="mt-0.5 i-ri-error-warning-fill size-4 shrink-0" />
<div className="min-w-0">
<div className="system-sm-semibold text-util-colors-warning-warning-700">
{t('overview.sourceAppDeletedTitle')}
</div>
<p className="mt-1 system-sm-regular text-util-colors-warning-warning-700">
{t('overview.sourceAppDeletedDescription')}
</p>
</div>
</section>
)
}
function ReleaseOverviewSection({ appInstanceId, children }: {
appInstanceId: string
children: React.ReactNode
}) {
const { t } = useTranslation('deployments')
return (
<section className="flex min-w-0 flex-col gap-3">
<div className="flex min-w-0 items-baseline justify-between gap-3">
<h3 className="system-sm-semibold text-text-primary">
{t('overview.recentReleases')}
</h3>
<Link
href={`/deployments/${appInstanceId}/releases`}
className="inline-flex shrink-0 items-center gap-1 system-xs-medium text-text-tertiary transition-colors hover:text-text-secondary"
>
{t('overview.previousReleases.viewAll')}
<span aria-hidden className="i-ri-arrow-right-line size-3.5" />
</Link>
</div>
<div className="flex min-w-0 flex-col gap-3">
{children}
</div>
</section>
)
}
export function OverviewTab({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const input = { params: { appInstanceId } }
const overviewQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({ input }))
const runtimeInstancesQuery = useQuery(consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({ input }))
const releasesQuery = useQuery(consoleQuery.enterprise.appReleaseService.listReleases.queryOptions({
input: {
params: { appInstanceId },
query: { pageNumber: 1, resultsPerPage: OVERVIEW_RELEASE_WINDOW },
},
}))
const instance = overviewQuery.data?.overview?.appInstance
const sourceAppAvailability = useSourceAppAvailability(instance)
if (overviewQuery.isLoading) {
return (
<OverviewLayout>
<ReleaseOverviewSection appInstanceId={appInstanceId}>
<ReleaseHeroSkeleton />
</ReleaseOverviewSection>
</OverviewLayout>
)
}
if (overviewQuery.isError) {
return (
<OverviewLayout>
<SectionState>{t('common.loadFailed')}</SectionState>
</OverviewLayout>
)
}
if (!instance?.id) {
return (
<OverviewLayout>
<SectionState>{t('detail.notFound')}</SectionState>
</OverviewLayout>
)
}
if (releasesQuery.isLoading) {
return (
<OverviewLayout>
{sourceAppAvailability.sourceAppUnavailable && <SourceAppDeletedNotice />}
<ReleaseOverviewSection appInstanceId={appInstanceId}>
<ReleaseHeroSkeleton />
</ReleaseOverviewSection>
<EnvironmentStripSkeleton />
</OverviewLayout>
)
}
if (releasesQuery.isError) {
return (
<OverviewLayout>
<SectionState>{t('common.loadFailed')}</SectionState>
</OverviewLayout>
)
}
const releaseRows = releasesQuery.data?.data ?? []
const runtimeRows = runtimeInstancesQuery.data?.data?.filter(row => row.environment?.id) ?? []
const latestRelease = releaseRows[0]
const stats = computeOverviewStats(runtimeRows, releaseRows)
return (
<OverviewLayout>
{sourceAppAvailability.sourceAppUnavailable && <SourceAppDeletedNotice />}
<div className="flex min-w-0 flex-col gap-6">
<ReleaseOverviewSection appInstanceId={appInstanceId}>
<ReleaseHero
appInstanceId={appInstanceId}
latestRelease={latestRelease}
stats={stats}
/>
</ReleaseOverviewSection>
<EnvironmentStrip
appInstanceId={appInstanceId}
rows={runtimeRows}
releaseRows={releaseRows}
stats={stats}
isLoading={runtimeInstancesQuery.isLoading}
isError={runtimeInstancesQuery.isError}
/>
</div>
</OverviewLayout>
)
}

View File

@ -1,180 +0,0 @@
import type { EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
import { describe, expect, it } from 'vitest'
import { computeDrift, computeOverviewStats, latestReleaseId } from '../overview-drift'
function row(overrides: EnvironmentDeployment): EnvironmentDeployment {
return overrides
}
function release(overrides: ReleaseRow): ReleaseRow {
return overrides
}
describe('computeDrift', () => {
it('should return undeployed when the runtime row signals undeployed', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1', name: 'prod' },
status: 'undeployed',
})
// Act
const result = computeDrift(runtime, [])
// Assert
expect(result).toEqual({ kind: 'undeployed' })
})
it('should return undeployed when there is no id, current release, or detail', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1', name: 'prod' },
})
// Act
const result = computeDrift(runtime, [release({ id: 'r-1' })])
// Assert
expect(result).toEqual({ kind: 'undeployed' })
})
it('should return unknown when the deployed release has no id', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1' },
status: 'ready',
runtime: { runtimeInstanceId: 'rt-1', replicas: 1 },
})
// Act
const result = computeDrift(runtime, [release({ id: 'r-1' })])
// Assert
expect(result).toEqual({ kind: 'unknown' })
})
it('should return unknown when the deployed release is not in the loaded window', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1' },
status: 'ready',
runtime: { runtimeInstanceId: 'rt-1' },
currentRelease: { id: 'r-older' },
})
// Act
const result = computeDrift(runtime, [release({ id: 'r-3' }), release({ id: 'r-2' })])
// Assert
expect(result).toEqual({ kind: 'unknown' })
})
it('should return up-to-date when the deployed release is the newest in the window', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1' },
status: 'ready',
runtime: { runtimeInstanceId: 'rt-1' },
currentRelease: { id: 'r-3' },
})
// Act
const result = computeDrift(runtime, [
release({ id: 'r-3' }),
release({ id: 'r-2' }),
release({ id: 'r-1' }),
])
// Assert
expect(result).toEqual({ kind: 'up-to-date' })
})
it('should return behind with the index distance when the deployed release is older', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1' },
status: 'ready',
runtime: { runtimeInstanceId: 'rt-1' },
currentRelease: { id: 'r-1' },
})
// Act
const result = computeDrift(runtime, [
release({ id: 'r-3' }),
release({ id: 'r-2' }),
release({ id: 'r-1' }),
])
// Assert
expect(result).toEqual({ kind: 'behind', steps: 2 })
})
it('should return unknown when the release window is empty', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1' },
status: 'ready',
runtime: { runtimeInstanceId: 'rt-1' },
currentRelease: { id: 'r-1' },
})
// Act
const result = computeDrift(runtime, [])
// Assert
expect(result).toEqual({ kind: 'unknown' })
})
})
describe('latestReleaseId', () => {
it('should return the first release id', () => {
expect(latestReleaseId([release({ id: 'r-3' }), release({ id: 'r-2' })])).toBe('r-3')
})
it('should return undefined when the list is empty', () => {
expect(latestReleaseId([])).toBeUndefined()
})
it('should return undefined when the first release has no id', () => {
expect(latestReleaseId([release({})])).toBeUndefined()
})
})
describe('computeOverviewStats', () => {
const releases = [release({ id: 'r-3' }), release({ id: 'r-2' }), release({ id: 'r-1' })]
it('should classify each row into a single bucket', () => {
// Arrange
const rows: EnvironmentDeployment[] = [
row({ runtime: { runtimeInstanceId: 'rt-1' }, environment: { id: 'env-1' }, status: 'ready', currentRelease: { id: 'r-3' } }),
row({ runtime: { runtimeInstanceId: 'rt-2' }, environment: { id: 'env-2' }, status: 'ready', currentRelease: { id: 'r-1' } }),
row({ runtime: { runtimeInstanceId: 'rt-3' }, environment: { id: 'env-3' }, status: 'deploying', currentRelease: { id: 'r-3' } }),
row({ runtime: { runtimeInstanceId: 'rt-4' }, environment: { id: 'env-4' }, status: 'deploy_failed', currentRelease: { id: 'r-2' } }),
row({ environment: { id: 'env-5' }, status: 'undeployed' }),
]
// Act
const stats = computeOverviewStats(rows, releases)
// Assert
expect(stats).toEqual({ total: 5, ready: 1, behind: 1, failed: 1, deploying: 1, undeployed: 1 })
})
it('should not count failed envs as behind even when on an older release', () => {
// Arrange
const rows: EnvironmentDeployment[] = [
row({ runtime: { runtimeInstanceId: 'rt-1' }, environment: { id: 'env-1' }, status: 'deploy_failed', currentRelease: { id: 'r-1' } }),
]
// Act
const stats = computeOverviewStats(rows, releases)
// Assert
expect(stats.failed).toBe(1)
expect(stats.behind).toBe(0)
})
it('should return all zeros for an empty grid', () => {
expect(computeOverviewStats([], releases)).toEqual({ total: 0, ready: 0, behind: 0, failed: 0, deploying: 0, undeployed: 0 })
})
})

View File

@ -1,177 +0,0 @@
'use client'
import type { EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { useRouter } from '@/next/navigation'
import { environmentId, environmentName } from '../../environment'
import { releaseCommit, releaseLabel } from '../../release'
import { deploymentStatus } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import { computeDrift, latestReleaseId } from './overview-drift'
type EnvironmentChipProps = {
appInstanceId: string
row: EnvironmentDeployment
releaseRows: ReleaseRow[]
}
type ChipKind = 'empty' | 'latest' | 'behind' | 'older' | 'deploying' | 'failed'
type ChipConfig = {
kind: ChipKind
dotClass: string
suffixClass: string
showRelease: boolean
intent: 'drawer' | 'navigate' | 'disabled'
releaseId?: string
}
export function EnvironmentChip({ appInstanceId, row, releaseRows }: EnvironmentChipProps) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const router = useRouter()
const envId = environmentId(row.environment)
const drift = computeDrift(row, releaseRows)
const status = deploymentStatus(row)
const latestId = latestReleaseId(releaseRows)
const hasAnyRelease = releaseRows.length > 0
const currentReleaseId = row.currentRelease?.id
const config = resolveConfig({ drift, status, hasAnyRelease, latestId, currentReleaseId })
const suffix = renderSuffix(config.kind, drift, t)
const showRelease = config.showRelease && Boolean(row.currentRelease?.id)
const isDisabled = config.intent === 'disabled'
const tooltip = isDisabled ? t('overview.chip.needsReleaseFirst') : config.intent === 'navigate' ? t('overview.chip.openInDeployTab') : undefined
function handleClick() {
if (config.intent === 'disabled')
return
if (config.intent === 'navigate') {
router.push(`/deployments/${appInstanceId}/deploy`)
return
}
openDeployDrawer({ appInstanceId, environmentId: envId, releaseId: config.releaseId })
}
return (
<button
type="button"
disabled={isDisabled}
title={tooltip}
onClick={handleClick}
className={cn(
'inline-flex max-w-[280px] items-center gap-2 rounded-full border px-3 py-1.5 system-xs-medium transition-colors',
'border-divider-subtle bg-components-panel-bg text-text-secondary',
'hover:bg-state-base-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-components-panel-bg',
)}
>
<span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', config.dotClass)} />
<span className="truncate text-text-primary">{environmentName(row.environment)}</span>
{showRelease && (
<span className="flex shrink-0 items-baseline gap-1 font-mono text-text-tertiary">
<span>{releaseLabel(row.currentRelease)}</span>
<span className="system-2xs-regular">{releaseCommit(row.currentRelease)}</span>
</span>
)}
<span aria-hidden className="text-text-quaternary">·</span>
<span className={cn('shrink-0', config.suffixClass)}>{suffix}</span>
</button>
)
}
function resolveConfig({ drift, status, hasAnyRelease, latestId, currentReleaseId }: {
drift: ReturnType<typeof computeDrift>
status: ReturnType<typeof deploymentStatus>
hasAnyRelease: boolean
latestId: string | undefined
currentReleaseId: string | undefined
}): ChipConfig {
if (status === 'deploying') {
return {
kind: 'deploying',
dotClass: 'bg-util-colors-blue-blue-500 animate-pulse',
suffixClass: 'text-util-colors-blue-blue-700',
showRelease: false,
intent: 'navigate',
}
}
if (status === 'deploy_failed') {
return {
kind: 'failed',
dotClass: 'bg-util-colors-red-red-500',
suffixClass: 'text-util-colors-red-red-700',
showRelease: true,
intent: 'drawer',
releaseId: currentReleaseId ?? latestId,
}
}
if (drift.kind === 'undeployed') {
return {
kind: 'empty',
dotClass: 'bg-text-quaternary',
suffixClass: 'text-text-quaternary',
showRelease: false,
intent: hasAnyRelease ? 'drawer' : 'disabled',
releaseId: latestId,
}
}
if (drift.kind === 'up-to-date') {
return {
kind: 'latest',
dotClass: 'bg-util-colors-green-green-500',
suffixClass: 'text-util-colors-green-green-700',
showRelease: true,
intent: 'drawer',
releaseId: currentReleaseId,
}
}
if (drift.kind === 'behind') {
return {
kind: 'behind',
dotClass: 'bg-util-colors-green-green-500',
suffixClass: 'text-util-colors-warning-warning-700',
showRelease: true,
intent: 'drawer',
releaseId: latestId,
}
}
return {
kind: 'older',
dotClass: 'bg-util-colors-green-green-500',
suffixClass: 'text-text-tertiary',
showRelease: true,
intent: 'drawer',
releaseId: latestId,
}
}
function renderSuffix(
kind: ChipKind,
drift: ReturnType<typeof computeDrift>,
t: ReturnType<typeof useTranslation<'deployments'>>['t'],
): string {
switch (kind) {
case 'empty':
return t('overview.chip.empty')
case 'latest':
return t('overview.chip.latest')
case 'behind':
return t('overview.chip.behind', { count: drift.kind === 'behind' ? drift.steps : 0 })
case 'older':
return t('overview.chip.olderRelease')
case 'deploying':
return t('overview.chip.deploying')
case 'failed':
return t('overview.chip.failed')
}
}

View File

@ -1,104 +0,0 @@
'use client'
import type { EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
import type { OverviewStats } from './overview-drift'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import Link from '@/next/link'
import { environmentId } from '../../environment'
import { SectionState } from '../common'
import { EnvironmentChip } from './environment-chip'
type EnvironmentStripProps = {
appInstanceId: string
rows: EnvironmentDeployment[]
releaseRows: ReleaseRow[]
stats: OverviewStats
isLoading: boolean
isError: boolean
}
export function EnvironmentStrip({ appInstanceId, rows, releaseRows, stats, isLoading, isError }: EnvironmentStripProps) {
const { t } = useTranslation('deployments')
const onLatest = stats.ready
const showSummary = stats.total > 0
return (
<section className="flex flex-col gap-3">
<div className="flex min-w-0 items-baseline gap-2">
<h3 className="system-sm-semibold text-text-primary">{t('overview.strip.title')}</h3>
{showSummary && (
<>
<span aria-hidden className="text-text-quaternary">·</span>
<span className="system-xs-regular text-text-tertiary">
{t('overview.strip.summary', { count: onLatest, total: stats.total })}
</span>
</>
)}
</div>
{stats.failed > 0 && <FailureBanner appInstanceId={appInstanceId} failedCount={stats.failed} t={t} />}
{isLoading
? <ChipSkeletons />
: isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: rows.length === 0
? <SectionState>{t('overview.strip.empty')}</SectionState>
: (
<div className="flex flex-wrap gap-2">
{rows.map(row => (
<EnvironmentChip
key={environmentId(row.environment)}
appInstanceId={appInstanceId}
row={row}
releaseRows={releaseRows}
/>
))}
</div>
)}
</section>
)
}
function FailureBanner({ appInstanceId, failedCount, t }: {
appInstanceId: string
failedCount: number
t: ReturnType<typeof useTranslation<'deployments'>>['t']
}) {
return (
<div className="flex items-center gap-2 rounded-lg border border-util-colors-red-red-200 bg-util-colors-red-red-50 px-3 py-2 system-sm-regular text-util-colors-red-red-700">
<span aria-hidden className="i-ri-error-warning-fill size-4 shrink-0" />
<span className="min-w-0 grow truncate">
{t('overview.strip.failedAlert', { count: failedCount })}
</span>
<Link
href={`/deployments/${appInstanceId}/deploy`}
className="shrink-0 system-xs-medium underline-offset-2 hover:underline"
>
{t('overview.strip.investigate')}
</Link>
</div>
)
}
const SKELETON_KEYS = ['a', 'b', 'c', 'd']
function ChipSkeletons() {
return (
<div className="flex flex-wrap gap-2">
{SKELETON_KEYS.map(key => (
<SkeletonRectangle key={key} className="my-0 h-7 w-32 animate-pulse rounded-full" />
))}
</div>
)
}
export function EnvironmentStripSkeleton() {
return (
<section className="flex flex-col gap-3">
<SkeletonRectangle className="my-0 h-3 w-44 animate-pulse" />
<ChipSkeletons />
</section>
)
}

View File

@ -1,64 +0,0 @@
import type { EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
export type Drift
= | { kind: 'undeployed' }
| { kind: 'unknown' }
| { kind: 'up-to-date' }
| { kind: 'behind', steps: number }
export function computeDrift(row: EnvironmentDeployment, releaseRows: ReleaseRow[]): Drift {
if (isUndeployedDeploymentRow(row))
return { kind: 'undeployed' }
const currentReleaseId = row.currentRelease?.id
if (!currentReleaseId)
return { kind: 'unknown' }
const idx = releaseRows.findIndex(release => release.id === currentReleaseId)
if (idx === -1)
return { kind: 'unknown' }
if (idx === 0)
return { kind: 'up-to-date' }
return { kind: 'behind', steps: idx }
}
export function latestReleaseId(releaseRows: ReleaseRow[]): string | undefined {
return releaseRows[0]?.id || undefined
}
export type OverviewStats = {
total: number
ready: number
behind: number
failed: number
deploying: number
undeployed: number
}
export function computeOverviewStats(rows: EnvironmentDeployment[], releaseRows: ReleaseRow[]): OverviewStats {
const stats: OverviewStats = { total: rows.length, ready: 0, behind: 0, failed: 0, deploying: 0, undeployed: 0 }
for (const row of rows) {
const drift = computeDrift(row, releaseRows)
if (drift.kind === 'undeployed') {
stats.undeployed += 1
continue
}
const status = deploymentStatus(row)
if (status === 'deploy_failed') {
stats.failed += 1
continue
}
if (status === 'deploying') {
stats.deploying += 1
continue
}
if (drift.kind === 'behind') {
stats.behind += 1
continue
}
if (status === 'ready')
stats.ready += 1
}
return stats
}

View File

@ -1,103 +0,0 @@
'use client'
import type { ReleaseRow } from '@dify/contracts/enterprise/types.gen'
import type { OverviewStats } from './overview-drift'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { formatDate, releaseLabel } from '../../release'
import { CreateReleaseControl } from '../versions-tab/create-release-control'
type ReleaseHeroProps = {
appInstanceId: string
latestRelease?: ReleaseRow
stats: OverviewStats
}
export function ReleaseHero({ appInstanceId, latestRelease, stats }: ReleaseHeroProps) {
const { t, i18n } = useTranslation('deployments')
const { formatTimeFromNow } = useFormatTimeFromNow()
const hasRelease = Boolean(latestRelease?.id)
const author = latestRelease?.createdBy?.name ?? ''
const ago = latestRelease?.createdAt ? formatTimeFromNow(new Date(latestRelease.createdAt).getTime()) : ''
const deployedEnvironmentNames = Array.from(new Set(
latestRelease?.deployedTo
?.map(item => item.environmentName || item.environmentId)
.filter((name): name is string => Boolean(name)) ?? [],
))
const deployedTargets = deployedEnvironmentNames.join(i18n.language.startsWith('zh') ? '、' : ', ')
const metaParts: { key: string, value: string }[] = []
if (author)
metaParts.push({ key: 'author', value: t('overview.hero.byName', { name: author }) })
if (ago)
metaParts.push({ key: 'ago', value: ago })
if (deployedTargets)
metaParts.push({ key: 'deployedTo', value: `${t('versions.col.deployedTo')} ${deployedTargets}` })
else if (hasRelease && stats.total === 0)
metaParts.push({ key: 'untargeted', value: t('overview.hero.untargeted') })
return (
<div className="overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg">
<div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
<div className="flex min-w-0 flex-col gap-2">
{hasRelease
? (
<>
<div className="flex min-w-0 items-center gap-3">
<span
aria-hidden
className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700"
>
<span className="i-ri-stack-fill size-5" />
</span>
<h2 className="truncate font-mono text-2xl font-semibold text-text-primary">
{releaseLabel(latestRelease)}
</h2>
</div>
{metaParts.length > 0 && (
<p
className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-1 system-sm-regular text-text-tertiary"
title={latestRelease?.createdAt ? formatDate(latestRelease.createdAt) : undefined}
>
{metaParts.map((part, index) => (
<span key={part.key} className="inline-flex items-baseline gap-1.5">
{index > 0 && <span aria-hidden className="text-text-quaternary">·</span>}
<span>{part.value}</span>
</span>
))}
</p>
)}
</>
)
: (
<>
<h2 className="system-xl-semibold text-text-primary">
{t('overview.hero.empty')}
</h2>
<p className="max-w-[640px] system-sm-regular text-text-tertiary">
{t('overview.hero.emptyDescription')}
</p>
</>
)}
</div>
<div className="shrink-0">
<CreateReleaseControl appInstanceId={appInstanceId} size="medium" />
</div>
</div>
</div>
)
}
export function ReleaseHeroSkeleton() {
return (
<div className="flex flex-col gap-4 rounded-xl border border-components-panel-border bg-components-panel-bg p-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-3">
<SkeletonRectangle className="my-0 h-7 w-40 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-60 animate-pulse" />
</div>
<SkeletonRectangle className="my-0 h-9 w-32 animate-pulse rounded-lg" />
</div>
)
}

View File

@ -1,414 +0,0 @@
'use client'
import type { AppInstanceBasicInfo, GetAppInstanceSettingsReply } from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import Textarea from '@/app/components/base/textarea'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { isUndeployedDeploymentRow } from '../runtime-status'
import { Section, SectionState } from './common'
import { AccessChannelsSection } from './settings-tab/access/channels-section'
import { DeveloperApiSection } from './settings-tab/access/developer-api-section'
import { AccessPermissionsSection } from './settings-tab/access/permissions-section'
type AppInstanceWithId = AppInstanceBasicInfo & { id: string }
const SETTINGS_FORM_SKELETON_FIELDS = [
{ key: 'name', inputClassName: 'my-0 h-8 w-full animate-pulse rounded-lg' },
{ key: 'description', inputClassName: 'my-0 h-24 w-full animate-pulse rounded-lg' },
]
function SettingsFormSkeleton() {
return (
<div className="flex flex-col gap-3">
{SETTINGS_FORM_SKELETON_FIELDS.map(field => (
<div key={field.key} className="flex flex-col gap-2">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRectangle className={field.inputClassName} />
</div>
))}
<SkeletonRow className="justify-end gap-2">
<SkeletonRectangle className="my-0 h-8 w-18 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-8 w-16 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
)
}
function DeleteInstanceSkeleton() {
return (
<div className="flex min-h-9 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<SkeletonRectangle className="h-3 w-3/5 animate-pulse" />
<SkeletonRow className="items-center justify-between gap-2">
<SkeletonRectangle className="h-3 w-48 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-18 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
)
}
type DeleteInstanceControlProps = {
app: AppInstanceWithId
settings?: GetAppInstanceSettingsReply
hasDeployments: boolean
}
function DeleteInstanceButton({
app,
settings,
hasDeployments,
}: DeleteInstanceControlProps) {
const { t } = useTranslation('deployments')
const router = useRouter()
const deleteInstance = useMutation(consoleQuery.enterprise.appInstanceService.deleteAppInstance.mutationOptions())
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const appInstanceId = app.id
const appName = app.name ?? appInstanceId
const canDelete = !hasDeployments && Boolean(settings)
const handleDelete = () => {
deleteInstance.mutate(
{
params: {
appInstanceId,
},
},
{
onSuccess: () => {
toast.success(t('settings.deleted'))
router.push('/deployments')
},
onError: () => {
toast.error(t('settings.deleteFailed'))
},
onSettled: () => {
setShowDeleteConfirm(false)
},
},
)
}
return (
<>
<Button
variant="primary"
tone="destructive"
disabled={!canDelete || deleteInstance.isPending}
onClick={() => setShowDeleteConfirm(true)}
>
{t('settings.delete')}
</Button>
<AlertDialog open={showDeleteConfirm} onOpenChange={open => !open && setShowDeleteConfirm(false)}>
<AlertDialogContent className="w-130">
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('settings.deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-tertiary">
{t('settings.deleteConfirmDesc', { name: appName })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary">
{t('createModal.cancel')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleDelete}>
{t('settings.delete')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
function DeleteInstanceControl({
app,
settings,
hasDeployments,
}: DeleteInstanceControlProps) {
const { t } = useTranslation('deployments')
return (
<div className="flex min-h-9 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="system-xs-regular text-text-secondary">
{hasDeployments
? t('settings.undeployFirst')
: t('settings.safeToDelete')}
</div>
<DeleteInstanceButton
app={app}
settings={settings}
hasDeployments={hasDeployments}
/>
</div>
)
}
function DangerSection({ children }: {
children: ReactNode
}) {
const { t } = useTranslation('deployments')
return (
<section className="border-b border-divider-subtle py-4 first:pt-0 last:border-b-0 last:pb-0">
<div className="flex flex-col gap-3 sm:flex-row sm:gap-x-6">
<div className="flex min-w-0 shrink-0 flex-col sm:w-40 sm:pt-1">
<div className="system-sm-semibold text-util-colors-red-red-700">
{t('settings.danger')}
</div>
<p className="mt-1 body-xs-regular text-util-colors-red-red-600">
{t('settings.dangerDesc')}
</p>
</div>
<div className="min-w-0 grow">
{children}
</div>
</div>
</section>
)
}
function SettingsForm({ app, settings }: {
app: AppInstanceWithId
settings?: GetAppInstanceSettingsReply
}) {
const { t } = useTranslation('deployments')
const updateInstance = useMutation(consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions())
const appName = app.name ?? app.id
const [name, setName] = useState(settings?.name ?? appName)
const [description, setDescription] = useState(settings?.description ?? app.description ?? '')
const initialName = settings?.name ?? appName
const initialDescription = settings?.description ?? app.description ?? ''
const canSave = Boolean(name.trim() && (name !== initialName || description !== initialDescription) && !updateInstance.isPending)
const handleSave = () => {
const appInstanceId = app.id
if (!canSave)
return
updateInstance.mutate(
{
params: {
appInstanceId,
},
body: {
appInstanceId,
name: name.trim(),
description: description.trim() || undefined,
},
},
{
onSuccess: () => {
toast.success(t('settings.updated'))
},
onError: () => {
toast.error(t('settings.updateFailed'))
},
},
)
}
return (
<Section
title={t('settings.general')}
description={t('settings.descriptionHelp')}
layout="row"
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="settings-name">
{t('settings.name')}
</label>
<Input
id="settings-name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="h-8"
/>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="settings-desc">
{t('settings.description')}
</label>
<Textarea
id="settings-desc"
value={description}
onChange={e => setDescription(e.target.value)}
className="min-h-24"
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
disabled={updateInstance.isPending || (name === initialName && description === initialDescription)}
onClick={() => {
setName(initialName)
setDescription(initialDescription)
}}
>
{t('settings.reset')}
</Button>
<Button variant="primary" disabled={!canSave} onClick={handleSave}>
{t('settings.save')}
</Button>
</div>
</div>
</Section>
)
}
function SettingsFormSection({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const appInput = { params: { appInstanceId } }
const overviewQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
input: appInput,
}))
const overview = overviewQuery.data
const app = overview?.overview?.appInstance
const settingsQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceSettings.queryOptions({
input: appInput,
}))
if (overviewQuery.isLoading || settingsQuery.isLoading) {
return (
<Section
title={t('settings.general')}
description={t('settings.descriptionHelp')}
layout="row"
>
<SettingsFormSkeleton />
</Section>
)
}
if (overviewQuery.isError || settingsQuery.isError) {
return (
<Section
title={t('settings.general')}
description={t('settings.descriptionHelp')}
layout="row"
>
<SectionState>{t('common.loadFailed')}</SectionState>
</Section>
)
}
if (!app?.id) {
return (
<Section
title={t('settings.general')}
description={t('settings.descriptionHelp')}
layout="row"
>
<SectionState>{t('detail.notFound')}</SectionState>
</Section>
)
}
const appName = app.name ?? app.id
const formKey = `${app.id}-${settingsQuery.data?.name ?? appName}-${settingsQuery.data?.description ?? app.description ?? ''}`
const appWithId = {
...app,
id: app.id,
}
return (
<SettingsForm
key={formKey}
app={appWithId}
settings={settingsQuery.data}
/>
)
}
function DeleteInstanceControlSection({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const appInput = { params: { appInstanceId } }
const overviewQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
input: appInput,
}))
const overview = overviewQuery.data
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({
input: appInput,
}))
const settingsQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceSettings.queryOptions({
input: appInput,
}))
const environmentDeployments = environmentDeploymentsQuery.data
const app = overview?.overview?.appInstance
if (overviewQuery.isLoading || environmentDeploymentsQuery.isLoading || settingsQuery.isLoading) {
return (
<DangerSection>
<DeleteInstanceSkeleton />
</DangerSection>
)
}
if (overviewQuery.isError || environmentDeploymentsQuery.isError || settingsQuery.isError) {
return (
<DangerSection>
<SectionState>{t('common.loadFailed')}</SectionState>
</DangerSection>
)
}
if (!app?.id) {
return (
<DangerSection>
<SectionState>{t('detail.notFound')}</SectionState>
</DangerSection>
)
}
const hasDeployments = environmentDeployments?.data?.some(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? false
const appWithId = {
...app,
id: app.id,
}
return (
<DangerSection>
<DeleteInstanceControl
app={appWithId}
settings={settingsQuery.data}
hasDeployments={hasDeployments}
/>
</DangerSection>
)
}
export function SettingsTab({ appInstanceId }: {
appInstanceId: string
}) {
return (
<div className="mx-auto flex w-full max-w-[1080px] min-w-0 flex-col gap-y-4 px-6 py-6 sm:py-8">
<AccessPermissionsSection appInstanceId={appInstanceId} />
<AccessChannelsSection appInstanceId={appInstanceId} />
<DeveloperApiSection appInstanceId={appInstanceId} />
<SettingsFormSection appInstanceId={appInstanceId} />
<DeleteInstanceControlSection appInstanceId={appInstanceId} />
</div>
)
}

View File

@ -1,160 +0,0 @@
'use client'
import type { AppDeployEnvironment, DeveloperApiKeyRow } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { environmentName } from '../../../environment'
function ApiKeyRow({ appInstanceId, apiKey }: {
appInstanceId: string
apiKey: DeveloperApiKeyRow
}) {
const { t } = useTranslation('deployments')
const revokeApiKey = useMutation(consoleQuery.enterprise.appDeployAccessService.deleteDeveloperApiKey.mutationOptions())
const displayValue = apiKey.maskedKey || apiKey.id || '—'
const environmentLabel = environmentName(apiKey.environment)
function handleRevoke() {
const environmentId = apiKey.environment?.id
if (!apiKey.id || !environmentId)
return
revokeApiKey.mutate({
params: {
appInstanceId,
environmentId,
apiKeyId: apiKey.id,
},
})
}
return (
<div className="flex items-center gap-3 py-1.5">
<div className="flex min-w-35 flex-col">
<span className="system-sm-medium text-text-primary">{apiKey.name || apiKey.id}</span>
<span className="system-xs-regular text-text-tertiary">
{t('access.api.envPrefix', { env: environmentLabel })}
</span>
</div>
<div className="flex min-w-0 flex-1 items-center gap-1 rounded-lg border border-components-input-border-active bg-components-input-bg-normal pr-1 pl-2">
<div className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary">
{displayValue}
</div>
<button
type="button"
onClick={handleRevoke}
aria-label={t('access.revoke')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"
>
<span className="i-ri-delete-bin-line size-3.5" />
</button>
</div>
</div>
)
}
export function ApiKeyList({ appInstanceId, apiKeys }: {
appInstanceId: string
apiKeys: DeveloperApiKeyRow[]
}) {
return (
<div className="flex flex-col divide-y divide-divider-subtle">
{apiKeys.map(apiKey => (
<ApiKeyRow
key={apiKey.id}
appInstanceId={appInstanceId}
apiKey={apiKey}
/>
))}
</div>
)
}
export function ApiKeyGenerateMenu({ appInstanceId, environments, apiKeys, onCreatedToken }: {
appInstanceId: string
environments: AppDeployEnvironment[]
apiKeys: DeveloperApiKeyRow[]
onCreatedToken: (token: string) => void
}) {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const generateApiKey = useMutation(consoleQuery.enterprise.appDeployAccessService.createDeveloperApiKey.mutationOptions())
const selectableEnvironments = environments.filter(env => env.id)
const disabled = selectableEnvironments.length === 0
function createApiKeyLabel(environmentId: string) {
const existingCount = apiKeys.filter(key =>
key.environment?.id === environmentId,
).length
const name = environments.find(env => env.id === environmentId)?.name ?? 'env'
return `${name}-key-${String(existingCount + 1).padStart(3, '0')}`
}
function handleGenerateApiKey(environmentId: string) {
generateApiKey.mutate(
{
params: {
appInstanceId,
environmentId,
},
body: {
appInstanceId,
environmentId,
name: createApiKeyLabel(environmentId),
},
},
{
onSuccess: (response) => {
if (response.token)
onCreatedToken(response.token)
},
},
)
}
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
className={cn(
'inline-flex h-8 items-center gap-1.5 rounded-lg px-3 system-sm-medium',
'border border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-text',
'hover:bg-components-button-secondary-bg-hover',
disabled && 'cursor-not-allowed opacity-50',
)}
>
<span className="i-ri-add-line size-3.5" />
{t('access.api.newKey')}
<span className="i-ri-arrow-down-s-line size-3.5" />
</DropdownMenuTrigger>
{open && !disabled && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-55">
{selectableEnvironments.map(env => (
<DropdownMenuItem
key={env.id}
className="gap-2 px-3"
onClick={() => {
setOpen(false)
handleGenerateApiKey(env.id!)
}}
>
<span className="system-sm-regular text-text-secondary">
{t('access.api.newKeyForEnv', { env: environmentName(env) })}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
)}
</DropdownMenu>
)
}

View File

@ -1,198 +0,0 @@
'use client'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { environmentName } from '../../../environment'
import { webappUrl } from '../../../webapp-url'
import { Section, SectionState } from '../../common'
import { CopyPill, EndpointRow } from './common'
import { getUrlOrigin } from './url'
const ACCESS_CHANNEL_SKELETON_SECTIONS = [
{ key: 'webapp', className: 'flex flex-col gap-2' },
{ key: 'cli', className: 'flex flex-col gap-2 border-t border-divider-subtle pt-3' },
]
function AccessChannelsSwitch({ appInstanceId, checked, disabled }: {
appInstanceId: string
checked: boolean
disabled?: boolean
}) {
const toggleAccessChannel = useMutation(consoleQuery.enterprise.appDeployAccessService.updateAccessChannels.mutationOptions())
return (
<Switch
checked={checked}
disabled={disabled}
onCheckedChange={(enabled) => {
toggleAccessChannel.mutate({
params: { appInstanceId },
body: { appInstanceId, enabled },
})
}}
/>
)
}
function AccessChannelsSkeleton() {
return (
<div className="flex flex-col gap-5">
{ACCESS_CHANNEL_SKELETON_SECTIONS.map(section => (
<div
key={section.key}
className={section.className}
>
<SkeletonRow className="items-center gap-2">
<SkeletonRectangle className="h-3.5 w-24 animate-pulse" />
<SkeletonRectangle className="my-0 h-5 w-24 animate-pulse rounded-full" />
</SkeletonRow>
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
<SkeletonRow className="flex-wrap items-center gap-x-3 gap-y-1.5">
<SkeletonRectangle className="h-3 w-35 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 min-w-65 flex-1 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-8 w-24 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
))}
</div>
)
}
export function AccessChannelsSection({
appInstanceId,
}: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const accessConfigQuery = useQuery(consoleQuery.enterprise.appDeployAccessService.getAppInstanceAccess.queryOptions({
input: {
params: { appInstanceId },
},
}))
const accessConfig = accessConfigQuery.data
const runEnabled = accessConfig?.accessChannels?.enabled ?? false
const webappRows = accessConfig?.accessChannels?.webappRows?.filter(row => row.url) ?? []
const cliDomain = getUrlOrigin(accessConfig?.accessChannels?.cli?.url)
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
return (
<Section
title={t('access.channels.title')}
description={t('access.channels.description')}
layout="row"
action={(
accessConfigQuery.isLoading
? <SwitchSkeleton />
: (
<AccessChannelsSwitch
appInstanceId={appInstanceId}
checked={runEnabled}
disabled={accessConfigQuery.isError}
/>
)
)}
>
{accessConfigQuery.isLoading
? <AccessChannelsSkeleton />
: accessConfigQuery.isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: runEnabled
? (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3.5">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="system-sm-medium text-text-primary">
{t('access.runAccess.webapp')}
</div>
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
{t('access.channels.followPermission')}
</span>
</div>
<div className="system-xs-regular text-text-tertiary">
{t('access.runAccess.webappDesc')}
</div>
{webappRows.length > 0
? (
<div className="flex flex-col gap-1.5">
{webappRows.map((row) => {
const endpointUrl = webappUrl(row.url)
return (
<EndpointRow
key={`webapp-${row.environment?.id ?? row.url}`}
envName={environmentName(row.environment)}
label={t('access.runAccess.urlLabel')}
value={endpointUrl}
openLabel={t('access.runAccess.openWebapp')}
/>
)
})}
</div>
)
: (
<SectionState>
{t('access.runAccess.webappEmpty')}
</SectionState>
)}
</div>
<div className="flex flex-col gap-2 border-t border-divider-subtle pt-3.5">
<div className="flex items-center gap-2">
<div className="system-sm-medium text-text-primary">
{t('access.cli.title')}
</div>
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
{t('access.channels.followPermission')}
</span>
</div>
<div className="system-xs-regular text-text-tertiary">
{t('access.cli.description')}
</div>
{cliDomain
? (
<div className="flex flex-wrap items-center gap-2">
<CopyPill
label={t('access.cli.domain')}
value={cliDomain}
className="min-w-0 flex-1"
/>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-download-cloud-2-line size-3.5" />
{t('access.cli.install')}
</a>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-book-open-line size-3.5" />
{t('access.cli.docs')}
</a>
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.cli.empty')}
</div>
)}
</div>
</div>
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.channels.disabled')}
</div>
)}
</Section>
)
}

View File

@ -1,78 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useTranslation } from 'react-i18next'
import { useClipboard } from '@/hooks/use-clipboard'
type CopyPillProps = {
label: string
value: string
prefix?: ReactNode
className?: string
}
export function CopyPill({ label, value, prefix, className }: CopyPillProps) {
const { t } = useTranslation('deployments')
const { copied, copy } = useClipboard({
onCopyError: () => {
toast.error(t('access.copyFailed'))
},
})
return (
<div
className={cn(
'flex h-8 items-center gap-1 rounded-lg border border-components-input-border-active bg-components-input-bg-normal pr-1 pl-1.5',
className,
)}
>
<div className="flex h-5 shrink-0 items-center rounded-md border border-divider-subtle px-1.5 system-2xs-medium text-text-tertiary">
{label}
</div>
{prefix}
<div className="min-w-0 flex-1 truncate px-1 font-mono system-sm-medium text-text-secondary">
{value}
</div>
<div className="h-3.5 w-px shrink-0 bg-divider-regular" />
<button
type="button"
onClick={() => copy(value)}
aria-label={t('access.copy')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span className={cn(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'size-3.5')} />
</button>
</div>
)
}
type EndpointRowProps = {
envName: string
label: string
value: string
openLabel?: string
}
export function EndpointRow({ envName, label, value, openLabel }: EndpointRowProps) {
return (
<div className="grid items-center gap-x-3 gap-y-1.5 sm:grid-cols-[minmax(88px,108px)_minmax(0,1fr)_auto]">
<span className="min-w-0 truncate system-xs-regular text-text-tertiary">
{envName}
</span>
<CopyPill label={label} value={value} className="min-w-0" />
{openLabel && (
<a
href={value}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-external-link-line size-3.5" />
{openLabel}
</a>
)}
</div>
)
}

View File

@ -1,207 +0,0 @@
'use client'
import type {
AppDeployEnvironment,
EnvironmentAccessRow,
} from '@dify/contracts/enterprise/types.gen'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { Section, SectionState } from '../../common'
import { ApiKeyGenerateMenu, ApiKeyList } from './api-keys'
import { CopyPill } from './common'
type CreatedApiToken = {
appInstanceId: string
token: string
}
const DEVELOPER_API_KEY_SKELETON_KEYS = ['primary-key', 'secondary-key']
function permissionEnvironment(row: EnvironmentAccessRow): AppDeployEnvironment | undefined {
return row.environment?.id ? row.environment : undefined
}
function DeveloperApiSwitch({ appInstanceId, checked, disabled }: {
appInstanceId: string
checked: boolean
disabled?: boolean
}) {
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.appDeployAccessService.updateDeveloperApi.mutationOptions())
return (
<Switch
checked={checked}
disabled={disabled}
onCheckedChange={(enabled) => {
toggleDeveloperAPI.mutate({
params: { appInstanceId },
body: { appInstanceId, enabled },
})
}}
/>
)
}
function CreatedApiTokenCard({ token, onDismiss }: {
token: string
onDismiss: () => void
}) {
const { t } = useTranslation('deployments')
return (
<div className="flex flex-col gap-2 border-y border-divider-subtle bg-background-default-subtle px-3 py-2.5">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="system-sm-medium text-text-primary">
{t('access.api.newTokenTitle')}
</span>
<span className="system-xs-regular text-text-tertiary">
{t('access.api.newTokenDescription')}
</span>
</div>
<button
type="button"
onClick={onDismiss}
aria-label={t('access.api.dismissToken')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span className="i-ri-close-line size-3.5" />
</button>
</div>
<CopyPill
label={t('access.api.newTokenLabel')}
value={token}
/>
</div>
)
}
function DeveloperApiSkeleton() {
return (
<div className="flex flex-col gap-2">
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
<SkeletonRow className="items-center justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1.5">
<SkeletonRectangle className="h-3.5 w-28 animate-pulse" />
<SkeletonRectangle className="h-3 w-40 animate-pulse" />
</div>
<SkeletonRectangle className="my-0 h-8 w-24 animate-pulse rounded-lg" />
</SkeletonRow>
<div className="flex flex-col divide-y divide-divider-subtle">
{DEVELOPER_API_KEY_SKELETON_KEYS.map(key => (
<SkeletonRow key={key} className="items-center gap-3 py-1.5">
<div className="flex min-w-35 flex-col gap-1.5">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRectangle className="h-2.5 w-18 animate-pulse" />
</div>
<SkeletonRectangle className="my-0 h-8 min-w-0 flex-1 animate-pulse rounded-lg" />
</SkeletonRow>
))}
</div>
</div>
)
}
export function DeveloperApiSection({
appInstanceId,
}: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const [createdApiToken, setCreatedApiToken] = useState<CreatedApiToken>()
const accessConfigQuery = useQuery(consoleQuery.enterprise.appDeployAccessService.getAppInstanceAccess.queryOptions({
input: {
params: { appInstanceId },
},
}))
const accessConfig = accessConfigQuery.data
const apiEnabled = accessConfig?.developerApi?.enabled ?? false
const apiUrl = accessConfig?.developerApi?.apiUrl
const apiKeys = accessConfig?.developerApi?.apiKeys ?? []
const environments = accessConfig?.permissions
?.map(permissionEnvironment)
.filter((environment): environment is AppDeployEnvironment => Boolean(environment)) ?? []
const visibleCreatedApiToken = createdApiToken?.appInstanceId === appInstanceId
? createdApiToken.token
: undefined
return (
<Section
title={t('access.api.developerTitle')}
description={t('access.api.description')}
layout="row"
action={(
accessConfigQuery.isLoading
? <SwitchSkeleton />
: (
<DeveloperApiSwitch
appInstanceId={appInstanceId}
checked={apiEnabled}
disabled={accessConfigQuery.isError}
/>
)
)}
>
{accessConfigQuery.isLoading
? <DeveloperApiSkeleton />
: accessConfigQuery.isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: apiEnabled
? (
<div className="flex flex-col gap-2">
{apiUrl && (
<CopyPill
label={t('access.api.endpoint')}
value={apiUrl}
/>
)}
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="system-sm-medium text-text-primary">
{t('access.api.backendTitle')}
</span>
<span className="system-xs-regular text-text-tertiary">
{t('access.api.keyList')}
</span>
</div>
<ApiKeyGenerateMenu
appInstanceId={appInstanceId}
environments={environments}
apiKeys={apiKeys}
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
/>
</div>
{visibleCreatedApiToken && (
<CreatedApiTokenCard
token={visibleCreatedApiToken}
onDismiss={() => setCreatedApiToken(undefined)}
/>
)}
{apiKeys.length === 0
? (
<SectionState>
{environments.length === 0
? t('access.api.empty')
: t('access.api.noKeys')}
</SectionState>
)
: (
<ApiKeyList
appInstanceId={appInstanceId}
apiKeys={apiKeys}
/>
)}
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.api.disabled')}
</div>
)}
</Section>
)
}

View File

@ -1,78 +0,0 @@
'use client'
import type {
EnvironmentAccessRow,
} from '@dify/contracts/enterprise/types.gen'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { Section, SectionState } from '../../common'
import { EnvironmentPermissionRow } from './permissions'
const ACCESS_PERMISSIONS_SKELETON_KEYS = ['production', 'staging', 'development']
function hasEnvironment(row: EnvironmentAccessRow): row is EnvironmentAccessRow & {
environment: NonNullable<EnvironmentAccessRow['environment']>
} {
return Boolean(row.environment?.id)
}
function AccessPermissionsSkeleton() {
return (
<div className="flex flex-col gap-3">
{ACCESS_PERMISSIONS_SKELETON_KEYS.map(key => (
<SkeletonRow key={key} className="flex-wrap items-center gap-x-3 gap-y-1.5">
<SkeletonRectangle className="h-3 w-35 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-55 animate-pulse rounded-lg" />
</SkeletonRow>
))}
</div>
)
}
export function AccessPermissionsSection({
appInstanceId,
}: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const accessConfigQuery = useQuery(consoleQuery.enterprise.appDeployAccessService.getAppInstanceAccess.queryOptions({
input: {
params: { appInstanceId },
},
}))
const accessConfig = accessConfigQuery.data
const permissionRows = accessConfig?.permissions?.filter(hasEnvironment) ?? []
return (
<Section
title={t('access.permissions.title')}
description={t('access.permissions.description')}
layout="row"
>
{accessConfigQuery.isLoading
? <AccessPermissionsSkeleton />
: accessConfigQuery.isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: permissionRows.length === 0
? (
<SectionState>
{t('access.runAccess.noEnvs')}
</SectionState>
)
: (
<div className="flex flex-col gap-2.5">
{permissionRows.map(row => (
<EnvironmentPermissionRow
key={row.environment.id}
appInstanceId={appInstanceId}
environment={row.environment}
summaryPolicy={row}
/>
))}
</div>
)}
</Section>
)
}

View File

@ -1,442 +0,0 @@
'use client'
import type {
AccessSubject,
AppDeployEnvironment,
EnvironmentAccessRow,
Subject,
} from '@dify/contracts/enterprise/types.gen'
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxChip,
ComboboxChipRemove,
ComboboxChips,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxInputTrigger,
ComboboxItem,
ComboboxItemIndicator,
ComboboxItemText,
ComboboxList,
ComboboxStatus,
ComboboxValue,
} from '@langgenius/dify-ui/combobox'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { environmentName } from '../../../environment'
type AccessPermissionKind = 'organization' | 'specific' | 'anyone'
function accessModeToPermissionKey(mode?: string): AccessPermissionKind {
const normalized = mode?.toLowerCase() ?? ''
if (normalized === 'private')
return 'specific'
if (normalized === 'public')
return 'anyone'
return 'organization'
}
function permissionKeyToAccessMode(key: AccessPermissionKind) {
if (key === 'organization')
return 'private_all'
if (key === 'specific')
return 'private'
return 'public'
}
const permissionIcon: Record<AccessPermissionKind, string> = {
organization: 'i-ri-team-line',
specific: 'i-ri-lock-line',
anyone: 'i-ri-global-line',
}
const permissionOrder: AccessPermissionKind[] = ['organization', 'specific', 'anyone']
function PermissionPicker({ value, disabled, onChange }: {
value: AccessPermissionKind
disabled?: boolean
onChange: (kind: AccessPermissionKind) => void
}) {
const { t } = useTranslation('deployments')
const icon = permissionIcon[value]
const label = t(`access.permission.${value}`)
return (
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled}
className={cn(
'inline-flex h-8 w-full min-w-0 items-center gap-2 rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-2.5 system-sm-regular text-text-secondary hover:bg-state-base-hover',
disabled && 'opacity-50',
)}
>
<span className={cn(icon, 'size-4 shrink-0 text-text-tertiary')} />
<span className="flex-1 truncate text-left">{label}</span>
<span className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-85 p-1">
{permissionOrder.map((kind) => {
const itemIcon = permissionIcon[kind]
const isSelected = kind === value
return (
<DropdownMenuItem
key={kind}
onClick={() => onChange(kind)}
className="mx-0 h-auto items-start gap-3 rounded-lg px-2.5 py-2"
>
<span className={cn(itemIcon, 'mt-0.5 size-4 shrink-0 text-text-tertiary')} />
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate system-sm-medium text-text-primary">
{t(`access.permission.${kind}`)}
</span>
</div>
<span className="system-xs-regular text-text-tertiary">
{t(`access.permission.${kind}Desc`)}
</span>
</div>
{isSelected && (
<span className="mt-0.5 i-ri-check-line size-4 shrink-0 text-text-accent" />
)}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
type SelectableAccessSubject = {
id: string
subjectType: string
name?: string
memberCount?: number
}
function normalizeSubject(subject: Subject): SelectableAccessSubject | undefined {
const id = subject.subjectId || subject.accountData?.id || subject.groupData?.id
const subjectType = subject.subjectType || (subject.groupData ? 'group' : 'account')
if (!id || !subjectType)
return undefined
return {
id,
subjectType,
name: subject.accountData?.name || subject.accountData?.email || subject.groupData?.name || id,
memberCount: subject.groupData?.groupSize,
}
}
function subjectKey(subject: Pick<SelectableAccessSubject, 'id' | 'subjectType'>) {
return `${subject.subjectType}:${subject.id}`
}
function getSubjectLabel(subject: SelectableAccessSubject) {
return subject.name || subject.id
}
function getSubjectValue(subject: SelectableAccessSubject) {
return subjectKey(subject)
}
function isSameSubject(item: SelectableAccessSubject, value: SelectableAccessSubject) {
return item.id === value.id && item.subjectType === value.subjectType
}
const SUBJECT_PICKER_SKELETON_KEYS = ['first-subject', 'second-subject', 'third-subject']
function policySubjects(subjects: SelectableAccessSubject[]): AccessSubject[] {
return subjects.map(subject => ({
subjectId: subject.id,
subjectType: subject.subjectType,
}))
}
function selectedSubjectsFromPolicy(policy?: EnvironmentAccessRow) {
return policy?.subjects
?.map(normalizeSubject)
.filter((subject): subject is SelectableAccessSubject => Boolean(subject)) ?? []
}
function SubjectIcon({ subject }: {
subject: SelectableAccessSubject
}) {
const isGroup = subject.subjectType === 'group'
return (
<span className={cn(isGroup ? 'i-ri-group-line' : 'i-ri-user-line', 'size-3.5 shrink-0 text-text-tertiary')} aria-hidden="true" />
)
}
type SubjectPickerProps = {
disabled?: boolean
selectedSubjects: SelectableAccessSubject[]
onChange: (subjects: SelectableAccessSubject[]) => void
}
function SubjectPicker({
disabled,
selectedSubjects,
onChange,
}: SubjectPickerProps) {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const debouncedKeyword = useDebounce(keyword, { wait: 300 })
const subjectsQuery = useQuery(consoleQuery.enterprise.accessSubjectService.listAccessSubjects.queryOptions({
input: {
query: {
keyword: debouncedKeyword.trim() || undefined,
pageNumber: 1,
resultsPerPage: 50,
},
},
enabled: open,
}))
const subjects = subjectsQuery.data?.subjects
?.map(normalizeSubject)
.filter((subject): subject is SelectableAccessSubject => Boolean(subject)) ?? []
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen)
setKeyword('')
setOpen(nextOpen)
}
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
if (details.reason !== 'item-press')
setKeyword(inputValue)
}
const handleValueChange = (nextSubjects: SelectableAccessSubject[]) => {
setKeyword('')
onChange(nextSubjects)
}
return (
<Combobox<SelectableAccessSubject, true>
multiple
open={open}
value={selectedSubjects}
inputValue={keyword}
items={subjects}
disabled={disabled}
itemToStringLabel={getSubjectLabel}
itemToStringValue={getSubjectValue}
isItemEqualToValue={isSameSubject}
filter={null}
onOpenChange={handleOpenChange}
onInputValueChange={handleInputValueChange}
onValueChange={handleValueChange}
>
<ComboboxInputGroup className="h-auto min-h-8 w-full max-w-full overflow-hidden py-1 pr-1">
<ComboboxValue>
{(selectedValue: SelectableAccessSubject[]) => (
<>
{selectedValue.length > 0 && (
<ComboboxChips className="flex-nowrap overflow-hidden">
{selectedValue.map(subject => (
<ComboboxChip
key={subjectKey(subject)}
className="shrink-0 rounded-full border border-divider-subtle bg-components-badge-white-to-dark"
>
<SubjectIcon subject={subject} />
<span className="max-w-32 truncate">{getSubjectLabel(subject)}</span>
{subject.subjectType === 'group' && subject.memberCount != null && (
<span className="system-2xs-regular text-text-tertiary">{subject.memberCount}</span>
)}
<ComboboxChipRemove
disabled={disabled || selectedSubjects.length <= 1}
aria-label={t('operation.remove', { ns: 'common' })}
>
<span className="i-ri-close-circle-fill size-3.5" aria-hidden="true" />
</ComboboxChipRemove>
</ComboboxChip>
))}
</ComboboxChips>
)}
<ComboboxInput
aria-label={t('access.members.pickPlaceholder')}
placeholder={selectedValue.length ? '' : t('access.members.pickPlaceholder')}
className={cn('px-2 py-0 system-sm-medium', selectedValue.length ? 'min-w-16' : 'min-w-0')}
/>
</>
)}
</ComboboxValue>
<ComboboxInputTrigger />
</ComboboxInputGroup>
<ComboboxContent popupClassName="w-(--anchor-width) min-w-90 p-0">
{subjectsQuery.isLoading
? (
<ComboboxStatus className="flex flex-col gap-2 px-3 py-3">
{SUBJECT_PICKER_SKELETON_KEYS.map(key => (
<SkeletonRow key={key} className="h-6">
<SkeletonRectangle className="h-3 w-full animate-pulse" />
</SkeletonRow>
))}
</ComboboxStatus>
)
: (
<>
<ComboboxList className="p-1">
{subjects.map(subject => (
<ComboboxItem
key={subjectKey(subject)}
value={subject}
className="mx-0"
>
<ComboboxItemText className="flex items-center gap-2 px-0">
<SubjectIcon subject={subject} />
<span className="min-w-0 flex-1 truncate">{getSubjectLabel(subject)}</span>
{subject.subjectType === 'group' && subject.memberCount != null && (
<span className="shrink-0 system-xs-regular text-text-tertiary">
{t('access.members.memberCount', { count: subject.memberCount })}
</span>
)}
</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>
))}
</ComboboxList>
<ComboboxEmpty className="px-3 py-5 text-center system-xs-regular">
{t('access.members.empty')}
</ComboboxEmpty>
</>
)}
</ComboboxContent>
</Combobox>
)
}
type EnvironmentPermissionRowProps = {
appInstanceId: string
environment: AppDeployEnvironment
summaryPolicy?: EnvironmentAccessRow
}
export function EnvironmentPermissionRow({
appInstanceId,
environment,
summaryPolicy,
}: EnvironmentPermissionRowProps) {
const { t } = useTranslation('deployments')
const environmentId = environment.id
const setEnvironmentAccessPolicy = useMutation(consoleQuery.enterprise.appDeployAccessService.updateEnvironmentAccessPolicy.mutationOptions())
const policyKind = accessModeToPermissionKey(summaryPolicy?.accessMode)
const policySubjectFingerprint = summaryPolicy?.subjects
?.map(subject => `${subject.subjectType ?? ''}:${subject.subjectId ?? subject.accountData?.id ?? subject.groupData?.id ?? ''}`)
.join(',')
const policyFingerprint = [
summaryPolicy?.accessMode ?? '',
policySubjectFingerprint ?? '',
].join(':')
const policySelectedSubjects = policyKind === 'specific' ? selectedSubjectsFromPolicy(summaryPolicy) : []
const [draft, setDraft] = useState<{
fingerprint?: string
kind?: AccessPermissionKind
subjects?: SelectableAccessSubject[]
}>({})
const hasDraft = draft.fingerprint === policyFingerprint
const permissionKind = hasDraft && draft.kind ? draft.kind : policyKind
const subjects = hasDraft && draft.subjects ? draft.subjects : policySelectedSubjects
const isSaving = setEnvironmentAccessPolicy.isPending
const controlsDisabled = isSaving
const persistPolicy = (nextKind: AccessPermissionKind, nextSubjects: SelectableAccessSubject[]) => {
if (!environmentId)
return
if (nextKind === 'specific' && nextSubjects.length === 0)
return
setEnvironmentAccessPolicy.mutate(
{
params: {
appInstanceId,
environmentId,
},
body: {
appInstanceId,
environmentId,
accessMode: permissionKeyToAccessMode(nextKind),
subjects: nextKind === 'specific' ? policySubjects(nextSubjects) : [],
},
},
{
onSuccess: () => {
setDraft({})
},
onError: () => {
toast.error(t('access.permission.updateFailed'))
},
},
)
}
const handlePermissionChange = (nextKind: AccessPermissionKind) => {
setDraft({
fingerprint: policyFingerprint,
kind: nextKind,
subjects: nextKind === 'specific' ? subjects : [],
})
if (nextKind === 'specific') {
persistPolicy(nextKind, subjects)
return
}
persistPolicy(nextKind, [])
}
const handleSubjectsChange = (nextSubjects: SelectableAccessSubject[]) => {
if (nextSubjects.length === 0)
return
setDraft({
fingerprint: policyFingerprint,
kind: 'specific',
subjects: nextSubjects,
})
persistPolicy('specific', nextSubjects)
}
return (
<div className="grid gap-x-3 gap-y-2 sm:grid-cols-[minmax(88px,108px)_minmax(0,1fr)] lg:grid-cols-[minmax(88px,108px)_minmax(160px,180px)_minmax(0,1fr)]">
<span className="pt-1.5 system-xs-regular text-text-tertiary">
{environmentName(environment)}
</span>
<div className="min-w-0">
<PermissionPicker
value={permissionKind}
disabled={controlsDisabled}
onChange={handlePermissionChange}
/>
</div>
{permissionKind === 'specific' && (
<div className="min-w-0 sm:col-start-2 lg:col-start-3">
<SubjectPicker
selectedSubjects={subjects}
disabled={controlsDisabled}
onChange={handleSubjectsChange}
/>
{subjects.length === 0 && (
<span className="mt-1.5 block system-xs-regular text-text-tertiary">
{t('access.members.emptySelection')}
</span>
)}
</div>
)}
</div>
)
}

View File

@ -1,10 +0,0 @@
export function getUrlOrigin(url?: string) {
if (!url)
return undefined
try {
return new URL(url).origin
}
catch {
return url
}
}

View File

@ -1,76 +0,0 @@
'use client'
import type { AppInstanceBasicInfo } from '@dify/contracts/enterprise/types.gen'
import { useQuery } from '@tanstack/react-query'
import { get } from '@/service/base'
const SOURCE_APP_DETAIL_QUERY_KEY = 'source-app-detail'
type SourceAppDetail = {
id?: string
}
type SourceAppAvailability = {
canCreateRelease: boolean
isChecking: boolean
sourceAppUnavailable: boolean
}
export function useSourceAppAvailability(
appInstance?: Pick<AppInstanceBasicInfo, 'sourceAppAvailable' | 'sourceAppId'>,
): SourceAppAvailability {
const sourceAppId = appInstance?.sourceAppId
const shouldVerifySourceApp = Boolean(sourceAppId && appInstance?.sourceAppAvailable !== false)
const sourceAppDetailQuery = useQuery({
queryKey: [SOURCE_APP_DETAIL_QUERY_KEY, sourceAppId],
queryFn: async () => {
if (!sourceAppId)
return true
const sourceApp = await get<SourceAppDetail>(`/apps/${sourceAppId}`, {}, { silent: true })
return sourceApp.id === sourceAppId
},
enabled: shouldVerifySourceApp,
retry: false,
})
if (!appInstance) {
return {
canCreateRelease: false,
isChecking: false,
sourceAppUnavailable: false,
}
}
if (appInstance.sourceAppAvailable === false) {
return {
canCreateRelease: false,
isChecking: false,
sourceAppUnavailable: true,
}
}
if (!sourceAppId) {
return {
canCreateRelease: true,
isChecking: false,
sourceAppUnavailable: false,
}
}
if (sourceAppDetailQuery.isLoading) {
return {
canCreateRelease: false,
isChecking: true,
sourceAppUnavailable: false,
}
}
const sourceAppUnavailable = sourceAppDetailQuery.isError || sourceAppDetailQuery.data === false
return {
canCreateRelease: !sourceAppUnavailable,
isChecking: false,
sourceAppUnavailable,
}
}

View File

@ -1,9 +0,0 @@
export const INSTANCE_DETAIL_TAB_KEYS = ['overview', 'deploy', 'releases', 'settings'] as const
export type InstanceDetailTabKey = typeof INSTANCE_DETAIL_TAB_KEYS[number]
const INSTANCE_DETAIL_TAB_KEY_SET = new Set<string>(INSTANCE_DETAIL_TAB_KEYS)
export function isInstanceDetailTabKey(value?: string): value is InstanceDetailTabKey {
return value != null && INSTANCE_DETAIL_TAB_KEY_SET.has(value)
}

View File

@ -1,48 +0,0 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { useSourceAppAvailability } from './source-app-availability'
import { CreateReleaseControl } from './versions-tab/create-release-control'
import { ReleaseHistoryTable } from './versions-tab/release-history-table'
function SourceAppUnavailableNotice({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const { data: overview } = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
input: {
params: { appInstanceId },
},
}))
const sourceAppAvailability = useSourceAppAvailability(overview?.overview?.appInstance)
if (!sourceAppAvailability.sourceAppUnavailable)
return null
return (
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-2 system-sm-regular text-text-tertiary">
{t('versions.sourceAppUnavailable')}
</div>
)
}
export function VersionsTab({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
return (
<div className="mx-auto flex w-full max-w-[1080px] min-w-0 flex-col gap-4 px-6 py-6">
<div className="flex items-center justify-between gap-3">
<div className="system-sm-semibold text-text-primary">
{t('versions.releaseHistory')}
</div>
<CreateReleaseControl appInstanceId={appInstanceId} size="medium" />
</div>
<SourceAppUnavailableNotice appInstanceId={appInstanceId} />
<ReleaseHistoryTable appInstanceId={appInstanceId} />
</div>
)
}

View File

@ -1,104 +0,0 @@
import type { EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
import { describe, expect, it } from 'vitest'
import { getReleaseDeployments } from '../release-deployments'
describe('getReleaseDeployments', () => {
it('should prefer runtime deployment state when history has the same environment', () => {
// Arrange
const releaseRow = {
id: 'release-1',
deployedTo: [
{
environmentId: 'env-1',
environmentName: 'Production',
},
],
} satisfies ReleaseRow
const deploymentRows = [
{
runtime: { runtimeInstanceId: 'deployment-1' },
environment: {
id: 'env-1',
name: 'Production',
},
status: 'ready',
currentRelease: {
id: 'release-1',
},
},
] satisfies EnvironmentDeployment[]
// Act
const result = getReleaseDeployments(releaseRow, deploymentRows)
// Assert
expect(result).toEqual([
{
environmentId: 'env-1',
environmentName: 'Production',
state: 'active',
},
])
})
it('should merge history deployments with runtime deployments for different environments', () => {
// Arrange
const releaseRow = {
id: 'release-1',
deployedTo: [
{
environmentId: 'env-1',
environmentName: 'Production',
},
],
} satisfies ReleaseRow
const deploymentRows = [
{
runtime: { runtimeInstanceId: 'deployment-2' },
environment: {
id: 'env-2',
name: 'Staging',
},
status: 'deploying',
currentRelease: {
id: 'release-1',
},
},
] satisfies EnvironmentDeployment[]
// Act
const result = getReleaseDeployments(releaseRow, deploymentRows)
// Assert
expect(result).toEqual([
{
environmentId: 'env-2',
environmentName: 'Staging',
state: 'deploying',
},
{
environmentId: 'env-1',
environmentName: 'Production',
state: 'active',
},
])
})
it('should return no deployments when the release row has no release id', () => {
// Arrange
const releaseRow = {
deployedTo: [
{
environmentId: 'env-1',
environmentName: 'Production',
},
],
} satisfies ReleaseRow
// Act
const result = getReleaseDeployments(releaseRow, [])
// Assert
expect(result).toEqual([])
})
})

View File

@ -1,130 +0,0 @@
import type { EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ReleaseHistoryTable } from '../release-history-table'
const mockUseQuery = vi.fn()
vi.mock('@tanstack/react-query', () => ({
keepPreviousData: Symbol('keepPreviousData'),
useQuery: (options: { queryKey?: string[] }) => mockUseQuery(options),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: () => '9 days ago',
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstanceOverview: {
queryOptions: () => ({ queryKey: ['app-instance-overview'] }),
},
},
appReleaseService: {
listReleases: {
queryOptions: () => ({ queryKey: ['release-history'] }),
},
},
appDeploymentService: {
listEnvironmentDeployments: {
queryOptions: () => ({ queryKey: ['runtime-instances'] }),
},
},
},
},
}))
function release(overrides: Partial<ReleaseRow> = {}): ReleaseRow {
return {
id: 'release-1',
name: 'R-001',
createdAt: '2026-05-05T10:00:00Z',
createdBy: { name: 'App-runner-demo' },
deployedTo: [
{
environmentId: 'env-1',
environmentName: 'default',
},
],
...overrides,
}
}
function runtimeInstance(overrides: Partial<EnvironmentDeployment> = {}): EnvironmentDeployment {
return {
runtime: { runtimeInstanceId: 'runtime-1' },
environment: { id: 'env-1', name: 'default' },
status: 'ready',
currentRelease: { id: 'release-1' },
...overrides,
}
}
describe('ReleaseHistoryTable', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseQuery.mockImplementation((options: { queryKey?: string[] }) => {
switch (options.queryKey?.[0]) {
case 'app-instance-overview':
return {
data: { overview: { appInstance: { sourceAppAvailable: true } } },
isLoading: false,
isError: false,
}
case 'release-history':
return {
data: {
data: [release()],
pagination: { totalCount: 1 },
},
isLoading: false,
isError: false,
}
case 'runtime-instances':
return {
data: { data: [runtimeInstance()] },
isLoading: false,
isError: false,
}
default:
return {
data: undefined,
isLoading: false,
isError: false,
}
}
})
})
// The desktop release history should use the same compact table shell as knowledge documents.
describe('Rendering', () => {
it('should render the desktop release history as a compact document-style table', () => {
// Arrange & Act
const { container } = render(<ReleaseHistoryTable appInstanceId="instance-1" />)
// Assert
const table = screen.getByRole('table')
expect(table).toHaveClass('border-collapse', 'border-0', 'text-sm', 'min-w-[700px]')
expect(container.querySelector('thead')).toHaveClass(
'h-8',
'border-b',
'border-divider-subtle',
'text-xs',
'leading-8',
'font-medium',
'text-text-tertiary',
'uppercase',
)
expect(container.querySelector('tbody tr')).toHaveClass(
'h-8',
'border-b',
'border-divider-subtle',
'hover:bg-background-default-hover',
)
})
})
})

View File

@ -1,198 +0,0 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { consoleQuery } from '@/service/client'
import { useSourceAppAvailability } from '../source-app-availability'
const DESCRIPTION_MAX_LENGTH = 512
const DESCRIPTION_WARN_THRESHOLD = 460
export function CreateReleaseControl({ appInstanceId, variant = 'primary', size = 'small' }: {
appInstanceId: string
variant?: 'primary' | 'secondary'
size?: 'small' | 'medium'
}) {
const { t } = useTranslation('deployments')
const createRelease = useMutation(consoleQuery.enterprise.appReleaseService.createRelease.mutationOptions())
const [isCreating, setIsCreating] = useState(false)
const [description, setDescription] = useState('')
const { data: overview } = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
input: {
params: { appInstanceId },
},
}))
const sourceAppAvailability = useSourceAppAvailability(overview?.overview?.appInstance)
const canCreateRelease = sourceAppAvailability.canCreateRelease
function closeDialog() {
setIsCreating(false)
setDescription('')
}
function handleCreateRelease(form: HTMLFormElement) {
if (!canCreateRelease || createRelease.isPending)
return
const formData = new FormData(form)
const releaseName = String(formData.get('name') ?? '').trim()
const releaseDescription = description.trim()
if (!releaseName)
return
createRelease.mutate(
{
params: {
appInstanceId,
},
body: {
name: releaseName,
description: releaseDescription || undefined,
},
},
{
onSuccess: (response) => {
if (!response.release?.id) {
toast.error(t('versions.createFailed'))
return
}
const createdName = response.release.name ?? releaseName
toast.success(t('versions.createSuccess', { name: createdName }))
form.reset()
closeDialog()
},
onError: () => {
toast.error(t('versions.createFailed'))
},
},
)
}
const descriptionLength = description.length
const isNearLimit = descriptionLength >= DESCRIPTION_WARN_THRESHOLD
return (
<>
<Button
size={size}
variant={variant}
disabled={!canCreateRelease}
onClick={() => setIsCreating(true)}
>
{t('versions.createRelease')}
</Button>
<Dialog
open={isCreating}
onOpenChange={(open) => {
if (!open)
closeDialog()
else
setIsCreating(true)
}}
>
<DialogContent className="w-140 overflow-hidden p-0">
<DialogCloseButton />
{isCreating && (
<form
onSubmit={(event) => {
event.preventDefault()
handleCreateRelease(event.currentTarget)
}}
>
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<div className="min-w-0">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('versions.createRelease')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('versions.createReleaseDescription')}
</DialogDescription>
</div>
</div>
<div className="flex flex-col gap-5 px-6 py-5">
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-name">
{t('versions.releaseNameLabel')}
</label>
<Input
id="release-name"
name="name"
placeholder={t('versions.releaseNamePlaceholder')}
maxLength={128}
required
autoFocus
className="h-9"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-description">
{t('versions.releaseDescriptionLabel')}
</label>
<div className="flex items-center gap-2">
<span className="system-xs-regular text-text-quaternary">
{t('versions.optional')}
</span>
<span
className={cn(
'system-xs-regular tabular-nums',
isNearLimit ? 'text-util-colors-warning-warning-700' : 'text-text-quaternary',
)}
>
{descriptionLength}
/
{DESCRIPTION_MAX_LENGTH}
</span>
</div>
</div>
<textarea
id="release-description"
name="description"
placeholder={t('versions.releaseDescriptionPlaceholder')}
maxLength={DESCRIPTION_MAX_LENGTH}
value={description}
onChange={e => setDescription(e.target.value)}
className="min-h-24 w-full resize-none appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
/>
</div>
</div>
<div className="flex items-center justify-between gap-4 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<div className="system-xs-regular text-text-tertiary">
{t('versions.createReleaseHint')}
</div>
<div className="flex shrink-0 justify-end gap-2">
<Button
type="button"
variant="secondary"
disabled={createRelease.isPending}
onClick={closeDialog}
>
{t('versions.cancelCreate')}
</Button>
<Button
type="submit"
variant="primary"
className="min-w-22"
disabled={!canCreateRelease || createRelease.isPending}
>
{createRelease.isPending ? t('versions.creating') : t('versions.create')}
</Button>
</div>
</div>
</form>
)}
</DialogContent>
</Dialog>
</>
)
}

View File

@ -1,178 +0,0 @@
'use client'
import type { AppDeployEnvironment, EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { environmentId, environmentName } from '../../environment'
import { releaseDeploymentAction } from '../../release-action'
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import { DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME } from '../list-styles'
type EnvironmentOption = AppDeployEnvironment & {
id: string
}
type DeployMenuRowState = 'promote' | 'deploy' | 'rollback' | 'current' | 'deploying'
type DeployMenuRow = {
env: EnvironmentOption
state: DeployMenuRowState
label: string
disabledReason?: string
}
type DeployMenuGroup = 'promote' | 'deploy' | 'rollback' | 'unavailable'
const GROUP_ORDER: DeployMenuGroup[] = ['promote', 'deploy', 'rollback', 'unavailable']
function stateToGroup(state: DeployMenuRowState): DeployMenuGroup {
if (state === 'promote')
return 'promote'
if (state === 'rollback')
return 'rollback'
if (state === 'deploy')
return 'deploy'
return 'unavailable'
}
export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows }: {
appInstanceId: string
releaseId: string
releaseRows: ReleaseRow[]
}) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const [open, setOpen] = useState(false)
const { data: environmentDeployments } = useQuery(consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
enabled: open,
}))
const environments: EnvironmentOption[] = (environmentDeployments?.data ?? [])
.map(row => row.environment)
.filter((env): env is EnvironmentOption => Boolean(env?.id))
const deploymentRows = environmentDeployments?.data?.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? []
const targetRelease = releaseRows.find(release => release.id === releaseId)
if (!targetRelease)
return null
const menuRows: DeployMenuRow[] = environments.map((env) => {
const envId = env.id
const envName = environmentName(env)
const row: EnvironmentDeployment | undefined = deploymentRows.find(item => environmentId(item.environment) === envId)
const currentRelease = row?.currentRelease
const isCurrent = currentRelease?.id === releaseId
const isEnvironmentDeploying = row ? deploymentStatus(row) === 'deploying' : false
if (isEnvironmentDeploying) {
return {
env,
state: 'deploying',
label: t('versions.deployingTo', { name: envName }),
disabledReason: t('versions.disabledReason.deploying'),
}
}
if (isCurrent) {
return {
env,
state: 'current',
label: t('versions.currentOn', { name: envName }),
disabledReason: t('versions.disabledReason.current', { name: envName }),
}
}
const action = releaseDeploymentAction({
targetRelease,
currentRelease,
releaseRows,
isExistingRelease: true,
})
if (!row) {
return {
env,
state: 'deploy',
label: t('versions.deployTo', { name: envName }),
}
}
if (action === 'rollback') {
return {
env,
state: 'rollback',
label: t('versions.rollbackTo', { name: envName }),
}
}
return {
env,
state: 'promote',
label: t('versions.promoteTo', { name: envName }),
}
})
const groupedRows = GROUP_ORDER.map(group => ({
group,
rows: menuRows.filter(row => stateToGroup(row.state) === group),
})).filter(section => section.rows.length > 0)
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
aria-label={t('versions.moreActions')}
className={DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-60">
{groupedRows.map((section, sectionIndex) => (
<div key={section.group}>
{sectionIndex > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
<div className="px-3 pt-1.5 pb-1 system-2xs-medium-uppercase text-text-quaternary">
{t(`versions.groupHeader.${section.group}`)}
</div>
{section.rows.map((row) => {
const isDisabled = row.state === 'current' || row.state === 'deploying'
return (
<DropdownMenuItem
key={row.env.id}
disabled={isDisabled}
title={isDisabled ? row.disabledReason : undefined}
aria-disabled={isDisabled}
className={cn(
'gap-2 px-3',
isDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={() => {
if (isDisabled)
return
setOpen(false)
openDeployDrawer({ appInstanceId, environmentId: row.env.id, releaseId })
}}
>
<span className="system-sm-regular text-text-secondary">
{row.label}
</span>
</DropdownMenuItem>
)
})}
</div>
))}
</DropdownMenuContent>
)}
</DropdownMenu>
)
}

View File

@ -1,46 +0,0 @@
'use client'
import type { ReleaseDeployment, ReleaseDeploymentState } from './release-deployments'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
const RELEASE_DEPLOYMENT_STYLES: Record<ReleaseDeploymentState, string> = {
active: 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700',
deploying: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700',
failed: 'border-util-colors-red-red-200 bg-util-colors-red-red-50 text-util-colors-red-red-700',
}
export function DeployedToBadge({ item }: {
item: ReleaseDeployment
}) {
const { t } = useTranslation('deployments')
const statusLabel = t(`versions.deployedStatus.${item.state}`)
return (
<Tooltip>
<TooltipTrigger
render={(
<span
className={cn(
'inline-flex h-6 items-center gap-1 rounded-md border px-1.5 system-xs-medium',
RELEASE_DEPLOYMENT_STYLES[item.state],
)}
>
{item.state === 'deploying'
? <span className="i-ri-loader-4-line size-3.5 animate-spin" />
: item.state === 'failed'
? <span className="i-ri-alert-line size-3.5" />
: <span className="size-1.5 rounded-full bg-current" />}
{item.environmentName}
</span>
)}
/>
<TooltipContent>
{statusLabel}
{' · '}
{item.environmentName}
</TooltipContent>
</Tooltip>
)
}

View File

@ -1,76 +0,0 @@
import type { DeployedEnvironment, EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
import { environmentId, environmentName } from '../../environment'
import { deploymentStatus } from '../../runtime-status'
export type ReleaseDeploymentState = 'active' | 'deploying' | 'failed'
export type ReleaseDeployment = {
environmentId: string
environmentName: string
state: ReleaseDeploymentState
}
function releaseDeploymentState(status?: string): ReleaseDeploymentState {
const normalized = status?.toLowerCase() ?? ''
if (normalized.includes('deploying') || normalized.includes('pending'))
return 'deploying'
if (normalized.includes('fail') || normalized.includes('error'))
return 'failed'
return 'active'
}
function runtimeDeploymentByEnvironmentId(deploymentRows: EnvironmentDeployment[]) {
return new Map(
deploymentRows
.map((deployment) => {
const envId = environmentId(deployment.environment)
return envId ? [envId, deployment] as const : undefined
})
.filter((entry): entry is readonly [string, EnvironmentDeployment] => !!entry),
)
}
function fromDeployedTo(item: DeployedEnvironment, runtimeDeployments: Map<string, EnvironmentDeployment>): ReleaseDeployment | undefined {
if (!item.environmentId)
return undefined
const runtimeDeployment = runtimeDeployments.get(item.environmentId)
return {
environmentId: item.environmentId,
environmentName: item.environmentName || item.environmentId,
state: runtimeDeployment ? releaseDeploymentState(deploymentStatus(runtimeDeployment)) : 'active',
}
}
function dedupeReleaseDeployments(items: ReleaseDeployment[]) {
return items.filter((item, index) => {
return items.findIndex(candidate => candidate.environmentId === item.environmentId) === index
})
}
export function getReleaseDeployments(row: ReleaseRow, deploymentRows: EnvironmentDeployment[]) {
const releaseId = row.id
if (!releaseId)
return []
const runtimeDeployments = runtimeDeploymentByEnvironmentId(deploymentRows)
const historyItems = row.deployedTo?.map(item => fromDeployedTo(item, runtimeDeployments)).filter((item): item is ReleaseDeployment => !!item) ?? []
const runtimeItems = deploymentRows.flatMap((deployment) => {
const envId = environmentId(deployment.environment)
if (!envId)
return []
const items: ReleaseDeployment[] = []
if (deployment.currentRelease?.id === releaseId) {
items.push({
environmentId: envId,
environmentName: environmentName(deployment.environment),
state: releaseDeploymentState(deploymentStatus(deployment)),
})
}
return items
})
return dedupeReleaseDeployments([...runtimeItems, ...historyItems])
}

View File

@ -1,379 +0,0 @@
'use client'
import type { EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
import type { ReleaseDeployment } from './release-deployments'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Pagination from '@/app/components/base/pagination'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { consoleQuery } from '@/service/client'
import { RELEASE_HISTORY_PAGE_SIZE } from '../../data'
import {
formatDate,
releaseCommit,
releaseLabel,
} from '../../release'
import { isUndeployedDeploymentRow } from '../../runtime-status'
import {
DetailListState,
} from '../common'
import {
DETAIL_LIST_CLASS_NAME,
DETAIL_LIST_DESKTOP_ROW_CLASS_NAME,
DETAIL_LIST_HEADER_ROW_CLASS_NAME,
DETAIL_LIST_ROW_CLASS_NAME,
RELEASE_DETAIL_LIST_GRID_CLASS_NAME,
} from '../list-styles'
import { DeployReleaseMenu } from './deploy-release-menu'
import { DeployedToBadge } from './deployed-to-badge'
import { getReleaseDeployments } from './release-deployments'
const RELEASE_TABLE_ROW_SKELETON_KEYS = ['latest', 'previous', 'older', 'archived', 'initial']
type ReleaseRowWithId = ReleaseRow & {
id: string
}
function hasReleaseId(row: ReleaseRow): row is ReleaseRowWithId {
return Boolean(row.id)
}
function ReleaseHistoryTableSkeleton() {
const { t } = useTranslation('deployments')
return (
<>
<div className={`${DETAIL_LIST_CLASS_NAME} pc:hidden`}>
{RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => (
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRow className="mt-1 gap-2">
<SkeletonRectangle className="h-3 w-28 animate-pulse" />
<SkeletonRectangle className="h-3 w-20 animate-pulse" />
</SkeletonRow>
</div>
<SkeletonRectangle className="my-0 h-7 w-8 animate-pulse rounded-lg" />
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<ReleaseDeploymentsSkeleton />
</div>
</div>
</div>
))}
</div>
<div className="hidden pc:block">
<div className={DETAIL_LIST_CLASS_NAME}>
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${RELEASE_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div>{t('versions.col.release')}</div>
<div>{t('versions.col.createdAt')}</div>
<div>{t('versions.col.author')}</div>
<div>{t('versions.col.deployedTo')}</div>
<div className="text-right">{t('versions.col.action')}</div>
</div>
{RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => (
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${RELEASE_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div className="min-w-0">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
</div>
<div className="min-w-0">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
</div>
<div className="min-w-0">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
</div>
<div className="min-w-0">
<ReleaseDeploymentsSkeleton />
</div>
<div className="flex justify-end">
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</div>
</div>
))}
</div>
</div>
</>
)
}
function ReleaseHistoryMobileRows({ appInstanceId, releaseRows, deploymentRows, deployedToLoading, deployedToHasError }: {
appInstanceId: string
releaseRows: ReleaseRowWithId[]
deploymentRows: EnvironmentDeployment[]
deployedToLoading?: boolean
deployedToHasError?: boolean
}) {
const { t } = useTranslation('deployments')
return (
<div className={`${DETAIL_LIST_CLASS_NAME} pc:hidden`}>
{releaseRows.map((row) => {
const release = row
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
const hasDeployments = releaseDeployments.length > 0 || deployedToLoading || deployedToHasError
return (
<div key={release.id} className={DETAIL_LIST_ROW_CLASS_NAME}>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Tooltip>
<TooltipTrigger
render={(
<span className="inline-flex max-w-full cursor-default truncate font-mono system-sm-medium text-text-primary">
{releaseLabel(release)}
</span>
)}
/>
<TooltipContent>
{t('versions.commitTooltip', { commit: releaseCommit(release) })}
</TooltipContent>
</Tooltip>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 system-xs-regular text-text-secondary">
<CreatedAtCell createdAt={release.createdAt} />
<span aria-hidden>·</span>
<span>{row.createdBy?.name ?? '—'}</span>
</div>
</div>
<div className="flex shrink-0 justify-end gap-1">
<DeployReleaseMenu releaseId={release.id} appInstanceId={appInstanceId} releaseRows={releaseRows} />
</div>
</div>
{hasDeployments && (
<div className="flex min-w-0 flex-wrap items-center gap-1">
<ReleaseDeploymentsContent
items={releaseDeployments}
isLoading={deployedToLoading}
hasError={deployedToHasError}
loadFailedLabel={t('common.loadFailed')}
/>
</div>
)}
</div>
</div>
)
})}
</div>
)
}
function ReleaseDeploymentsSkeleton() {
return (
<SkeletonRow className="gap-1">
<SkeletonRectangle className="my-0 h-5 w-20 animate-pulse rounded-md" />
<SkeletonRectangle className="my-0 h-5 w-18 animate-pulse rounded-md" />
</SkeletonRow>
)
}
function ReleaseDeploymentsContent({
items,
isLoading,
hasError,
loadFailedLabel,
}: {
items: ReleaseDeployment[]
isLoading?: boolean
hasError?: boolean
loadFailedLabel: string
}) {
if (isLoading)
return <ReleaseDeploymentsSkeleton />
if (hasError)
return <span className="system-sm-regular text-text-tertiary">{loadFailedLabel}</span>
if (items.length === 0)
return <span className="system-sm-regular text-text-quaternary"></span>
return items.map(item => (
<DeployedToBadge
key={`${item.environmentId}-${item.state}`}
item={item}
/>
))
}
function CreatedAtCell({ createdAt }: {
createdAt?: string
}) {
const { formatTimeFromNow } = useFormatTimeFromNow()
if (!createdAt)
return <></>
const ms = Date.parse(createdAt)
if (Number.isNaN(ms))
return <>{formatDate(createdAt)}</>
return (
<Tooltip>
<TooltipTrigger
render={(
<span className="cursor-default">
{formatTimeFromNow(ms)}
</span>
)}
/>
<TooltipContent>{formatDate(createdAt)}</TooltipContent>
</Tooltip>
)
}
function ReleaseHistoryRows({ appInstanceId, releaseRows, deploymentRows, deployedToLoading, deployedToHasError }: {
appInstanceId: string
releaseRows: ReleaseRowWithId[]
deploymentRows: EnvironmentDeployment[]
deployedToLoading?: boolean
deployedToHasError?: boolean
}) {
const { t } = useTranslation('deployments')
return (
<>
<ReleaseHistoryMobileRows
appInstanceId={appInstanceId}
releaseRows={releaseRows}
deploymentRows={deploymentRows}
deployedToLoading={deployedToLoading}
deployedToHasError={deployedToHasError}
/>
<div className="hidden pc:block">
<div className={DETAIL_LIST_CLASS_NAME}>
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${RELEASE_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div>{t('versions.col.release')}</div>
<div>{t('versions.col.createdAt')}</div>
<div>{t('versions.col.author')}</div>
<div>{t('versions.col.deployedTo')}</div>
<div className="text-right">{t('versions.col.action')}</div>
</div>
{releaseRows.map((row) => {
const release = row
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
return (
<div key={release.id} className={DETAIL_LIST_ROW_CLASS_NAME}>
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${RELEASE_DETAIL_LIST_GRID_CLASS_NAME}`}>
<div className="min-w-0">
<Tooltip>
<TooltipTrigger
render={(
<span className="inline-flex max-w-full cursor-default truncate font-mono system-sm-medium text-text-primary">
{releaseLabel(release)}
</span>
)}
/>
<TooltipContent>
{t('versions.commitTooltip', { commit: releaseCommit(release) })}
</TooltipContent>
</Tooltip>
</div>
<div className="min-w-0 system-sm-regular text-text-secondary">
<CreatedAtCell createdAt={release.createdAt} />
</div>
<div className="min-w-0 truncate system-sm-regular text-text-secondary">
{row.createdBy?.name ?? '—'}
</div>
<div className="min-w-0">
<div className="flex flex-wrap gap-1">
<ReleaseDeploymentsContent
items={releaseDeployments}
isLoading={deployedToLoading}
hasError={deployedToHasError}
loadFailedLabel={t('common.loadFailed')}
/>
</div>
</div>
<div className="flex justify-end">
<DeployReleaseMenu releaseId={release.id} appInstanceId={appInstanceId} releaseRows={releaseRows} />
</div>
</div>
</div>
)
})}
</div>
</div>
</>
)
}
export function ReleaseHistoryTable({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const [currentPage, setCurrentPage] = useState(0)
const input = { params: { appInstanceId } }
const overviewQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
input,
}))
const releaseHistoryQuery = useQuery(consoleQuery.enterprise.appReleaseService.listReleases.queryOptions({
input: {
...input,
query: {
pageNumber: currentPage + 1,
resultsPerPage: RELEASE_HISTORY_PAGE_SIZE,
},
},
placeholderData: keepPreviousData,
}))
const releaseRows = releaseHistoryQuery.data?.data?.filter(hasReleaseId) ?? []
const totalReleases = releaseHistoryQuery.data?.pagination?.totalCount ?? releaseRows.length
const shouldLoadRuntimeInstances = releaseRows.length > 0
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({
input,
enabled: shouldLoadRuntimeInstances,
}))
const isLoading = releaseHistoryQuery.isLoading
|| (releaseRows.length === 0 && overviewQuery.isLoading)
const hasError = releaseHistoryQuery.isError
|| (releaseRows.length === 0 && overviewQuery.isError)
const deployedToLoading = shouldLoadRuntimeInstances && environmentDeploymentsQuery.isLoading
const deployedToHasError = shouldLoadRuntimeInstances && environmentDeploymentsQuery.isError
const sourceAppUnavailable = overviewQuery.data?.overview?.appInstance?.sourceAppAvailable === false
const deploymentRows = environmentDeploymentsQuery.data?.data?.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? []
if (isLoading) {
return <ReleaseHistoryTableSkeleton />
}
if (hasError) {
return (
<DetailListState>
{t('common.loadFailed')}
</DetailListState>
)
}
if (releaseRows.length === 0) {
return (
<DetailListState>
{sourceAppUnavailable ? t('versions.emptySourceUnavailable') : t('versions.emptyWithCreate')}
</DetailListState>
)
}
return (
<div className="flex flex-col gap-3">
<ReleaseHistoryRows
appInstanceId={appInstanceId}
releaseRows={releaseRows}
deploymentRows={deploymentRows}
deployedToLoading={deployedToLoading}
deployedToHasError={deployedToHasError}
/>
{totalReleases > RELEASE_HISTORY_PAGE_SIZE && (
<Pagination
className="border-y border-divider-subtle"
current={currentPage}
total={totalReleases}
limit={RELEASE_HISTORY_PAGE_SIZE}
onChange={setCurrentPage}
/>
)}
</div>
)
}

View File

@ -1,28 +0,0 @@
import type { AppDeployEnvironment } from '@dify/contracts/enterprise/types.gen'
export function environmentId(environment?: AppDeployEnvironment) {
return environment?.id ?? ''
}
export function environmentName(environment?: AppDeployEnvironment) {
return environment?.name || environment?.id || '—'
}
export function environmentMode(environment?: AppDeployEnvironment) {
const type = environment?.type?.toLowerCase() ?? ''
return type.includes('isolated') ? 'isolated' : 'shared'
}
function environmentRuntimeName(environment?: AppDeployEnvironment) {
return environment?.backend ?? ''
}
export function environmentBackend(environment?: AppDeployEnvironment) {
const runtime = environmentRuntimeName(environment).toLowerCase()
return runtime.includes('host') ? 'host' : 'k8s'
}
export function environmentHealth(environment?: AppDeployEnvironment) {
const status = environment?.status?.toLowerCase() ?? ''
return status.includes('ready') ? 'ready' : 'degraded'
}

View File

@ -1,18 +0,0 @@
'use client'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
export function CreateDeploymentButton() {
const { t } = useTranslation('deployments')
return (
<Link
href="/deployments/create"
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary-600 px-3 system-sm-medium text-text-primary-on-surface hover:bg-primary-700"
>
<span className="i-ri-add-line size-4 shrink-0" aria-hidden="true" />
<span>{t('list.createDeployment')}</span>
</Link>
)
}

View File

@ -1,92 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useQueryState } from 'nuqs'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { envFilterQueryState } from './query-state'
type EnvironmentFilterOption = {
value: string
text: string
icon: ReactNode
}
export function EnvironmentFilter() {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const [envFilter, setEnvFilter] = useQueryState('env', envFilterQueryState)
const activeFilter = envFilter === 'all' || envFilter === 'not-deployed'
? envFilter
: 'all'
const filterOptions: EnvironmentFilterOption[] = [
{
value: 'all',
text: t('filter.allEnvs'),
icon: <span className="i-ri-apps-2-line size-[14px]" />,
},
{
value: 'not-deployed',
text: t('filter.notDeployed'),
icon: <span className="i-ri-inbox-line size-[14px]" />,
},
]
const selectedOption = filterOptions.find(option => option.value === activeFilter) ?? filterOptions[0]
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn(
'flex h-8 cursor-pointer items-center gap-1 rounded-lg border border-transparent bg-components-input-bg-normal px-2 text-left select-none',
open && 'shadow-xs',
)}
>
<div className="p-px text-text-tertiary">
{selectedOption?.icon}
</div>
<div className="max-w-40 min-w-0 truncate system-sm-regular text-text-secondary">
{selectedOption?.text}
</div>
<div className="shrink-0 p-px">
<span className={cn('i-ri-arrow-down-s-line size-3.5 text-text-tertiary transition-transform', open && 'rotate-180')} />
</div>
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-60 rounded-lg border border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs"
>
<div className="max-h-72 overflow-auto p-1">
{filterOptions.map(option => (
<DropdownMenuItem
key={option.value}
onClick={() => {
void setEnvFilter(option.value)
setOpen(false)
}}
className={cn(
'flex items-center gap-2 rounded-lg py-1.5 pr-2 pl-3 select-none',
'cursor-pointer hover:bg-state-base-hover',
)}
>
<span className="shrink-0 text-text-tertiary">{option.icon}</span>
<span className="grow truncate text-sm/5 text-text-tertiary">{option.text}</span>
{option.value === activeFilter && (
<span className="i-custom-vender-line-general-check size-4 shrink-0 text-text-secondary" />
)}
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
)}
</DropdownMenu>
)
}

View File

@ -1,213 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
import { debounce, useQueryState } from 'nuqs'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../data'
import { CreateDeploymentButton } from './create-deployment-button'
import { EnvironmentFilter } from './environment-filter'
import { InstanceCard } from './instance-card'
import { envFilterQueryState, keywordsQueryState } from './query-state'
const INSTANCE_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
const EMPTY_INSTANCE_CARD_KEYS = Array.from({ length: 36 }, (_, index) => `empty-instance-card-${index}`)
function DeploymentsListState({ children }: {
children: ReactNode
}) {
return (
<div className="col-span-full rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-12 text-center system-sm-regular text-text-tertiary">
{children}
</div>
)
}
function DeploymentsListEmpty() {
const { t } = useTranslation('deployments')
return (
<>
{EMPTY_INSTANCE_CARD_KEYS.map(key => (
<div
key={key}
className="inline-flex h-40 rounded-xl bg-background-default-lighter"
/>
))}
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-linear-to-t from-background-body to-transparent">
<span className="system-md-medium text-text-tertiary">
{t('list.empty')}
</span>
</div>
</>
)
}
function InstanceCardSkeleton() {
return (
<div className="relative col-span-1 inline-flex h-40 flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-xs">
<div className="flex h-16.5 shrink-0 grow-0 items-center gap-3 px-3.5 pt-3.5 pb-3">
<div className="relative shrink-0">
<SkeletonRectangle className="my-0 size-10 animate-pulse rounded-lg" />
<SkeletonRectangle className="absolute -right-0.5 -bottom-0.5 my-0 size-4 animate-pulse rounded-sm shadow-xs" />
</div>
<div className="flex w-0 grow flex-col gap-1.5 py-px">
<SkeletonRectangle className="my-0 h-3.5 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-2.5 w-1/3 animate-pulse" />
</div>
</div>
<div className="flex grow flex-col gap-2 px-3.5">
<div className="flex min-w-0 items-center gap-1.5">
<SkeletonRectangle className="my-0 h-5 w-18 animate-pulse rounded-md" />
</div>
<div className="flex min-w-0 items-center gap-1.5">
<SkeletonRectangle className="my-0 size-3.5 animate-pulse rounded-sm" />
<SkeletonRectangle className="my-0 h-3 w-3/4 animate-pulse" />
</div>
</div>
<div className="absolute right-0 bottom-1 left-0 flex h-10.5 shrink-0 items-center pt-1 pr-12 pb-1.5 pl-3.5">
<div className="flex min-w-0 grow items-center gap-1.5">
<SkeletonRectangle className="my-0 size-3.5 animate-pulse rounded-sm" />
<SkeletonRectangle className="my-0 h-3 w-1/2 animate-pulse" />
</div>
</div>
<div className="absolute right-1.5 bottom-1 flex h-10.5 w-8 items-center justify-center">
<SkeletonRectangle className="my-0 h-1 w-4 animate-pulse rounded-full" />
</div>
</div>
)
}
function DeploymentsListSkeleton() {
return INSTANCE_CARD_SKELETON_KEYS.map(key => (
<InstanceCardSkeleton key={key} />
))
}
function DeploymentsSearchInput() {
const { t } = useTranslation('deployments')
const [keywords, setKeywords] = useQueryState('keywords', keywordsQueryState)
function handleKeywordsChange(next: string) {
void setKeywords(next.trim() ? next : null, {
limitUrlUpdates: next.trim() ? debounce(300) : undefined,
})
}
return (
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-50"
placeholder={t('filter.searchPlaceholder')}
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
)
}
function DeploymentsListControls() {
return (
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-3 bg-background-body px-12 pt-7 pb-5">
<CreateDeploymentButton />
<div className="flex items-center gap-2">
<EnvironmentFilter />
<DeploymentsSearchInput />
</div>
</div>
)
}
export function DeploymentsList() {
const { t } = useTranslation('deployments')
const [envFilter] = useQueryState('env', envFilterQueryState)
const [keywords] = useQueryState('keywords', keywordsQueryState)
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const queryKeywords = keywords.trim()
const {
data,
error,
fetchNextPage,
hasNextPage,
isError,
isFetching,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
...consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
...(envFilter === 'not-deployed' ? { notDeployed: true } : {}),
...(queryKeywords ? { query: queryKeywords } : {}),
},
}),
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
})
const pages = data?.pages ?? []
const apps = pages.flatMap(page => page.data ?? [])
const showSkeleton = isLoading || (isFetching && pages.length === 0)
const showEmptyState = !showSkeleton && !isError && apps.length === 0
useEffect(() => {
if (!hasNextPage || isLoading || isFetchingNextPage || error)
return
const anchor = anchorRef.current
const container = containerRef.current
if (!anchor || !container)
return
const observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting)
void fetchNextPage()
}, {
root: container,
rootMargin: '160px',
threshold: 0.1,
})
observer.observe(anchor)
return () => observer.disconnect()
}, [error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading])
return (
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<DeploymentsListControls />
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
showEmptyState && 'overflow-hidden',
)}
>
{showSkeleton
? <DeploymentsListSkeleton />
: isError
? <DeploymentsListState>{t('common.loadFailed')}</DeploymentsListState>
: apps.length === 0
? <DeploymentsListEmpty />
: apps.map(app => (
<InstanceCard
key={app.id}
app={app}
/>
))}
{isFetchingNextPage && <DeploymentsListSkeleton />}
</div>
<div ref={anchorRef} className="h-0" />
<div className="py-4" />
</div>
)
}

View File

@ -1,299 +0,0 @@
'use client'
import type { AppInstance } from '@dify/contracts/enterprise/types.gen'
import type { InstanceDetailTabKey } from '../detail/tabs'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLinkItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import AppIcon from '@/app/components/base/app-icon'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { toAppMode } from '../app-mode'
import { environmentName } from '../environment'
import { deploymentStatus } from '../runtime-status'
const INSTANCE_CARD_MENU_TAB_KEYS = ['deploy', 'releases', 'settings'] satisfies InstanceDetailTabKey[]
type EnvironmentStatusItem = {
key: 'failed' | 'deploying' | 'ready'
name: string
label: string
className: string
}
function getInstanceTabHref(appInstanceId: string, tabKey: InstanceDetailTabKey) {
return `/deployments/${appInstanceId}/${tabKey}`
}
export function InstanceCard({ app }: {
app: AppInstance
}) {
const { t } = useTranslation('deployments')
const { formatTimeFromNow } = useFormatTimeFromNow()
const [isStatusTooltipOpen, setIsStatusTooltipOpen] = useState(false)
const appInstanceId = app.id ?? ''
const appName = app.name ?? appInstanceId
const appMode = toAppMode(app.mode)
const detailHref = `/deployments/${appInstanceId}/overview`
const statusCount = (status: string) =>
app.statuses?.find(item => item.status === status)?.count ?? 0
const failedCount = statusCount('failed') + statusCount('deploy_failed')
const deployingCount = statusCount('deploying')
const readyCount = statusCount('ready')
const envCount = failedCount + deployingCount + readyCount
const lastDeployedAt = app.lastDeployedAt
? Date.parse(app.lastDeployedAt)
: null
const primaryStatus: 'none' | 'failed' | 'deploying' | 'ready' = envCount === 0
? 'none'
: failedCount > 0
? 'failed'
: deployingCount > 0
? 'deploying'
: 'ready'
const environmentDeploymentsQuery = useQuery({
...consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
}),
enabled: isStatusTooltipOpen && primaryStatus !== 'none' && !!appInstanceId,
})
const primaryText = primaryStatus === 'none'
? t('card.notDeployed')
: primaryStatus === 'failed'
? t('card.failed', { count: failedCount })
: primaryStatus === 'deploying'
? t('card.deploying', { count: deployingCount })
: t('card.ready', { count: readyCount })
const secondaryParts: string[] = []
if (primaryStatus === 'failed' && deployingCount > 0)
secondaryParts.push(t('card.deploying', { count: deployingCount }))
if ((primaryStatus === 'failed' || primaryStatus === 'deploying') && readyCount > 0)
secondaryParts.push(t('card.ready', { count: readyCount }))
const environmentDeployments = environmentDeploymentsQuery.data?.data?.filter(row => row.environment?.id) ?? []
const environmentStatusItems = environmentDeployments.flatMap<EnvironmentStatusItem>((row) => {
const status = deploymentStatus(row)
if (status === 'deploy_failed') {
return [{
key: 'failed',
name: environmentName(row.environment),
label: t('status.deployFailed'),
className: 'text-util-colors-red-red-700',
}]
}
if (status === 'deploying') {
return [{
key: 'deploying',
name: environmentName(row.environment),
label: t('status.deploying'),
className: 'text-util-colors-warning-warning-700',
}]
}
if (status === 'ready') {
return [{
key: 'ready',
name: environmentName(row.environment),
label: t('status.ready'),
className: 'text-util-colors-green-green-700',
}]
}
return []
})
const statusTooltip = primaryStatus === 'none'
? t('card.tooltip.notDeployed')
: (
<div className="flex min-w-45 flex-col gap-1">
{environmentStatusItems.map(item => (
<div key={`${item.key}-${item.name}`} className="flex min-w-0 justify-between gap-3">
<span className="truncate text-text-secondary" title={item.name}>{item.name}</span>
<span className={cn('shrink-0', item.className)}>{item.label}</span>
</div>
))}
{environmentStatusItems.length === 0 && !environmentDeploymentsQuery.isLoading && !environmentDeploymentsQuery.isError && (
<>
{failedCount > 0 && (
<div className="flex justify-between gap-3">
<span className="text-text-tertiary">{t('status.deployFailed')}</span>
<span className="text-text-secondary">{failedCount}</span>
</div>
)}
{deployingCount > 0 && (
<div className="flex justify-between gap-3">
<span className="text-text-tertiary">{t('status.deploying')}</span>
<span className="text-text-secondary">{deployingCount}</span>
</div>
)}
{readyCount > 0 && (
<div className="flex justify-between gap-3">
<span className="text-text-tertiary">{t('status.ready')}</span>
<span className="text-text-secondary">{readyCount}</span>
</div>
)}
</>
)}
{environmentDeploymentsQuery.isLoading && (
<div className="text-text-quaternary">
{t('common.loading')}
</div>
)}
{environmentDeploymentsQuery.isError && (
<div className="text-util-colors-warning-warning-700">
{t('common.loadFailed')}
</div>
)}
</div>
)
if (!app.id)
return null
const healthPillClass = primaryStatus === 'none'
? 'text-text-tertiary bg-background-section-burn'
: primaryStatus === 'failed'
? 'text-util-colors-red-red-700 bg-util-colors-red-red-50'
: primaryStatus === 'deploying'
? 'text-util-colors-warning-warning-700 bg-util-colors-warning-warning-50'
: 'text-util-colors-green-green-700 bg-util-colors-green-green-50'
const healthDotClass = primaryStatus === 'none'
? 'bg-text-quaternary'
: primaryStatus === 'failed'
? 'bg-util-colors-red-red-500'
: primaryStatus === 'deploying'
? 'bg-util-colors-warning-warning-500 animate-pulse'
: 'bg-util-colors-green-green-500'
const appModeLabel = t(`appMode.${appMode}`, { defaultValue: appMode })
return (
<div
className="group relative col-span-1 inline-flex h-40 cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-xs transition-all duration-200 ease-in-out hover:shadow-lg"
>
<Link
href={detailHref}
className="flex h-full flex-col rounded-xl outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<div className="flex h-16.5 shrink-0 grow-0 items-center gap-3 px-3.5 pt-3.5 pb-3">
<div className="relative shrink-0">
<AppIcon
size="large"
iconType="emoji"
icon={app.icon}
background={app.iconBackground}
/>
<AppTypeIcon
type={appMode}
wrapperClassName="absolute -bottom-0.5 -right-0.5 size-4 shadow-xs"
className="size-3"
/>
</div>
<div className="w-0 grow py-px">
<div className="flex items-center text-sm/5 font-semibold text-text-secondary">
<div className="truncate" title={appName}>{appName}</div>
</div>
<div className="truncate text-2xs/4.5 font-medium text-text-tertiary" title={appModeLabel}>
{appModeLabel}
</div>
</div>
</div>
<div className="flex grow flex-col gap-2 px-3.5">
<Tooltip open={isStatusTooltipOpen} onOpenChange={setIsStatusTooltipOpen}>
<TooltipTrigger
render={(
<div className="flex min-w-0 items-center gap-1.5">
<span
className={cn(
'inline-flex h-5 shrink-0 items-center gap-1 rounded-md px-1.5 system-xs-medium',
healthPillClass,
)}
>
<span className={cn('size-1.5 rounded-full', healthDotClass)} />
{primaryText}
</span>
{secondaryParts.length > 0 && (
<span className="truncate system-xs-regular text-text-tertiary">
{secondaryParts.join(' · ')}
</span>
)}
</div>
)}
/>
<TooltipContent>{statusTooltip}</TooltipContent>
</Tooltip>
<div className="flex min-w-0 items-center gap-1.5 system-xs-regular text-text-tertiary">
<span aria-hidden className="i-ri-apps-2-line size-3.5 shrink-0 text-text-quaternary" />
<span className="truncate" title={app.sourceAppName ?? appName}>
{t('card.fromApp', { name: app.sourceAppName ?? appName })}
</span>
</div>
</div>
<div className="absolute right-0 bottom-1 left-0 flex h-10.5 shrink-0 items-center pt-1 pr-12 pb-1.5 pl-3.5">
<div className="flex min-w-0 grow items-center gap-1.5 system-xs-regular text-text-tertiary">
<span aria-hidden className="i-ri-time-line size-3.5 shrink-0 text-text-quaternary" />
<span className="truncate">
{lastDeployedAt
? t('card.lastDeployed', { time: formatTimeFromNow(lastDeployedAt) })
: t('card.neverDeployed')}
</span>
</div>
</div>
</Link>
<div className="pointer-events-auto absolute right-1.5 bottom-1 flex h-10.5 items-center">
<InstanceCardActions appInstanceId={appInstanceId} />
</div>
</div>
)
}
function InstanceCardActions({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={t('card.moreActions')}
className={cn(
'flex size-8 items-center justify-center rounded-md border-none bg-transparent p-2 hover:bg-state-base-hover data-popup-open:bg-state-base-hover data-popup-open:shadow-none',
)}
>
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-54">
{INSTANCE_CARD_MENU_TAB_KEYS.map((tabKey) => {
const href = getInstanceTabHref(appInstanceId, tabKey)
return (
<DropdownMenuLinkItem
key={tabKey}
className="gap-2 px-3"
render={<Link href={href} />}
>
<span className="system-sm-regular text-text-secondary">{t(`tabs.${tabKey}.name`)}</span>
</DropdownMenuLinkItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -1,4 +0,0 @@
import { parseAsString } from 'nuqs'
export const envFilterQueryState = parseAsString.withDefault('all').withOptions({ history: 'push' })
export const keywordsQueryState = parseAsString.withDefault('').withOptions({ history: 'push' })

View File

@ -1,119 +0,0 @@
'use client'
import type { AppInstance, AppInstanceBasicInfo } from '@dify/contracts/enterprise/types.gen'
import type { NavItem } from '@/app/components/header/nav/nav-selector'
import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Nav from '@/app/components/header/nav'
import { useParams, useSelectedLayoutSegment } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { toAppMode } from '../app-mode'
import { CreateInstanceModal } from '../components/create-instance-modal'
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../data'
function navItemFromListApp(app: AppInstance): NavItem[] {
if (!app.id || !app.name)
return []
return [{
id: app.id,
name: app.name,
link: `/deployments/${app.id}/overview`,
icon_type: 'emoji',
icon: app.icon ?? '',
icon_background: app.iconBackground ?? null,
icon_url: null,
mode: toAppMode(app.mode),
}]
}
function navItemFromOverview(instance?: AppInstanceBasicInfo): NavItem | undefined {
if (!instance?.id)
return undefined
const name = instance.name ?? instance.id
return {
id: instance.id,
name,
link: `/deployments/${instance.id}/overview`,
icon_type: 'emoji',
icon: instance.icon ?? '',
icon_background: instance.iconBackground ?? null,
icon_url: null,
mode: toAppMode(instance.mode),
}
}
export function DeploymentsNav() {
const { t } = useTranslation()
const selectedSegment = useSelectedLayoutSegment()
const isActive = selectedSegment === 'deployments'
const params = useParams<{ appInstanceId?: string }>()
const appInstanceId = params?.appInstanceId
const hasAppInstanceId = Boolean(appInstanceId)
const [createModalOpen, setCreateModalOpen] = useState(false)
const { data: currentInstance } = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
input: { params: { appInstanceId: appInstanceId ?? '' } },
enabled: isActive && hasAppInstanceId,
select: data => data.overview?.appInstance,
}))
const listQuery = useInfiniteQuery({
...consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
},
}),
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
enabled: isActive,
})
const appNavItems = listQuery.data?.pages.flatMap(page => page.data?.flatMap(navItemFromListApp) ?? []) ?? []
const currentNavItem = navItemFromOverview(currentInstance)
const navigationItems: NavItem[] = isActive
? currentNavItem && !appNavItems.some(item => item.id === currentNavItem.id)
? [...appNavItems, currentNavItem]
: appNavItems
: []
const curNav = appInstanceId
? navigationItems.find(item => item.id === appInstanceId)
: undefined
function handleCreate() {
setCreateModalOpen(true)
}
function handleLoadMore() {
if (listQuery.hasNextPage && !listQuery.isFetchingNextPage)
void listQuery.fetchNextPage()
}
return (
<>
<Nav
isApp={false}
icon={<span aria-hidden className="i-ri-rocket-line size-4" />}
activeIcon={<span aria-hidden className="i-ri-rocket-fill size-4" />}
text={t('menus.deployments', { ns: 'common' })}
activeSegment="deployments"
link="/deployments"
curNav={curNav}
navigationItems={navigationItems}
createText={t('deployments:createModal.title')}
onCreate={handleCreate}
onLoadMore={handleLoadMore}
isLoadingMore={listQuery.isFetchingNextPage}
/>
<CreateInstanceModal open={createModalOpen} onOpenChange={setCreateModalOpen} />
</>
)
}

View File

@ -1,69 +0,0 @@
import type { ReleaseRow, ReleaseSummary } from '@dify/contracts/enterprise/types.gen'
export type ReleaseDeploymentAction = 'deploy' | 'deployExistingRelease' | 'promote' | 'rollback'
function releaseCreatedAt(release?: ReleaseSummary | ReleaseRow) {
const value = release?.createdAt
if (!value)
return undefined
const time = Date.parse(value)
return Number.isFinite(time) ? time : undefined
}
function releaseById(releaseRows: ReleaseRow[], releaseId?: string) {
return releaseRows.find(release => release.id === releaseId)
}
function releaseOrderIndex(releaseRows: ReleaseRow[], releaseId?: string) {
return releaseRows.findIndex(release => release.id === releaseId)
}
function compareReleaseOrder(targetRelease: ReleaseSummary | ReleaseRow | undefined, currentRelease: ReleaseSummary, releaseRows: ReleaseRow[]) {
if (!targetRelease?.id || !currentRelease.id)
return undefined
if (targetRelease.id === currentRelease.id)
return 0
const normalizedTargetRelease = releaseById(releaseRows, targetRelease.id) ?? targetRelease
const normalizedCurrentRelease = releaseById(releaseRows, currentRelease.id) ?? currentRelease
const targetCreatedAt = releaseCreatedAt(normalizedTargetRelease)
const currentCreatedAt = releaseCreatedAt(normalizedCurrentRelease)
if (targetCreatedAt !== undefined && currentCreatedAt !== undefined && targetCreatedAt !== currentCreatedAt)
return targetCreatedAt > currentCreatedAt ? 1 : -1
const targetIndex = releaseOrderIndex(releaseRows, targetRelease.id)
const currentIndex = releaseOrderIndex(releaseRows, currentRelease.id)
if (targetIndex >= 0 && currentIndex >= 0 && targetIndex !== currentIndex)
return targetIndex < currentIndex ? 1 : -1
return undefined
}
export function releaseDeploymentAction({
targetRelease,
currentRelease,
releaseRows,
isExistingRelease,
}: {
targetRelease?: ReleaseSummary | ReleaseRow
currentRelease?: ReleaseSummary
releaseRows: ReleaseRow[]
isExistingRelease?: boolean
}): ReleaseDeploymentAction {
if (!currentRelease?.id)
return isExistingRelease ? 'deployExistingRelease' : 'deploy'
const order = compareReleaseOrder(targetRelease, currentRelease, releaseRows)
if (order === -1)
return 'rollback'
if (order === 1)
return 'promote'
return targetRelease?.id && targetRelease.id !== currentRelease.id
? 'promote'
: isExistingRelease
? 'deployExistingRelease'
: 'deploy'
}

View File

@ -1,15 +0,0 @@
import type { ReleaseRow, ReleaseSummary } from '@dify/contracts/enterprise/types.gen'
export function formatDate(value?: string) {
if (!value)
return '—'
return value.replace('T', ' ').replace(/\.\d+Z?$/, '').replace(/Z$/, '').slice(0, 16)
}
export function releaseLabel(release?: ReleaseSummary | ReleaseRow) {
return release?.name || release?.id || '—'
}
export function releaseCommit(release?: ReleaseSummary | ReleaseRow) {
return release && 'shortCommitId' in release ? release.shortCommitId || '—' : '—'
}

View File

@ -1,17 +0,0 @@
import type { ReleaseRuntimeBinding } from '@dify/contracts/enterprise/types.gen'
export function runtimeBindingSummary(binding?: ReleaseRuntimeBinding) {
return binding?.name || binding?.displayValue || binding?.kind || '—'
}
export function isRuntimeEnvVarBinding(binding?: ReleaseRuntimeBinding) {
return (binding?.kind?.toLowerCase() ?? '').includes('env')
}
export function isRuntimeModelBinding(binding?: ReleaseRuntimeBinding) {
return (binding?.kind?.toLowerCase() ?? '').includes('model')
}
export function isRuntimePluginBinding(binding?: ReleaseRuntimeBinding) {
return !isRuntimeEnvVarBinding(binding) && !isRuntimeModelBinding(binding)
}

View File

@ -1,40 +0,0 @@
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
type DeploymentUiStatus = 'ready' | 'deploying' | 'deploy_failed' | 'unknown'
export const DEPLOYMENT_STATUS_POLLING_INTERVAL = 3000
type DeploymentStatusQueryData = {
data?: Array<Pick<EnvironmentDeployment, 'status'>>
}
export function isUndeployedDeploymentRow(row?: EnvironmentDeployment) {
return (row?.status?.toLowerCase() ?? '').includes('undeployed') || (!row?.runtime?.runtimeInstanceId && !row?.currentRelease && !row?.runtime)
}
export function deploymentStatus(row?: Pick<EnvironmentDeployment, 'status'>): DeploymentUiStatus {
const runtimeStatus = row?.status?.toLowerCase() ?? ''
if (!runtimeStatus || runtimeStatus.includes('undeployed'))
return 'unknown'
if (runtimeStatus.includes('deploying') || runtimeStatus.includes('pending'))
return 'deploying'
if (runtimeStatus.includes('fail') || runtimeStatus.includes('error'))
return 'deploy_failed'
if (runtimeStatus.includes('ready')
|| runtimeStatus.includes('running')
|| runtimeStatus.includes('active')
|| runtimeStatus.includes('success')
|| runtimeStatus.includes('succeed')
|| runtimeStatus.includes('deployed')) {
return 'ready'
}
return 'unknown'
}
export function hasDeployingDeployment(rows?: Array<Pick<EnvironmentDeployment, 'status'>>) {
return rows?.some(row => deploymentStatus(row) === 'deploying') ?? false
}
export function deploymentStatusPollingInterval(data?: DeploymentStatusQueryData) {
return hasDeployingDeployment(data?.data) ? DEPLOYMENT_STATUS_POLLING_INTERVAL : false
}

View File

@ -1,25 +0,0 @@
import { atom } from 'jotai'
type OpenDeployDrawerParams = {
appInstanceId: string
environmentId?: string
releaseId?: string
}
export const deployDrawerOpenAtom = atom(false)
export const deployDrawerAppInstanceIdAtom = atom<string | undefined>(undefined)
export const deployDrawerEnvironmentIdAtom = atom<string | undefined>(undefined)
export const deployDrawerReleaseIdAtom = atom<string | undefined>(undefined)
export const openDeployDrawerAtom = atom(null, (_get, set, params: OpenDeployDrawerParams) => {
set(deployDrawerAppInstanceIdAtom, params.appInstanceId)
set(deployDrawerEnvironmentIdAtom, params.environmentId)
set(deployDrawerReleaseIdAtom, params.releaseId)
set(deployDrawerOpenAtom, true)
})
export const closeDeployDrawerAtom = atom(null, (_get, set) => {
set(deployDrawerOpenAtom, false)
set(deployDrawerAppInstanceIdAtom, undefined)
set(deployDrawerEnvironmentIdAtom, undefined)
set(deployDrawerReleaseIdAtom, undefined)
})

View File

@ -1,26 +0,0 @@
import { PUBLIC_API_PREFIX } from '@/config'
const absoluteUrlRegExp = /^[a-z][a-z\d+.-]*:\/\//i
function withLeadingSlash(path: string) {
return path.startsWith('/') ? path : `/${path}`
}
function publicWebappOrigin() {
try {
return new URL(PUBLIC_API_PREFIX).origin
}
catch {
return PUBLIC_API_PREFIX.replace(/\/api\/?$/, '').replace(/\/+$/, '')
}
}
export function webappUrl(url?: string) {
if (!url)
return ''
if (absoluteUrlRegExp.test(url))
return url
const origin = publicWebappOrigin()
return `${origin}${withLeadingSlash(url)}`
}

View File

@ -13,7 +13,6 @@ import type datasetHitTesting from '../i18n/en-US/dataset-hit-testing.json'
import type datasetPipeline from '../i18n/en-US/dataset-pipeline.json'
import type datasetSettings from '../i18n/en-US/dataset-settings.json'
import type dataset from '../i18n/en-US/dataset.json'
import type deployments from '../i18n/en-US/deployments.json'
import type education from '../i18n/en-US/education.json'
import type explore from '../i18n/en-US/explore.json'
import type layout from '../i18n/en-US/layout.json'
@ -47,7 +46,6 @@ export type Resources = {
datasetHitTesting: typeof datasetHitTesting
datasetPipeline: typeof datasetPipeline
datasetSettings: typeof datasetSettings
deployments: typeof deployments
education: typeof education
explore: typeof explore
layout: typeof layout
@ -81,7 +79,6 @@ export const namespaces = [
'datasetHitTesting',
'datasetPipeline',
'datasetSettings',
'deployments',
'education',
'explore',
'layout',

View File

@ -278,7 +278,6 @@
"menus.apps": "Studio",
"menus.datasets": "Knowledge",
"menus.datasetsTips": "COMING SOON: Import your own text data or write data in real-time via Webhook for LLM context enhancement.",
"menus.deployments": "Deployments",
"menus.explore": "Explore",
"menus.exploreMarketplace": "Explore Marketplace",
"menus.newApp": "New App",

View File

@ -1,452 +0,0 @@
{
"access.api.backendTitle": "Backend service API",
"access.api.description": "Access this instance over HTTP. Each API key is scoped to one environment.",
"access.api.developerTitle": "Developer API",
"access.api.disabled": "API access is turned off for this instance.",
"access.api.dismissToken": "Dismiss token",
"access.api.empty": "Deploy to an environment first to start issuing API keys.",
"access.api.endpoint": "Endpoint",
"access.api.envPrefix": "env: {{env}}",
"access.api.keyList": "API key list",
"access.api.newKey": "New key",
"access.api.newKeyForEnv": "Generate for {{env}}",
"access.api.newTokenDescription": "This token is shown only once. Copy it before leaving this page.",
"access.api.newTokenLabel": "Token",
"access.api.newTokenTitle": "API key created",
"access.api.noKeys": "No API keys yet. Create one to start calling the API.",
"access.api.title": "API",
"access.channels.description": "WebApp and CLI entry points use the access permissions above.",
"access.channels.disabled": "Access channels are turned off for this instance.",
"access.channels.followPermission": "Follows permissions",
"access.channels.title": "Access channels",
"access.cli.description": "Use the unified domain when accessing this instance from the CLI.",
"access.cli.docs": "Usage guide",
"access.cli.domain": "Domain",
"access.cli.empty": "CLI endpoint not configured.",
"access.cli.install": "Install CLI",
"access.cli.title": "CLI",
"access.copied": "Copied",
"access.copy": "Copy",
"access.copyFailed": "Copy failed",
"access.copyToast": "Copied to clipboard",
"access.hide": "Hide",
"access.members.clearAll": "Clear all",
"access.members.empty": "No matches found.",
"access.members.emptySelection": "Choose at least one group or member to save this permission.",
"access.members.groupCount_one": "{{count}} group",
"access.members.groupCount_other": "{{count}} groups",
"access.members.groups": "Groups",
"access.members.individuals": "Members",
"access.members.memberCount_one": "{{count}} member",
"access.members.memberCount_other": "{{count}} members",
"access.members.pickPlaceholder": "Select groups or members",
"access.members.searchPlaceholder": "Search groups and members",
"access.members.selectedLabel": "Selected",
"access.permission.anyone": "Anyone",
"access.permission.anyoneDesc": "Anyone with the link, no login required",
"access.permission.comingSoon": "Coming soon",
"access.permission.external": "Authenticated external users",
"access.permission.externalDesc": "External users who completed SSO/OIDC authentication",
"access.permission.memberCount_one": "{{count}} member",
"access.permission.memberCount_other": "{{count}} members",
"access.permission.organization": "Only members within the organization",
"access.permission.organizationDesc": "All internal members of your workspace",
"access.permission.specific": "Specific members",
"access.permission.specificDesc": "Pick groups or individual members",
"access.permission.specificUnavailable": "Specific member selection is disabled until real workspace subjects are connected.",
"access.permission.updateFailed": "Failed to update access policy.",
"access.permissions.description": "Configure who can access this instance in each deployed environment.",
"access.permissions.title": "Access permissions",
"access.revoke": "Revoke",
"access.runAccess.description": "Manage how users can run this app and who is allowed to access it per environment.",
"access.runAccess.disabled": "Run access is turned off for this instance.",
"access.runAccess.mcp": "MCP",
"access.runAccess.mcpDesc": "Expose this instance as a Model Context Protocol server.",
"access.runAccess.mcpEmpty": "MCP endpoint not configured.",
"access.runAccess.noEnvs": "Deploy to an environment to configure access permissions.",
"access.runAccess.openWebapp": "Open WebApp",
"access.runAccess.permissions": "Access permissions",
"access.runAccess.permissionsDesc": "Who can access this instance in each environment.",
"access.runAccess.title": "Run access",
"access.runAccess.urlLabel": "URL",
"access.runAccess.webapp": "WebApp",
"access.runAccess.webappDesc": "Hosted web page for end users.",
"access.runAccess.webappEmpty": "WebApp URL not configured.",
"access.show": "Show",
"appMode.advanced-chat": "Chatflow",
"appMode.agent-chat": "Agent",
"appMode.chat": "Chatbot",
"appMode.completion": "Completion",
"appMode.workflow": "Workflow",
"card.deploying": "{{count}} deploying",
"card.failed": "{{count}} failed",
"card.fromApp": "From {{name}}",
"card.lastDeployed": "Last deployed {{time}}",
"card.menu.delete": "Delete instance",
"card.menu.deleteDisabled": "Instance deletion is not available for backend-managed deployments yet.",
"card.menu.deploy": "Deploy to an environment",
"card.menu.viewDetail": "View instance detail",
"card.moreActions": "More actions",
"card.neverDeployed": "Not deployed yet",
"card.notDeployed": "Not deployed",
"card.ready": "{{count}} ready",
"card.tooltip.notDeployed": "This instance has not been deployed to any environment yet.",
"common.loadFailed": "Failed to load. Try again later.",
"common.loading": "Loading...",
"createGuide.actions.back": "Back",
"createGuide.actions.cancel": "Cancel",
"createGuide.actions.continue": "Continue",
"createGuide.actions.createAndDeploy": "Create and deploy",
"createGuide.actions.deploying": "Deploying...",
"createGuide.description": "Create a usable deployment through source, release, bindings, and environment selection.",
"createGuide.done.backToList": "Back to deployments",
"createGuide.done.description": "Deployment has started for {{environment}}.",
"createGuide.done.next": "You can review status, access, and release history from the deployments list.",
"createGuide.done.ready": "The deployment request has been submitted.",
"createGuide.done.title": "Deployment started",
"createGuide.dsl.defaultAppName": "Imported DSL app",
"createGuide.dsl.description": "Prepare an app instance from a DSL package. Import execution will be wired when the backend API is ready.",
"createGuide.dsl.dropDescription": "DSL import is a UI placeholder for now. The final deploy action will stay disabled from real import work until the API is available.",
"createGuide.dsl.dropTitle": "Upload DSL package",
"createGuide.dsl.title": "Import DSL",
"createGuide.errors.deployFailed": "Failed to create and deploy the app instance.",
"createGuide.method.description": "Start from an app already in Studio or prepare a deployment from a DSL package.",
"createGuide.methods.bindApp.description": "Use an app that already exists in Studio as the release source.",
"createGuide.methods.bindApp.title": "Bind existing Studio app",
"createGuide.methods.importDsl.description": "Upload a YAML DSL package and continue through the deployment UI.",
"createGuide.methods.importDsl.title": "Import DSL",
"createGuide.methods.mocked": "Mocked",
"createGuide.nav.back": "Deployments",
"createGuide.release.defaultNote": "Initial deployable release",
"createGuide.release.description": "Name the deployable instance and capture the first release metadata.",
"createGuide.release.instanceName": "Instance name",
"createGuide.release.releaseName": "Release name",
"createGuide.release.releaseNote": "Release note",
"createGuide.release.title": "Create release",
"createGuide.review.bindings": "Runtime bindings",
"createGuide.review.description": "Confirm the source, release, credentials, and target environment before deployment.",
"createGuide.review.environment": "Environment",
"createGuide.review.instance": "Instance",
"createGuide.review.plan.createInstance": "Create the app instance",
"createGuide.review.plan.createRelease": "Create release {{release}}",
"createGuide.review.plan.deployTo": "Deploy to {{environment}}",
"createGuide.review.plan.resolveBindings": "Resolve runtime credentials",
"createGuide.review.planTitle": "Execution plan",
"createGuide.review.release": "Release",
"createGuide.review.releaseNote": "Release note",
"createGuide.review.source": "Source",
"createGuide.review.summary": "Deployment summary",
"createGuide.review.title": "Review deployment",
"createGuide.source.description": "Choose the Studio app that will become the first release source.",
"createGuide.source.empty": "No Studio apps found.",
"createGuide.source.searchPlaceholder": "Search apps",
"createGuide.source.sourceApp": "Source app",
"createGuide.source.title": "Choose source",
"createGuide.steps.done": "Done",
"createGuide.steps.method": "Select a method",
"createGuide.steps.release": "Create release",
"createGuide.steps.review": "Create and deploy",
"createGuide.steps.source": "Choose source",
"createGuide.steps.target": "Deploy target",
"createGuide.target.bindingHint": "Pick the credentials that will be used when this release runs.",
"createGuide.target.bindings": "Runtime bindings",
"createGuide.target.description": "Select the runtime environment and resolve the credentials used by the release.",
"createGuide.target.environment": "Target environment",
"createGuide.target.missingRequiredBinding": "Select a credential for this required binding.",
"createGuide.target.noBindingRequired": "No runtime binding required.",
"createGuide.target.noCredentialCandidates": "No available credentials.",
"createGuide.target.required": "Required",
"createGuide.target.selectCredential": "Select a credential",
"createGuide.target.title": "Select deploy target",
"createGuide.title": "Create deployment",
"createModal.appPickerPlaceholder": "Select a source app",
"createModal.appSearchEmpty": "No matching apps",
"createModal.appSearchPlaceholder": "Search apps…",
"createModal.cancel": "Cancel",
"createModal.create": "Create",
"createModal.createFailed": "Failed to create app instance.",
"createModal.description": "Pick a source app from Studio and create a deployable instance.",
"createModal.descriptionLabel": "Description",
"createModal.descriptionPlaceholder": "Describe what this instance is used for",
"createModal.loadMoreApps": "Load more apps",
"createModal.loadingApps": "Loading apps…",
"createModal.nameLabel": "Instance name",
"createModal.namePlaceholder": "Instance name",
"createModal.noApps": "No apps found in this workspace. Create one in Studio first.",
"createModal.selected": "Selected",
"createModal.sourceApp": "Source app (required)",
"createModal.title": "Create app instance",
"deployDrawer.bindingOptionsFailed": "Failed to load credential options.",
"deployDrawer.bindingSelectionHint": "Choose the credentials used by this deployment.",
"deployDrawer.bindingsDisabled": "Resolved from the release preview. Editing is not available yet.",
"deployDrawer.cancel": "Cancel",
"deployDrawer.defaultSelect": "Select...",
"deployDrawer.deploy": "Deploy",
"deployDrawer.deployExistingRelease": "Deploy existing release",
"deployDrawer.deployExistingReleaseDescription": "Deploy an existing release from the release history to a target environment.",
"deployDrawer.deployExistingReleaseTitle": "Deploy existing release",
"deployDrawer.deployFailed": "Failed to start deployment.",
"deployDrawer.deploying": "Deploying...",
"deployDrawer.description": "Select a release and deploy it to a target environment.",
"deployDrawer.envVars": "Environment variables",
"deployDrawer.existingReleaseHint": "This existing release will be deployed as-is. No new release will be created.",
"deployDrawer.loadingBindings": "Resolving...",
"deployDrawer.lockedHint": "Locked to current environment",
"deployDrawer.missingRequiredBinding": "Select a credential for this required binding.",
"deployDrawer.modelCreds": "Model credentials",
"deployDrawer.needsValidation": " (needs validation)",
"deployDrawer.newReleaseHint": "A new release will be created from the current app YAML.",
"deployDrawer.noBindingRequired": "Not required",
"deployDrawer.noCredentialCandidates": "No available credentials.",
"deployDrawer.noReleaseAvailable": "Create a release before deploying this app instance.",
"deployDrawer.notFound": "Instance not found.",
"deployDrawer.noteLabel": "Release note (optional)",
"deployDrawer.notePlaceholder": "e.g. Ship onboarding copy tweak",
"deployDrawer.pluginCreds": "Plugin credentials",
"deployDrawer.promote": "Promote",
"deployDrawer.promoteDescription": "Promote a newer release to the target environment.",
"deployDrawer.promoteTitle": "Promote release",
"deployDrawer.readOnly": "Read-only",
"deployDrawer.releaseLabel": "Release",
"deployDrawer.requiredBinding": "Required",
"deployDrawer.rollback": "Rollback",
"deployDrawer.rollbackDescription": "Rollback the target environment to a previous release.",
"deployDrawer.rollbackTitle": "Rollback release",
"deployDrawer.runtimeCredentials": "Runtime credentials",
"deployDrawer.secretPlaceholder": "secret",
"deployDrawer.selectCredential": "Select a credential",
"deployDrawer.selectEnv": "Select an environment",
"deployDrawer.selectProviderCred": "Select {{provider}} credential",
"deployDrawer.selectProviderKey": "Select {{provider}} key",
"deployDrawer.selectRelease": "Select a release",
"deployDrawer.targetEnv": "Target environment",
"deployDrawer.title": "Deploy to environment",
"deployDrawer.valuePlaceholder": "value",
"deployTab.cancelDeployment": "Cancel deployment",
"deployTab.col.actions": "Actions",
"deployTab.col.currentRelease": "Current release",
"deployTab.col.environment": "Environment",
"deployTab.col.status": "Status",
"deployTab.col.updated": "Updated",
"deployTab.collapseDetails": "Collapse deployment details",
"deployTab.confirmUndeploy": "Undeploy",
"deployTab.deployOtherVersion": "Deploy another release",
"deployTab.deployToEnv": "Deploy to {{name}}",
"deployTab.deployToNewEnv": "Deploy to new environment...",
"deployTab.empty": "No deployments yet. Deploy this instance to an environment to get started.",
"deployTab.envCount": "Environments",
"deployTab.expandDetails": "Expand deployment details",
"deployTab.moreActions": "More actions",
"deployTab.newDeployment": "New deployment",
"deployTab.panel.commit": "Commit ID",
"deployTab.panel.deploymentId": "Deployment ID",
"deployTab.panel.endpoints": "Endpoints",
"deployTab.panel.envVars": "Environment variables",
"deployTab.panel.error": "Error",
"deployTab.panel.failedRelease": "Failed release",
"deployTab.panel.health": "Health",
"deployTab.panel.instanceInfo": "Instance info",
"deployTab.panel.modelCreds": "Model credentials",
"deployTab.panel.pluginCreds": "Plugin credentials",
"deployTab.panel.release": "Release",
"deployTab.panel.releaseCreatedAt": "Release created at",
"deployTab.panel.releaseInfo": "Release info",
"deployTab.panel.replicas": "Replicas",
"deployTab.panel.run": "Run",
"deployTab.panel.runtimeBindings": "Runtime bindings",
"deployTab.panel.runtimeInfo": "Runtime",
"deployTab.panel.runtimeMode": "Runtime mode",
"deployTab.panel.runtimeNote": "Runtime note",
"deployTab.panel.targetRelease": "Target release",
"deployTab.panel.unknownError": "Deployment failed.",
"deployTab.promote": "Promote",
"deployTab.releaseCreatedAt": "Release created {{time}}",
"deployTab.retry": "Retry",
"deployTab.shortcut": "Shortcut",
"deployTab.status.deployFailed": "Deploy failed",
"deployTab.status.deployingRelease": "Deploying ({{release}})",
"deployTab.status.runningWithFailed": "Running (last deployment failed)",
"deployTab.undeploy": "Undeploy",
"deployTab.undeployConfirmDesc": "End-user access will stop immediately. The release can be redeployed later.",
"deployTab.undeployConfirmTitle": "Undeploy from {{name}}?",
"deployTab.undeployFrom": "Undeploy from {{name}}",
"deployTab.viewError": "View error",
"deployTab.viewLogs": "View logs",
"deployTab.viewProgress": "View progress",
"detail.backToInstances": "Back to app instances",
"detail.deployingCount": "{{count}} deploying",
"detail.envCount_one": "{{count}} env",
"detail.envCount_other": "{{count}} envs",
"detail.failedCount": "{{count}} failed",
"detail.notFound": "Instance not found",
"detail.openSourceApp": "Open source app {{name}}",
"detail.sourceApp": "Source app",
"detail.sourceAppDeleted": "Source app deleted",
"detail.sourceAppLink": "Source app",
"documentTitle.create": "Create deployment · Deployments",
"documentTitle.detail": "Instance · Deployments",
"documentTitle.list": "Deployments",
"filter.allEnvs": "All environments",
"filter.notDeployed": "Not deployed",
"filter.searchPlaceholder": "Search instances",
"health.degraded": "Degraded",
"health.ready": "Ready",
"list.createDeployment": "Create deployment",
"list.empty": "No app instances found.",
"mode.isolated": "Isolated",
"mode.shared": "Shared",
"newInstance.comingSoon": "Coming soon",
"newInstance.fromStudio": "Select from Studio",
"newInstance.importDSL": "Import DSL",
"newInstance.title": "Create app instance",
"overview.accessEndpoints": "Access endpoints",
"overview.accessStatus": "Access status",
"overview.api": "API",
"overview.apiKeysCount_one": "{{count}} API key",
"overview.apiKeysCount_other": "{{count}} API keys",
"overview.appMode": "App mode",
"overview.availableForDeployment": "Available for deployment",
"overview.basicInfo": "Basic info",
"overview.chip.behind_one": "↑ 1",
"overview.chip.behind_other": "↑ {{count}}",
"overview.chip.deploying": "deploying",
"overview.chip.empty": "empty",
"overview.chip.failed": "failed",
"overview.chip.latest": "latest",
"overview.chip.needsReleaseFirst": "Create a release first",
"overview.chip.olderRelease": "older",
"overview.chip.openInDeployTab": "View deployment progress",
"overview.cli": "CLI",
"overview.configureAccess": "Configure access",
"overview.configured": "Configured",
"overview.createRelease": "Create release",
"overview.created": "Created",
"overview.deploy": "Deploy",
"overview.deployedEnvironments": "deployed",
"overview.deploymentOverview": "Deployment overview",
"overview.deploymentStatus": "Deployment status",
"overview.description": "Description",
"overview.developerApi": "Developer API",
"overview.disabled": "Disabled",
"overview.emptyValue": "Not set",
"overview.enabled": "Enabled",
"overview.enabledChannels": "access enabled",
"overview.endUserAccess": "End-user access",
"overview.environments": "Environments",
"overview.hero.byName": "by {{name}}",
"overview.hero.empty": "No releases yet",
"overview.hero.emptyDescription": "Capture the current app to make a release you can deploy.",
"overview.hero.propagation_one": "deployed to {{count}}/{{total}} environment",
"overview.hero.propagation_other": "deployed to {{count}}/{{total}} environments",
"overview.hero.untargeted": "no environments configured yet",
"overview.instanceDetails": "Instance details",
"overview.instanceId": "Instance ID",
"overview.manageDeployments": "Manage deployments",
"overview.name": "Name",
"overview.noAccessConfig": "No access configuration.",
"overview.noReleaseSourceUnavailable": "The source app was deleted. Existing releases can still be deployed, but there is no release yet.",
"overview.noReleaseYet": "Create a release before deploying this app instance.",
"overview.notConfigured": "Not configured",
"overview.previousReleases.empty": "No earlier releases yet.",
"overview.previousReleases.retired": "Not currently deployed",
"overview.previousReleases.title": "Previous releases",
"overview.previousReleases.viewAll": "View all",
"overview.ready": "Ready",
"overview.recentReleases": "Recent releases",
"overview.releaseDeployedTitle": "{{release}} is deployed",
"overview.releaseReadyTitle": "{{release}} is ready to deploy",
"overview.serviceMap": "Service map",
"overview.servingRelease": "Serving {{release}}",
"overview.servingReleaseDescription": "This app instance is deployed to {{count}}/{{total}} environments.",
"overview.sourceAppDeletedDescription": "Historical releases are still deployable, but new releases cannot be generated from the deleted source app. Switch to another source app to continue.",
"overview.sourceAppDeletedTitle": "Source app was deleted",
"overview.sourceAppDeletedValue": "Deleted source app",
"overview.sourceAppUnavailable": "Unavailable",
"overview.strip.empty": "No environments configured.",
"overview.strip.failedAlert_one": "1 environment failed to deploy.",
"overview.strip.failedAlert_other": "{{count}} environments failed to deploy.",
"overview.strip.investigate": "Investigate",
"overview.strip.summary_one": "1 of {{total}} on latest",
"overview.strip.summary_other": "{{count}} of {{total}} on latest",
"overview.strip.title": "Environments",
"overview.switchSourceApp": "Switch source app",
"overview.switchSourceAppDescription": "Choose the Studio app that future releases should use as their DSL source.",
"overview.switchSourceAppHint": "After switching, only newly created releases use the new source app. Historical releases and existing deployments are not changed.",
"overview.targetRelease": "Target release",
"overview.webapp": "WebApp",
"settings.danger": "Danger zone",
"settings.dangerDesc": "Deleting this app instance removes deployment metadata after all environments are undeployed.",
"settings.delete": "Delete instance",
"settings.deleteConfirmDesc": "Delete {{name}}? This cannot be undone.",
"settings.deleteConfirmTitle": "Delete app instance",
"settings.deleteFailed": "Failed to delete instance.",
"settings.deleted": "Instance deleted",
"settings.description": "Description",
"settings.descriptionHelp": "Update metadata for this app instance.",
"settings.general": "General",
"settings.name": "Instance name",
"settings.reset": "Reset",
"settings.safeToDelete": "No active deployments. Safe to delete.",
"settings.save": "Save changes",
"settings.undeployFirst": "Undeploy from all environments before deleting.",
"settings.updateFailed": "Failed to update instance.",
"settings.updated": "Instance updated",
"status.deployFailed": "Deploy failed",
"status.deploying": "Deploying",
"status.notDeployed": "Not deployed",
"status.ready": "Ready",
"status.unknown": "Unknown",
"subtitle": "Deploy and manage your apps across environments.",
"tabs.deploy.description": "Environments this instance is deployed to and their current releases.",
"tabs.deploy.name": "Deploy",
"tabs.overview.description": "Deploy releases and review target environments.",
"tabs.overview.name": "Overview",
"tabs.releases.description": "All releases for this app. Deploy any release to an environment.",
"tabs.releases.name": "Releases",
"tabs.settings.description": "Access, API keys, metadata, and backend-managed settings.",
"tabs.settings.name": "Settings",
"title": "App instances",
"versions.cancelCreate": "Cancel",
"versions.col.action": "Action",
"versions.col.author": "Author",
"versions.col.commit": "Commit",
"versions.col.createdAt": "Created at",
"versions.col.deployedTo": "Deployed to",
"versions.col.release": "Release",
"versions.commitTooltip": "Commit {{commit}}",
"versions.create": "Create",
"versions.createFailed": "Failed to create release.",
"versions.createRelease": "Create release",
"versions.createReleaseDescription": "Capture the current workflow as a deployable release.",
"versions.createReleaseHint": "New releases can be deployed to any environment.",
"versions.createSuccess": "Release \"{{name}}\" created.",
"versions.creating": "Creating...",
"versions.currentOn": "Current on {{name}}",
"versions.deploy": "Deploy",
"versions.deployTo": "Deploy to {{name}}",
"versions.deployedStatus.active": "Running",
"versions.deployedStatus.deploying": "Deploying",
"versions.deployedStatus.failed": "Failed",
"versions.deployingTo": "{{name}} is deploying",
"versions.disabledReason.current": "Already running on {{name}}",
"versions.disabledReason.deploying": "Wait for the active deployment to finish",
"versions.disabledReason.envDisabled": "This environment isn't deployable",
"versions.empty": "No releases available yet.",
"versions.emptySourceUnavailable": "No releases yet. The source app was deleted, so new releases cannot be created.",
"versions.emptyWithCreate": "No releases yet. Create the first release before deploying.",
"versions.groupHeader.deploy": "Deploy",
"versions.groupHeader.promote": "Promote",
"versions.groupHeader.rollback": "Rollback",
"versions.groupHeader.unavailable": "Unavailable",
"versions.moreActions": "More actions",
"versions.optional": "Optional",
"versions.promote": "Promote",
"versions.promoteTo": "Promote to {{name}}",
"versions.releaseDescriptionLabel": "Description",
"versions.releaseDescriptionPlaceholder": "Describe this release",
"versions.releaseHistory": "Release history",
"versions.releaseNameLabel": "Release name",
"versions.releaseNamePlaceholder": "Release name",
"versions.rollbackTo": "Rollback to {{name}}",
"versions.sourceAppUnavailable": "The source app was deleted. Existing releases are still deployable, but new releases cannot be created."
}

View File

@ -277,7 +277,6 @@
"menus.apps": "工作室",
"menus.datasets": "知识库",
"menus.datasetsTips": "即将到来:上传自己的长文本数据,或通过 Webhook 集成自己的数据源",
"menus.deployments": "部署",
"menus.explore": "探索",
"menus.exploreMarketplace": "探索 Marketplace",
"menus.newApp": "创建应用",

View File

@ -1,384 +0,0 @@
{
"access.api.backendTitle": "后端服务 API",
"access.api.description": "通过 HTTP 调用该实例。每个 API 密钥仅在一个环境中生效。",
"access.api.developerTitle": "开发者 API",
"access.api.disabled": "该实例的 API 接入已关闭。",
"access.api.dismissToken": "关闭密钥",
"access.api.empty": "请先部署到环境后再签发 API 密钥。",
"access.api.endpoint": "请求地址",
"access.api.envPrefix": "env{{env}}",
"access.api.keyList": "API Key 列表",
"access.api.newKey": "生成新 Key",
"access.api.newKeyForEnv": "为 {{env}} 生成",
"access.api.newTokenDescription": "该明文密钥仅本次显示,请在离开页面前复制保存。",
"access.api.newTokenLabel": "密钥",
"access.api.newTokenTitle": "API Key 已创建",
"access.api.noKeys": "尚无 API 密钥,创建一个即可调用 API。",
"access.api.title": "API",
"access.channels.description": "WebApp 与 CLI 入口遵循上方访问权限。",
"access.channels.disabled": "该实例的接入渠道已关闭。",
"access.channels.followPermission": "随权限开放",
"access.channels.title": "接入渠道",
"access.cli.description": "通过 CLI 访问此实例时使用统一域名。",
"access.cli.docs": "使用说明",
"access.cli.domain": "域名",
"access.cli.empty": "尚未配置 CLI 接入地址。",
"access.cli.install": "安装 CLI",
"access.cli.title": "CLI",
"access.copied": "已复制",
"access.copy": "复制",
"access.copyFailed": "复制失败",
"access.copyToast": "已复制到剪贴板",
"access.hide": "隐藏",
"access.members.clearAll": "全部清除",
"access.members.empty": "未找到匹配结果。",
"access.members.emptySelection": "至少选择一个分组或成员后才会保存该权限。",
"access.members.groupCount_one": "{{count}} 个分组",
"access.members.groupCount_other": "{{count}} 个分组",
"access.members.groups": "分组",
"access.members.individuals": "成员",
"access.members.memberCount_one": "{{count}} 位成员",
"access.members.memberCount_other": "{{count}} 位成员",
"access.members.pickPlaceholder": "选择分组或成员",
"access.members.searchPlaceholder": "搜索分组和成员",
"access.members.selectedLabel": "已选择",
"access.permission.anyone": "任何人",
"access.permission.anyoneDesc": "任何拥有链接的人,无需登录",
"access.permission.comingSoon": "即将支持",
"access.permission.external": "已认证的外部用户",
"access.permission.externalDesc": "通过 SSO / OIDC 完成认证的外部用户",
"access.permission.memberCount_one": "{{count}} 位成员",
"access.permission.memberCount_other": "{{count}} 位成员",
"access.permission.organization": "仅组织内成员",
"access.permission.organizationDesc": "工作区内所有内部成员",
"access.permission.specific": "特定成员",
"access.permission.specificDesc": "选择指定的分组或单个成员",
"access.permission.specificUnavailable": "特定成员暂未启用,需接入真实工作区成员与分组后再开放。",
"access.permission.updateFailed": "更新访问策略失败。",
"access.permissions.description": "配置该实例在每个已部署环境中的访问人员。",
"access.permissions.title": "访问权限",
"access.revoke": "撤销",
"access.runAccess.description": "管理用户如何运行该应用,以及在每个环境里谁可以访问。",
"access.runAccess.disabled": "该实例的运行时接入已关闭。",
"access.runAccess.mcp": "MCP",
"access.runAccess.mcpDesc": "将此实例作为 Model Context Protocol 服务器对外提供。",
"access.runAccess.mcpEmpty": "尚未配置 MCP 端点。",
"access.runAccess.noEnvs": "请先部署到环境后再配置访问权限。",
"access.runAccess.openWebapp": "打开 WebApp",
"access.runAccess.permissions": "访问权限",
"access.runAccess.permissionsDesc": "每个环境里可以访问该实例的人员。",
"access.runAccess.title": "访问控制Run",
"access.runAccess.urlLabel": "访问地址",
"access.runAccess.webapp": "WebApp",
"access.runAccess.webappDesc": "面向终端用户的托管 Web 页面。",
"access.runAccess.webappEmpty": "尚未配置 WebApp 访问地址。",
"access.show": "显示",
"appMode.advanced-chat": "Chatflow",
"appMode.agent-chat": "Agent",
"appMode.chat": "聊天助手",
"appMode.completion": "文本生成",
"appMode.workflow": "Workflow",
"card.deploying": "{{count}} 个部署中",
"card.failed": "{{count}} 个失败",
"card.fromApp": "来自 {{name}}",
"card.lastDeployed": "上次部署于 {{time}}",
"card.menu.delete": "删除实例",
"card.menu.deleteDisabled": "后端托管的部署暂不支持删除实例。",
"card.menu.deploy": "部署到环境",
"card.menu.viewDetail": "查看实例详情",
"card.moreActions": "更多操作",
"card.neverDeployed": "尚未部署",
"card.notDeployed": "未部署",
"card.ready": "{{count}} 个就绪",
"card.tooltip.notDeployed": "该实例尚未部署到任何环境。",
"common.loadFailed": "加载失败,请稍后再试。",
"common.loading": "加载中...",
"createModal.appPickerPlaceholder": "选择源应用",
"createModal.appSearchEmpty": "没有匹配的应用",
"createModal.appSearchPlaceholder": "搜索应用…",
"createModal.cancel": "取消",
"createModal.create": "创建",
"createModal.createFailed": "创建应用实例失败。",
"createModal.description": "从 Studio 选择一个源应用并创建可部署的实例。",
"createModal.descriptionLabel": "描述",
"createModal.descriptionPlaceholder": "描述该实例的用途",
"createModal.loadMoreApps": "加载更多应用",
"createModal.loadingApps": "正在加载应用…",
"createModal.nameLabel": "实例名称",
"createModal.namePlaceholder": "实例名称",
"createModal.noApps": "当前工作区还没有应用,请先在 Studio 创建一个。",
"createModal.selected": "已选择",
"createModal.sourceApp": "源应用(必选)",
"createModal.title": "创建应用实例",
"deployDrawer.bindingOptionsFailed": "加载凭据选项失败。",
"deployDrawer.bindingSelectionHint": "选择本次部署要使用的运行时凭据。",
"deployDrawer.bindingsDisabled": "来自发布版本预览的解析结果,暂不支持在这里编辑。",
"deployDrawer.cancel": "取消",
"deployDrawer.defaultSelect": "选择...",
"deployDrawer.deploy": "部署",
"deployDrawer.deployExistingRelease": "部署已有发布版本",
"deployDrawer.deployExistingReleaseDescription": "从发布历史中选择一个已有发布版本,部署到目标环境。",
"deployDrawer.deployExistingReleaseTitle": "部署已有发布版本",
"deployDrawer.deployFailed": "启动部署失败。",
"deployDrawer.deploying": "部署中...",
"deployDrawer.description": "选择一个发布版本,并部署到目标环境。",
"deployDrawer.envVars": "环境变量",
"deployDrawer.existingReleaseHint": "将直接部署该已有发布版本,不会创建新的发布版本。",
"deployDrawer.loadingBindings": "解析中...",
"deployDrawer.lockedHint": "已锁定至当前环境",
"deployDrawer.missingRequiredBinding": "请选择该必填绑定使用的凭据。",
"deployDrawer.modelCreds": "模型凭据",
"deployDrawer.needsValidation": "(待验证)",
"deployDrawer.newReleaseHint": "将基于当前应用 YAML 创建一个新的发布版本。",
"deployDrawer.noBindingRequired": "无需配置",
"deployDrawer.noCredentialCandidates": "没有可用凭据。",
"deployDrawer.noReleaseAvailable": "请先创建发布版本,再部署该应用实例。",
"deployDrawer.notFound": "未找到实例。",
"deployDrawer.noteLabel": "发布备注(可选)",
"deployDrawer.notePlaceholder": "例如:优化引导文案",
"deployDrawer.pluginCreds": "插件凭据",
"deployDrawer.promote": "推送",
"deployDrawer.promoteDescription": "将更新的发布版本推送到目标环境。",
"deployDrawer.promoteTitle": "推送发布版本",
"deployDrawer.readOnly": "只读",
"deployDrawer.releaseLabel": "发布版本",
"deployDrawer.requiredBinding": "必填",
"deployDrawer.rollback": "回滚",
"deployDrawer.rollbackDescription": "将目标环境回滚到之前的发布版本。",
"deployDrawer.rollbackTitle": "回滚发布版本",
"deployDrawer.runtimeCredentials": "运行时凭据",
"deployDrawer.secretPlaceholder": "机密值",
"deployDrawer.selectCredential": "选择凭据",
"deployDrawer.selectEnv": "选择一个环境",
"deployDrawer.selectProviderCred": "选择 {{provider}} 凭据",
"deployDrawer.selectProviderKey": "选择 {{provider}} 密钥",
"deployDrawer.selectRelease": "选择一个发布版本",
"deployDrawer.targetEnv": "目标环境",
"deployDrawer.title": "部署到环境",
"deployDrawer.valuePlaceholder": "值",
"deployTab.cancelDeployment": "取消部署",
"deployTab.col.actions": "操作",
"deployTab.col.currentRelease": "当前发布版本",
"deployTab.col.environment": "环境",
"deployTab.col.status": "状态",
"deployTab.col.updated": "更新时间",
"deployTab.collapseDetails": "收起部署详情",
"deployTab.confirmUndeploy": "取消部署",
"deployTab.deployOtherVersion": "部署其他发布版本",
"deployTab.deployToEnv": "部署到 {{name}}",
"deployTab.deployToNewEnv": "部署到新环境...",
"deployTab.empty": "暂无部署。将此实例部署到环境以开始使用。",
"deployTab.envCount": "环境",
"deployTab.expandDetails": "展开部署详情",
"deployTab.moreActions": "更多操作",
"deployTab.newDeployment": "新建部署",
"deployTab.panel.commit": "Commit ID",
"deployTab.panel.deploymentId": "部署 ID",
"deployTab.panel.endpoints": "端点",
"deployTab.panel.envVars": "环境变量",
"deployTab.panel.error": "错误",
"deployTab.panel.failedRelease": "失败发布版本",
"deployTab.panel.health": "健康检查",
"deployTab.panel.instanceInfo": "实例信息",
"deployTab.panel.modelCreds": "模型凭据",
"deployTab.panel.pluginCreds": "插件凭据",
"deployTab.panel.release": "发布版本",
"deployTab.panel.releaseCreatedAt": "发布版本创建时间",
"deployTab.panel.releaseInfo": "发布版本信息",
"deployTab.panel.replicas": "副本数",
"deployTab.panel.run": "运行",
"deployTab.panel.runtimeBindings": "运行时绑定",
"deployTab.panel.runtimeInfo": "运行时",
"deployTab.panel.runtimeMode": "运行模式",
"deployTab.panel.runtimeNote": "运行时备注",
"deployTab.panel.targetRelease": "目标发布版本",
"deployTab.panel.unknownError": "部署失败。",
"deployTab.promote": "推送",
"deployTab.releaseCreatedAt": "发布版本创建于 {{time}}",
"deployTab.retry": "重试",
"deployTab.shortcut": "快捷",
"deployTab.status.deployFailed": "部署失败",
"deployTab.status.deployingRelease": "部署中({{release}}",
"deployTab.status.runningWithFailed": "运行中(上次部署失败)",
"deployTab.undeploy": "下线",
"deployTab.undeployConfirmDesc": "用户访问将立即停止。该发布版本之后仍可重新部署。",
"deployTab.undeployConfirmTitle": "从 {{name}} 取消部署?",
"deployTab.undeployFrom": "从 {{name}} 取消部署",
"deployTab.viewError": "查看错误",
"deployTab.viewLogs": "查看日志",
"deployTab.viewProgress": "查看进度",
"detail.backToInstances": "返回应用实例",
"detail.deployingCount": "{{count}} 个部署中",
"detail.envCount_one": "{{count}} 个环境",
"detail.envCount_other": "{{count}} 个环境",
"detail.failedCount": "{{count}} 个失败",
"detail.notFound": "未找到实例",
"detail.openSourceApp": "打开源应用 {{name}}",
"detail.sourceApp": "源应用",
"detail.sourceAppDeleted": "源应用已删除",
"detail.sourceAppLink": "源应用",
"documentTitle.detail": "实例 · 部署",
"documentTitle.list": "部署",
"filter.allEnvs": "全部环境",
"filter.notDeployed": "未部署",
"filter.searchPlaceholder": "搜索实例",
"health.degraded": "降级",
"health.ready": "就绪",
"list.empty": "未找到应用实例。",
"mode.isolated": "独立",
"mode.shared": "共享",
"newInstance.comingSoon": "即将支持",
"newInstance.fromStudio": "从 Studio 选择",
"newInstance.importDSL": "导入 DSL",
"newInstance.title": "创建应用实例",
"overview.accessEndpoints": "接入端点",
"overview.accessStatus": "接入状态",
"overview.api": "API",
"overview.apiKeysCount_one": "{{count}} 个 API Key",
"overview.apiKeysCount_other": "{{count}} 个 API Key",
"overview.appMode": "应用类型",
"overview.availableForDeployment": "可部署",
"overview.basicInfo": "基本信息",
"overview.chip.behind_one": "↑ 1",
"overview.chip.behind_other": "↑ {{count}}",
"overview.chip.deploying": "部署中",
"overview.chip.empty": "空",
"overview.chip.failed": "失败",
"overview.chip.latest": "最新",
"overview.chip.needsReleaseFirst": "请先创建发布版本",
"overview.chip.olderRelease": "较旧",
"overview.chip.openInDeployTab": "查看部署进度",
"overview.cli": "CLI",
"overview.configureAccess": "配置接入",
"overview.configured": "已配置",
"overview.createRelease": "创建发布版本",
"overview.created": "创建时间",
"overview.deploy": "部署",
"overview.deployedEnvironments": "已部署",
"overview.deploymentOverview": "部署概览",
"overview.deploymentStatus": "部署状态",
"overview.description": "描述",
"overview.developerApi": "开发者 API",
"overview.disabled": "未启用",
"overview.emptyValue": "未设置",
"overview.enabled": "已启用",
"overview.enabledChannels": "接入启用",
"overview.endUserAccess": "终端用户接入",
"overview.environments": "环境",
"overview.hero.byName": "由 {{name}} 创建",
"overview.hero.empty": "尚无发布版本",
"overview.hero.emptyDescription": "把当前应用保存为发布版本后即可部署。",
"overview.hero.propagation_one": "已部署到 {{total}} 个环境中的 {{count}} 个",
"overview.hero.propagation_other": "已部署到 {{total}} 个环境中的 {{count}} 个",
"overview.hero.untargeted": "尚未配置环境",
"overview.instanceDetails": "实例详情",
"overview.instanceId": "实例 ID",
"overview.manageDeployments": "管理部署",
"overview.name": "名称",
"overview.noAccessConfig": "未配置接入方式。",
"overview.noReleaseSourceUnavailable": "源应用已删除。已有发布版本仍可部署,但当前还没有可部署发布版本。",
"overview.noReleaseYet": "请先创建发布版本,再部署该应用实例。",
"overview.notConfigured": "未配置",
"overview.previousReleases.empty": "暂无更早的发布版本。",
"overview.previousReleases.retired": "当前未部署",
"overview.previousReleases.title": "历史发布版本",
"overview.previousReleases.viewAll": "查看全部",
"overview.ready": "可部署",
"overview.recentReleases": "近期发布版本",
"overview.releaseDeployedTitle": "{{release}} 已部署",
"overview.releaseReadyTitle": "{{release}} 可以部署",
"overview.serviceMap": "服务流",
"overview.servingRelease": "正在提供 {{release}}",
"overview.servingReleaseDescription": "该应用实例已部署到 {{count}}/{{total}} 个环境。",
"overview.sourceAppDeletedDescription": "历史发布版本仍可继续部署,但无法再基于已删除的源应用生成新发布版本。请切换到其他源应用后继续使用。",
"overview.sourceAppDeletedTitle": "源应用已被删除",
"overview.sourceAppDeletedValue": "已删除的源应用",
"overview.sourceAppUnavailable": "不可用",
"overview.strip.empty": "尚未配置任何环境。",
"overview.strip.failedAlert_one": "1 个环境部署失败。",
"overview.strip.failedAlert_other": "{{count}} 个环境部署失败。",
"overview.strip.investigate": "查看详情",
"overview.strip.summary_one": "{{total}} 个环境中 1 个已部署最新",
"overview.strip.summary_other": "{{total}} 个环境中 {{count}} 个已部署最新",
"overview.strip.title": "环境",
"overview.switchSourceApp": "切换源应用",
"overview.switchSourceAppDescription": "选择后续新建发布版本使用的 Studio 应用 DSL 来源。",
"overview.switchSourceAppHint": "切换后,仅新建发布版本会使用新的源应用;历史发布版本和现有部署不受影响。",
"overview.targetRelease": "目标发布版本",
"overview.webapp": "WebApp",
"settings.danger": "危险区域",
"settings.dangerDesc": "删除应用实例会在所有环境取消部署后移除部署元数据。",
"settings.delete": "删除实例",
"settings.deleteConfirmDesc": "确定删除 {{name}}?此操作无法撤销。",
"settings.deleteConfirmTitle": "删除应用实例",
"settings.deleteFailed": "删除实例失败。",
"settings.deleted": "实例已删除",
"settings.description": "描述",
"settings.descriptionHelp": "更新该应用实例的元数据。",
"settings.general": "常规",
"settings.name": "实例名称",
"settings.reset": "重置",
"settings.safeToDelete": "无活动部署,可安全删除。",
"settings.save": "保存修改",
"settings.undeployFirst": "请先在所有环境取消部署后再删除。",
"settings.updateFailed": "更新实例失败。",
"settings.updated": "实例已更新",
"status.deployFailed": "部署失败",
"status.deploying": "部署中",
"status.notDeployed": "未部署",
"status.ready": "就绪",
"status.unknown": "未知",
"subtitle": "在不同环境中部署和管理你的应用。",
"tabs.deploy.description": "该实例已部署的环境及其当前发布版本。",
"tabs.deploy.name": "部署",
"tabs.overview.description": "部署发布版本并查看目标环境。",
"tabs.overview.name": "概览",
"tabs.releases.description": "此应用的所有发布版本,可将任一发布版本部署到环境。",
"tabs.releases.name": "发布版本",
"tabs.settings.description": "管理接入、API Key、元数据和后端托管设置。",
"tabs.settings.name": "设置",
"title": "应用实例",
"versions.cancelCreate": "取消",
"versions.col.action": "操作",
"versions.col.author": "作者",
"versions.col.commit": "提交",
"versions.col.createdAt": "创建时间",
"versions.col.deployedTo": "已部署到",
"versions.col.release": "发布版本",
"versions.commitTooltip": "Commit {{commit}}",
"versions.create": "创建",
"versions.createFailed": "创建发布版本失败。",
"versions.createRelease": "创建发布版本",
"versions.createReleaseDescription": "将当前工作流保存为可部署发布版本。",
"versions.createReleaseHint": "新发布版本可部署到任意环境。",
"versions.createSuccess": "发布版本 \"{{name}}\" 已创建。",
"versions.creating": "创建中...",
"versions.currentOn": "{{name}} 当前发布版本",
"versions.deploy": "部署",
"versions.deployTo": "部署到 {{name}}",
"versions.deployedStatus.active": "运行中",
"versions.deployedStatus.deploying": "部署中",
"versions.deployedStatus.failed": "失败",
"versions.deployingTo": "{{name}} 正在部署",
"versions.disabledReason.current": "{{name}} 已运行此版本",
"versions.disabledReason.deploying": "请等待当前部署完成",
"versions.disabledReason.envDisabled": "此环境不可部署",
"versions.empty": "暂无可用发布版本。",
"versions.emptySourceUnavailable": "暂无发布版本。源应用已删除,无法创建新发布版本。",
"versions.emptyWithCreate": "暂无发布版本,请先创建第一个可部署发布版本。",
"versions.groupHeader.deploy": "部署",
"versions.groupHeader.promote": "推送",
"versions.groupHeader.rollback": "回滚",
"versions.groupHeader.unavailable": "不可用",
"versions.moreActions": "更多操作",
"versions.optional": "可选",
"versions.promote": "推送",
"versions.promoteTo": "推送到 {{name}}",
"versions.releaseDescriptionLabel": "描述",
"versions.releaseDescriptionPlaceholder": "描述这个发布版本",
"versions.releaseHistory": "发布历史",
"versions.releaseNameLabel": "发布版本名称",
"versions.releaseNamePlaceholder": "发布版本名称",
"versions.rollbackTo": "回滚到 {{name}}",
"versions.sourceAppUnavailable": "源应用已删除。已有发布版本仍可部署,但无法创建新发布版本。"
}

Some files were not shown because too many files have changed in this diff Show More