Compare commits

..

5 Commits

126 changed files with 266 additions and 15767 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.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance.
## 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

1
.github/CODEOWNERS vendored
View File

@ -166,6 +166,7 @@
# Frontend - App - API Documentation
/web/app/components/develop/ @JzoNgKVO @iamjoel
/web/app/components/develop/template/*.mdx @JzoNgKVO @iamjoel @RiskeyL
# Frontend - App - Logs and Annotations
/web/app/components/app/workflow-log/ @JzoNgKVO @iamjoel

View File

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

View File

@ -1,4 +1,4 @@
name: Deploy Agent Dev
name: Deploy SaaS
permissions:
contents: read
@ -7,7 +7,7 @@ on:
workflow_run:
workflows: ["Build and Push API & Web"]
branches:
- "deploy/agent-dev"
- "deploy/saas"
types:
- completed
@ -16,7 +16,7 @@ jobs:
runs-on: depot-ubuntu-24.04
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/agent-dev'
github.event.workflow_run.head_branch == 'deploy/saas'
steps:
- name: Deploy to server
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5

View File

@ -11,7 +11,7 @@ from controllers.console.app.error import (
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.console.wraps import account_initialization_required, setup_required
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from core.app.app_config.entities import ModelConfig
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from core.helper.code_executor.code_node_provider import CodeNodeProvider
@ -22,7 +22,7 @@ from core.llm_generator.llm_generator import LLMGenerator
from extensions.ext_database import db
from graphon.model_runtime.entities.llm_entities import LLMMode
from graphon.model_runtime.errors.invoke import InvokeError
from libs.login import current_account_with_tenant, login_required
from libs.login import login_required
from models import App
from services.workflow_service import WorkflowService
@ -64,9 +64,9 @@ class RuleGenerateApi(Resource):
@setup_required
@login_required
@account_initialization_required
def post(self):
@with_current_tenant_id
def post(self, current_tenant_id: str):
args = RuleGeneratePayload.model_validate(console_ns.payload)
_, current_tenant_id = current_account_with_tenant()
try:
rules = LLMGenerator.generate_rule_config(tenant_id=current_tenant_id, args=args)
@ -93,9 +93,9 @@ class RuleCodeGenerateApi(Resource):
@setup_required
@login_required
@account_initialization_required
def post(self):
@with_current_tenant_id
def post(self, current_tenant_id: str):
args = RuleCodeGeneratePayload.model_validate(console_ns.payload)
_, current_tenant_id = current_account_with_tenant()
try:
code_result = LLMGenerator.generate_code(
@ -125,9 +125,9 @@ class RuleStructuredOutputGenerateApi(Resource):
@setup_required
@login_required
@account_initialization_required
def post(self):
@with_current_tenant_id
def post(self, current_tenant_id: str):
args = RuleStructuredOutputPayload.model_validate(console_ns.payload)
_, current_tenant_id = current_account_with_tenant()
try:
structured_output = LLMGenerator.generate_structured_output(
@ -157,9 +157,9 @@ class InstructionGenerateApi(Resource):
@setup_required
@login_required
@account_initialization_required
def post(self):
@with_current_tenant_id
def post(self, current_tenant_id: str):
args = InstructionGeneratePayload.model_validate(console_ns.payload)
_, current_tenant_id = current_account_with_tenant()
providers: list[type[CodeNodeProvider]] = [Python3CodeProvider, JavascriptCodeProvider]
code_provider: type[CodeNodeProvider] | None = next(
(p for p in providers if p.is_accept_language(args.language)), None

View File

@ -11,11 +11,16 @@ from werkzeug.exceptions import NotFound
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_tenant_id,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import to_timestamp
from libs.login import current_account_with_tenant, login_required
from libs.login import login_required
from models.enums import AppMCPServerStatus
from models.model import App, AppMCPServer
@ -92,8 +97,8 @@ class AppMCPServerController(Resource):
@login_required
@setup_required
@edit_permission_required
def post(self, app_model: App):
_, current_tenant_id = current_account_with_tenant()
@with_current_tenant_id
def post(self, current_tenant_id: str, app_model: App):
payload = MCPServerCreatePayload.model_validate(console_ns.payload or {})
description = payload.description
@ -163,8 +168,8 @@ class AppMCPServerRefreshController(Resource):
@login_required
@account_initialization_required
@edit_permission_required
def get(self, server_id: UUID):
_, current_tenant_id = current_account_with_tenant()
@with_current_tenant_id
def get(self, current_tenant_id: str, server_id: UUID):
server = db.session.scalar(
select(AppMCPServer)
.where(AppMCPServer.id == server_id, AppMCPServer.tenant_id == current_tenant_id)

View File

@ -8,12 +8,17 @@ from pydantic import BaseModel, Field, field_validator
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, is_admin_or_owner_required, setup_required
from controllers.console.wraps import (
account_initialization_required,
is_admin_or_owner_required,
setup_required,
with_current_tenant_id,
)
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.validate import CredentialsValidateFailedError
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.helper import uuid_value
from libs.login import current_account_with_tenant, login_required
from libs.login import login_required
from services.model_load_balancing_service import ModelLoadBalancingService
from services.model_provider_service import ModelProviderService
@ -138,9 +143,8 @@ class DefaultModelApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self):
_, tenant_id = current_account_with_tenant()
@with_current_tenant_id
def get(self, tenant_id: str):
args = ParserGetDefault.model_validate(request.args.to_dict(flat=True))
model_provider_service = ModelProviderService()
@ -156,9 +160,8 @@ class DefaultModelApi(Resource):
@login_required
@is_admin_or_owner_required
@account_initialization_required
def post(self):
_, tenant_id = current_account_with_tenant()
@with_current_tenant_id
def post(self, tenant_id: str):
args = ParserPostDefault.model_validate(console_ns.payload)
model_provider_service = ModelProviderService()
model_settings = args.model_settings
@ -189,9 +192,8 @@ class ModelProviderModelApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, provider):
_, tenant_id = current_account_with_tenant()
@with_current_tenant_id
def get(self, tenant_id: str, provider):
model_provider_service = ModelProviderService()
models = model_provider_service.get_models_by_provider(tenant_id=tenant_id, provider=provider)
@ -202,9 +204,9 @@ class ModelProviderModelApi(Resource):
@login_required
@is_admin_or_owner_required
@account_initialization_required
def post(self, provider: str):
@with_current_tenant_id
def post(self, tenant_id: str, provider: str):
# To save the model's load balance configs
_, tenant_id = current_account_with_tenant()
args = ParserPostModels.model_validate(console_ns.payload)
if args.config_from == "custom-model":
@ -249,9 +251,8 @@ class ModelProviderModelApi(Resource):
@login_required
@is_admin_or_owner_required
@account_initialization_required
def delete(self, provider: str):
_, tenant_id = current_account_with_tenant()
@with_current_tenant_id
def delete(self, tenant_id: str, provider: str):
args = ParserDeleteModels.model_validate(console_ns.payload)
model_provider_service = ModelProviderService()
@ -268,9 +269,8 @@ class ModelProviderModelCredentialApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, provider: str):
_, tenant_id = current_account_with_tenant()
@with_current_tenant_id
def get(self, tenant_id: str, provider: str):
args = ParserGetCredentials.model_validate(request.args.to_dict(flat=True))
model_provider_service = ModelProviderService()
@ -323,9 +323,8 @@ class ModelProviderModelCredentialApi(Resource):
@login_required
@is_admin_or_owner_required
@account_initialization_required
def post(self, provider: str):
_, tenant_id = current_account_with_tenant()
@with_current_tenant_id
def post(self, tenant_id: str, provider: str):
args = ParserCreateCredential.model_validate(console_ns.payload)
model_provider_service = ModelProviderService()
@ -355,8 +354,8 @@ class ModelProviderModelCredentialApi(Resource):
@login_required
@is_admin_or_owner_required
@account_initialization_required
def put(self, provider: str):
_, current_tenant_id = current_account_with_tenant()
@with_current_tenant_id
def put(self, current_tenant_id: str, provider: str):
args = ParserUpdateCredential.model_validate(console_ns.payload)
model_provider_service = ModelProviderService()
@ -382,8 +381,8 @@ class ModelProviderModelCredentialApi(Resource):
@login_required
@is_admin_or_owner_required
@account_initialization_required
def delete(self, provider: str):
_, current_tenant_id = current_account_with_tenant()
@with_current_tenant_id
def delete(self, current_tenant_id: str, provider: str):
args = ParserDeleteCredential.model_validate(console_ns.payload)
model_provider_service = ModelProviderService()
@ -406,8 +405,8 @@ class ModelProviderModelCredentialSwitchApi(Resource):
@login_required
@is_admin_or_owner_required
@account_initialization_required
def post(self, provider: str):
_, current_tenant_id = current_account_with_tenant()
@with_current_tenant_id
def post(self, current_tenant_id: str, provider: str):
args = ParserSwitch.model_validate(console_ns.payload)
service = ModelProviderService()
@ -430,9 +429,8 @@ class ModelProviderModelEnableApi(Resource):
@setup_required
@login_required
@account_initialization_required
def patch(self, provider: str):
_, tenant_id = current_account_with_tenant()
@with_current_tenant_id
def patch(self, tenant_id: str, provider: str):
args = ParserDeleteModels.model_validate(console_ns.payload)
model_provider_service = ModelProviderService()
@ -452,9 +450,8 @@ class ModelProviderModelDisableApi(Resource):
@setup_required
@login_required
@account_initialization_required
def patch(self, provider: str):
_, tenant_id = current_account_with_tenant()
@with_current_tenant_id
def patch(self, tenant_id: str, provider: str):
args = ParserDeleteModels.model_validate(console_ns.payload)
model_provider_service = ModelProviderService()
@ -480,8 +477,8 @@ class ModelProviderModelValidateApi(Resource):
@setup_required
@login_required
@account_initialization_required
def post(self, provider: str):
_, tenant_id = current_account_with_tenant()
@with_current_tenant_id
def post(self, tenant_id: str, provider: str):
args = ParserValidate.model_validate(console_ns.payload)
model_provider_service = ModelProviderService()
@ -515,9 +512,9 @@ class ModelProviderModelParameterRuleApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, provider: str):
@with_current_tenant_id
def get(self, tenant_id: str, provider: str):
args = ParserParameter.model_validate(request.args.to_dict(flat=True))
_, tenant_id = current_account_with_tenant()
model_provider_service = ModelProviderService()
parameter_rules = model_provider_service.get_model_parameter_rules(
@ -532,8 +529,8 @@ class ModelProviderAvailableModelApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, model_type: str):
_, tenant_id = current_account_with_tenant()
@with_current_tenant_id
def get(self, tenant_id: str, model_type: str):
model_provider_service = ModelProviderService()
models = model_provider_service.get_models_by_model_type(tenant_id=tenant_id, model_type=model_type)

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,200 +0,0 @@
"""Inner API endpoints for runtime credential resolution.
Called by Enterprise while resolving AppRunner runtime artifacts. The endpoint
returns decrypted model and tool 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.helper.provider_cache import ToolProviderCredentialsCache
from core.helper.provider_encryption import create_provider_encrypter
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
from core.tools.tool_manager import ToolManager
from extensions.ext_database import db
from models.provider import ProviderCredential
from models.tools import BuiltinToolProvider
logger = logging.getLogger(__name__)
_KIND_MODEL = "model"
_KIND_TOOL = "tool"
# (body, status) pair returned by a resolver helper when resolution fails.
ResolveError = tuple[dict[str, str], int]
class InnerRuntimeCredentialResolveItem(BaseModel):
credential_id: str = Field(description="Credential id")
provider: str = Field(description="Runtime provider identifier, for example langgenius/openai/openai")
kind: str = Field(description="Credential kind, either 'model' or 'tool'")
class InnerRuntimeCredentialsResolvePayload(BaseModel):
tenant_id: str = Field(description="Workspace id")
credentials: list[InnerRuntimeCredentialResolveItem] = Field(default_factory=list)
register_schema_model(inner_api_ns, InnerRuntimeCredentialsResolvePayload)
@inner_api_ns.route("/enterprise/runtime/credentials/resolve")
class EnterpriseRuntimeCredentialsResolve(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_ns.doc(
"enterprise_runtime_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[InnerRuntimeCredentialsResolvePayload.__name__])
def post(self):
args = InnerRuntimeCredentialsResolvePayload.model_validate(inner_api_ns.payload or {})
if not args.credentials:
return {"credentials": []}, 200
# Model resolution shares one provider configuration set; build it lazily
# so a tool-only request never pays for the plugin daemon round trip.
model_configurations = None
resolved: list[dict[str, Any]] = []
for item in args.credentials:
if item.kind == _KIND_MODEL:
if model_configurations is None:
provider_manager = create_plugin_provider_manager(tenant_id=args.tenant_id)
model_configurations = provider_manager.get_configurations(args.tenant_id)
values, error = _resolve_model(args.tenant_id, model_configurations, item)
elif item.kind == _KIND_TOOL:
values, error = _resolve_tool(args.tenant_id, item)
else:
return {"message": f"unsupported credential kind '{item.kind}'"}, 400
if error is not None:
return error
resolved.append(
{
"credential_id": item.credential_id,
"kind": item.kind,
"provider": item.provider,
"values": values,
}
)
return {"credentials": resolved}, 200
def _resolve_model(
tenant_id: str, provider_configurations: Any, item: InnerRuntimeCredentialResolveItem
) -> tuple[dict[str, Any] | None, ResolveError | None]:
provider_configuration = provider_configurations.get(item.provider)
if provider_configuration is None:
return None, ({"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 == 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 None, ({"message": f"credential '{item.credential_id}' not found"}, 404)
try:
values = json.loads(credential.encrypted_config)
except JSONDecodeError:
return None, ({"message": f"credential '{item.credential_id}' has invalid config"}, 400)
if not isinstance(values, dict):
return None, ({"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=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": tenant_id,
"error": type(exc).__name__,
},
)
return None, ({"message": f"credential '{item.credential_id}' decrypt failed"}, 400)
return values, None
def _resolve_tool(
tenant_id: str, item: InnerRuntimeCredentialResolveItem
) -> tuple[dict[str, Any] | None, ResolveError | None]:
try:
provider_controller = ToolManager.get_builtin_provider(item.provider, tenant_id)
except Exception as exc:
logger.warning(
"failed to load runtime tool provider",
extra={"provider": item.provider, "tenant_id": tenant_id, "error": type(exc).__name__},
)
return None, ({"message": f"tool provider '{item.provider}' not found"}, 404)
with Session(db.engine) as session:
stmt = select(BuiltinToolProvider).where(
BuiltinToolProvider.id == item.credential_id,
BuiltinToolProvider.tenant_id == tenant_id,
)
builtin_provider = session.execute(stmt).scalar_one_or_none()
if builtin_provider is None:
return None, ({"message": f"credential '{item.credential_id}' not found"}, 404)
try:
# Tool credentials are stored as a single encrypted dict; the secret
# fields are decided by the schema bound to this credential type.
provider_encrypter, _ = create_provider_encrypter(
tenant_id=tenant_id,
config=[
schema.to_basic_provider_config()
for schema in provider_controller.get_credentials_schema_by_type(builtin_provider.credential_type)
],
cache=ToolProviderCredentialsCache(
tenant_id=tenant_id, provider=item.provider, credential_id=builtin_provider.id
),
)
values = dict(provider_encrypter.decrypt(builtin_provider.credentials))
except Exception as exc:
logger.warning(
"failed to resolve runtime tool credential",
extra={
"credential_id": item.credential_id,
"provider": item.provider,
"tenant_id": tenant_id,
"error": type(exc).__name__,
},
)
return None, ({"message": f"credential '{item.credential_id}' decrypt failed"}, 400)
return values, None

View File

@ -14836,7 +14836,6 @@ Default configuration for form inputs.
| ---- | ---- | ----------- | -------- |
| app_dsl_version | string | | Yes |
| branding | [BrandingModel](#brandingmodel) | | Yes |
| enable_app_deploy | boolean | | Yes |
| enable_change_email | boolean | | Yes |
| enable_collaboration_mode | boolean | | Yes |
| enable_creators_platform | boolean | | Yes |

View File

@ -1325,7 +1325,6 @@ Returns Server-Sent Events stream.
| ---- | ---- | ----------- | -------- |
| app_dsl_version | string | | Yes |
| branding | [BrandingModel](#brandingmodel) | | Yes |
| enable_app_deploy | boolean | | Yes |
| enable_change_email | boolean | | Yes |
| enable_collaboration_mode | boolean | | Yes |
| enable_creators_platform | boolean | | Yes |

View File

@ -161,7 +161,6 @@ class PluginManagerModel(FeatureResponseModel):
class SystemFeatureModel(FeatureResponseModel):
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
@ -254,7 +253,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

@ -34,7 +34,6 @@ def test_rule_generate_success(app, monkeypatch: pytest.MonkeyPatch) -> None:
api = generator_module.RuleGenerateApi()
method = _unwrap(api.post)
monkeypatch.setattr(generator_module, "current_account_with_tenant", lambda: (None, "t1"))
monkeypatch.setattr(generator_module.LLMGenerator, "generate_rule_config", lambda **_kwargs: {"rules": []})
with app.test_request_context(
@ -42,7 +41,7 @@ def test_rule_generate_success(app, monkeypatch: pytest.MonkeyPatch) -> None:
method="POST",
json={"instruction": "do it", "model_config": _model_config_payload()},
):
response = method()
response = method("t1")
assert response == {"rules": []}
@ -51,8 +50,6 @@ def test_rule_code_generate_maps_token_error(app, monkeypatch: pytest.MonkeyPatc
api = generator_module.RuleCodeGenerateApi()
method = _unwrap(api.post)
monkeypatch.setattr(generator_module, "current_account_with_tenant", lambda: (None, "t1"))
def _raise(*_args, **_kwargs):
raise ProviderTokenNotInitError("missing token")
@ -64,15 +61,13 @@ def test_rule_code_generate_maps_token_error(app, monkeypatch: pytest.MonkeyPatc
json={"instruction": "do it", "model_config": _model_config_payload()},
):
with pytest.raises(ProviderNotInitializeError):
method()
method("t1")
def test_instruction_generate_app_not_found(app, monkeypatch: pytest.MonkeyPatch) -> None:
api = generator_module.InstructionGenerateApi()
method = _unwrap(api.post)
monkeypatch.setattr(generator_module, "current_account_with_tenant", lambda: (None, "t1"))
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: None))
with app.test_request_context(
@ -85,7 +80,7 @@ def test_instruction_generate_app_not_found(app, monkeypatch: pytest.MonkeyPatch
"model_config": _model_config_payload(),
},
):
response, status = method()
response, status = method("t1")
assert status == 400
assert response["error"] == "app app-1 not found"
@ -95,8 +90,6 @@ def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.Monkey
api = generator_module.InstructionGenerateApi()
method = _unwrap(api.post)
monkeypatch.setattr(generator_module, "current_account_with_tenant", lambda: (None, "t1"))
app_model = SimpleNamespace(id="app-1")
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
_install_workflow_service(monkeypatch, workflow=None)
@ -111,7 +104,7 @@ def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.Monkey
"model_config": _model_config_payload(),
},
):
response, status = method()
response, status = method("t1")
assert status == 400
assert response["error"] == "workflow app-1 not found"
@ -121,8 +114,6 @@ def test_instruction_generate_node_missing(app, monkeypatch: pytest.MonkeyPatch)
api = generator_module.InstructionGenerateApi()
method = _unwrap(api.post)
monkeypatch.setattr(generator_module, "current_account_with_tenant", lambda: (None, "t1"))
app_model = SimpleNamespace(id="app-1")
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
@ -139,7 +130,7 @@ def test_instruction_generate_node_missing(app, monkeypatch: pytest.MonkeyPatch)
"model_config": _model_config_payload(),
},
):
response, status = method()
response, status = method("t1")
assert status == 400
assert response["error"] == "node node-1 not found"
@ -149,8 +140,6 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
api = generator_module.InstructionGenerateApi()
method = _unwrap(api.post)
monkeypatch.setattr(generator_module, "current_account_with_tenant", lambda: (None, "t1"))
app_model = SimpleNamespace(id="app-1")
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
@ -174,7 +163,7 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
"model_config": _model_config_payload(),
},
):
response = method()
response = method("t1")
assert response == {"code": "x"}
@ -183,7 +172,6 @@ def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch
api = generator_module.InstructionGenerateApi()
method = _unwrap(api.post)
monkeypatch.setattr(generator_module, "current_account_with_tenant", lambda: (None, "t1"))
monkeypatch.setattr(
generator_module.LLMGenerator,
"instruction_modify_legacy",
@ -201,7 +189,7 @@ def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch
"model_config": _model_config_payload(),
},
):
response = method()
response = method("t1")
assert response == {"instruction": "ok"}
@ -210,8 +198,6 @@ def test_instruction_generate_incompatible_params(app, monkeypatch: pytest.Monke
api = generator_module.InstructionGenerateApi()
method = _unwrap(api.post)
monkeypatch.setattr(generator_module, "current_account_with_tenant", lambda: (None, "t1"))
with app.test_request_context(
"/console/api/instruction-generate",
method="POST",
@ -223,7 +209,7 @@ def test_instruction_generate_incompatible_params(app, monkeypatch: pytest.Monke
"model_config": _model_config_payload(),
},
):
response, status = method()
response, status = method("t1")
assert status == 400
assert response["error"] == "incompatible parameters"

View File

@ -121,7 +121,6 @@ class TestAppMCPServerController:
with (
app.test_request_context("/", json=payload),
patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload),
patch("controllers.console.app.mcp_server.current_account_with_tenant", return_value=(None, "tenant-1")),
patch("controllers.console.app.mcp_server.db.session.add"),
patch("controllers.console.app.mcp_server.db.session.commit"),
patch("controllers.console.app.mcp_server.AppMCPServer.generate_server_code", return_value="server-code"),
@ -131,7 +130,7 @@ class TestAppMCPServerController:
),
):
response, status_code = method(
api, app_model=SimpleNamespace(id="app-1", name="Demo App", description="App description")
api, "tenant-1", app_model=SimpleNamespace(id="app-1", name="Demo App", description="App description")
)
assert response == {"id": "server-1"}

View File

@ -1,4 +1,4 @@
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
from flask import Flask
@ -34,15 +34,11 @@ class TestDefaultModelApi:
"/",
query_string={"model_type": ModelType.LLM},
),
patch(
"controllers.console.workspace.models.current_account_with_tenant",
return_value=(MagicMock(), "tenant1"),
),
patch("controllers.console.workspace.models.ModelProviderService") as service_mock,
):
service_mock.return_value.get_default_model_of_model_type.return_value = {"model": "gpt-4"}
result = method(api)
result = method(api, "tenant1")
assert "data" in result
@ -62,13 +58,9 @@ class TestDefaultModelApi:
with (
app.test_request_context("/", json=payload),
patch(
"controllers.console.workspace.models.current_account_with_tenant",
return_value=(MagicMock(), "tenant1"),
),
patch("controllers.console.workspace.models.ModelProviderService"),
):
result = method(api)
result = method(api, "tenant1")
assert result["result"] == "success"
@ -78,12 +70,11 @@ class TestDefaultModelApi:
with (
app.test_request_context("/", query_string={"model_type": ModelType.LLM}),
patch("controllers.console.workspace.models.current_account_with_tenant", return_value=(MagicMock(), "t1")),
patch("controllers.console.workspace.models.ModelProviderService") as service,
):
service.return_value.get_default_model_of_model_type.return_value = None
result = method(api)
result = method(api, "t1")
assert "data" in result
@ -95,15 +86,11 @@ class TestModelProviderModelApi:
with (
app.test_request_context("/"),
patch(
"controllers.console.workspace.models.current_account_with_tenant",
return_value=(MagicMock(), "tenant1"),
),
patch("controllers.console.workspace.models.ModelProviderService") as service_mock,
):
service_mock.return_value.get_models_by_provider.return_value = []
result = method(api, "openai")
result = method(api, "tenant1", "openai")
assert "data" in result
@ -122,14 +109,10 @@ class TestModelProviderModelApi:
with (
app.test_request_context("/", json=payload),
patch(
"controllers.console.workspace.models.current_account_with_tenant",
return_value=(MagicMock(), "tenant1"),
),
patch("controllers.console.workspace.models.ModelProviderService"),
patch("controllers.console.workspace.models.ModelLoadBalancingService"),
):
result, status = method(api, "openai")
result, status = method(api, "tenant1", "openai")
assert status == 200
@ -144,13 +127,9 @@ class TestModelProviderModelApi:
with (
app.test_request_context("/", json=payload),
patch(
"controllers.console.workspace.models.current_account_with_tenant",
return_value=(MagicMock(), "tenant1"),
),
patch("controllers.console.workspace.models.ModelProviderService"),
):
result, status = method(api, "openai")
result, status = method(api, "tenant1", "openai")
assert status == 204
@ -160,12 +139,11 @@ class TestModelProviderModelApi:
with (
app.test_request_context("/"),
patch("controllers.console.workspace.models.current_account_with_tenant", return_value=(MagicMock(), "t1")),
patch("controllers.console.workspace.models.ModelProviderService") as service,
):
service.return_value.get_models_by_provider.return_value = []
result = method(api, "openai")
result = method(api, "t1", "openai")
assert "data" in result
@ -183,10 +161,6 @@ class TestModelProviderModelCredentialApi:
"model_type": ModelType.LLM,
},
),
patch(
"controllers.console.workspace.models.current_account_with_tenant",
return_value=(MagicMock(), "tenant1"),
),
patch("controllers.console.workspace.models.ModelProviderService") as provider_service,
patch("controllers.console.workspace.models.ModelLoadBalancingService") as lb_service,
):
@ -198,7 +172,7 @@ class TestModelProviderModelCredentialApi:
provider_service.return_value.provider_manager.get_provider_model_available_credentials.return_value = []
lb_service.return_value.get_load_balancing_configs.return_value = (False, [])
result = method(api, "openai")
result = method(api, "tenant1", "openai")
assert "credentials" in result
@ -214,13 +188,9 @@ class TestModelProviderModelCredentialApi:
with (
app.test_request_context("/", json=payload),
patch(
"controllers.console.workspace.models.current_account_with_tenant",
return_value=(MagicMock(), "tenant1"),
),
patch("controllers.console.workspace.models.ModelProviderService"),
):
result, status = method(api, "openai")
result, status = method(api, "tenant1", "openai")
assert status == 201
@ -230,7 +200,6 @@ class TestModelProviderModelCredentialApi:
with (
app.test_request_context("/", query_string={"model": "gpt", "model_type": ModelType.LLM}),
patch("controllers.console.workspace.models.current_account_with_tenant", return_value=(MagicMock(), "t1")),
patch("controllers.console.workspace.models.ModelProviderService") as service,
patch("controllers.console.workspace.models.ModelLoadBalancingService") as lb,
):
@ -238,7 +207,7 @@ class TestModelProviderModelCredentialApi:
service.return_value.provider_manager.get_provider_model_available_credentials.return_value = []
lb.return_value.get_load_balancing_configs.return_value = (False, [])
result = method(api, "openai")
result = method(api, "t1", "openai")
assert result["credentials"] == {}
@ -254,10 +223,9 @@ class TestModelProviderModelCredentialApi:
with (
app.test_request_context("/", json=payload),
patch("controllers.console.workspace.models.current_account_with_tenant", return_value=(MagicMock(), "t1")),
patch("controllers.console.workspace.models.ModelProviderService"),
):
result, status = method(api, "openai")
result, status = method(api, "t1", "openai")
assert status == 204
@ -275,13 +243,9 @@ class TestModelProviderModelCredentialSwitchApi:
with (
app.test_request_context("/", json=payload),
patch(
"controllers.console.workspace.models.current_account_with_tenant",
return_value=(MagicMock(), "tenant1"),
),
patch("controllers.console.workspace.models.ModelProviderService"),
):
result = method(api, "openai")
result = method(api, "tenant1", "openai")
assert result["result"] == "success"
@ -298,13 +262,9 @@ class TestModelEnableDisableApis:
with (
app.test_request_context("/", json=payload),
patch(
"controllers.console.workspace.models.current_account_with_tenant",
return_value=(MagicMock(), "tenant1"),
),
patch("controllers.console.workspace.models.ModelProviderService"),
):
result = method(api, "openai")
result = method(api, "tenant1", "openai")
assert result["result"] == "success"
@ -319,13 +279,9 @@ class TestModelEnableDisableApis:
with (
app.test_request_context("/", json=payload),
patch(
"controllers.console.workspace.models.current_account_with_tenant",
return_value=(MagicMock(), "tenant1"),
),
patch("controllers.console.workspace.models.ModelProviderService"),
):
result = method(api, "openai")
result = method(api, "tenant1", "openai")
assert result["result"] == "success"
@ -343,13 +299,9 @@ class TestModelProviderModelValidateApi:
with (
app.test_request_context("/", json=payload),
patch(
"controllers.console.workspace.models.current_account_with_tenant",
return_value=(MagicMock(), "tenant1"),
),
patch("controllers.console.workspace.models.ModelProviderService"),
):
result = method(api, "openai")
result = method(api, "tenant1", "openai")
assert result["result"] == "success"
@ -366,15 +318,11 @@ class TestModelProviderModelValidateApi:
with (
app.test_request_context("/", json=payload),
patch(
"controllers.console.workspace.models.current_account_with_tenant",
return_value=(MagicMock(), "tenant1"),
),
patch("controllers.console.workspace.models.ModelProviderService") as service_mock,
):
service_mock.return_value.validate_model_credentials.side_effect = CredentialsValidateFailedError("invalid")
result = method(api, "openai")
result = method(api, "tenant1", "openai")
assert result["result"] == "error"
@ -386,15 +334,11 @@ class TestParameterAndAvailableModels:
with (
app.test_request_context("/", query_string={"model": "gpt-4"}),
patch(
"controllers.console.workspace.models.current_account_with_tenant",
return_value=(MagicMock(), "tenant1"),
),
patch("controllers.console.workspace.models.ModelProviderService") as service_mock,
):
service_mock.return_value.get_model_parameter_rules.return_value = []
result = method(api, "openai")
result = method(api, "tenant1", "openai")
assert "data" in result
@ -404,15 +348,11 @@ class TestParameterAndAvailableModels:
with (
app.test_request_context("/"),
patch(
"controllers.console.workspace.models.current_account_with_tenant",
return_value=(MagicMock(), "tenant1"),
),
patch("controllers.console.workspace.models.ModelProviderService") as service_mock,
):
service_mock.return_value.get_models_by_model_type.return_value = []
result = method(api, ModelType.LLM)
result = method(api, "tenant1", ModelType.LLM)
assert "data" in result
@ -422,12 +362,11 @@ class TestParameterAndAvailableModels:
with (
app.test_request_context("/", query_string={"model": "gpt"}),
patch("controllers.console.workspace.models.current_account_with_tenant", return_value=(MagicMock(), "t1")),
patch("controllers.console.workspace.models.ModelProviderService") as service,
):
service.return_value.get_model_parameter_rules.return_value = []
result = method(api, "openai")
result = method(api, "t1", "openai")
assert result["data"] == []
@ -437,11 +376,10 @@ class TestParameterAndAvailableModels:
with (
app.test_request_context("/"),
patch("controllers.console.workspace.models.current_account_with_tenant", return_value=(MagicMock(), "t1")),
patch("controllers.console.workspace.models.ModelProviderService") as service,
):
service.return_value.get_models_by_model_type.return_value = []
result = method(api, ModelType.LLM)
result = method(api, "t1", ModelType.LLM)
assert result["data"] == []

View File

@ -1,206 +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 (
EnterpriseRuntimeCredentialsResolve,
InnerRuntimeCredentialsResolvePayload,
)
def test_runtime_credentials_payload_accepts_items():
payload = InnerRuntimeCredentialsResolvePayload.model_validate(
{
"tenant_id": "tenant-1",
"credentials": [
{
"credential_id": "credential-1",
"provider": "langgenius/openai/openai",
"kind": "model",
}
],
}
)
assert payload.tenant_id == "tenant-1"
assert payload.credentials[0].provider == "langgenius/openai/openai"
assert payload.credentials[0].kind == "model"
@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 = EnterpriseRuntimeCredentialsResolve()
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",
"kind": "model",
}
],
}
body, status_code = unwrapped(handler)
assert status_code == 200
assert body["credentials"][0]["kind"] == "model"
assert body["credentials"][0]["values"]["openai_api_key"] == "sk-test"
assert body["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 = EnterpriseRuntimeCredentialsResolve()
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", "kind": "model"}],
}
body, status_code = unwrapped(handler)
assert status_code == 404
assert "provider" in body["message"]
@patch("controllers.inner_api.runtime_credentials.create_provider_encrypter")
@patch("controllers.inner_api.runtime_credentials.ToolProviderCredentialsCache")
@patch("controllers.inner_api.runtime_credentials.db")
@patch("controllers.inner_api.runtime_credentials.Session")
@patch("controllers.inner_api.runtime_credentials.ToolManager")
def test_runtime_tool_credentials_resolve_returns_decrypted_values(
mock_tool_manager,
mock_session_cls,
mock_db,
mock_cache_cls,
mock_create_encrypter,
app: Flask,
):
provider_controller = MagicMock()
provider_controller.get_credentials_schema_by_type.return_value = []
mock_tool_manager.get_builtin_provider.return_value = provider_controller
builtin_provider = MagicMock()
builtin_provider.id = "credential-1"
session = MagicMock()
session.__enter__.return_value = session
session.__exit__.return_value = False
session.execute.return_value.scalar_one_or_none.return_value = builtin_provider
mock_session_cls.return_value = session
mock_db.engine = MagicMock()
provider_encrypter = MagicMock()
provider_encrypter.decrypt.return_value = {"tavily_api_key": "tvly-secret"}
mock_create_encrypter.return_value = (provider_encrypter, MagicMock())
handler = EnterpriseRuntimeCredentialsResolve()
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/tavily/tavily",
"kind": "tool",
}
],
}
body, status_code = unwrapped(handler)
assert status_code == 200
assert body["credentials"][0]["kind"] == "tool"
assert body["credentials"][0]["provider"] == "langgenius/tavily/tavily"
assert body["credentials"][0]["values"]["tavily_api_key"] == "tvly-secret"
@patch("controllers.inner_api.runtime_credentials.db")
@patch("controllers.inner_api.runtime_credentials.Session")
@patch("controllers.inner_api.runtime_credentials.ToolManager")
def test_runtime_tool_credentials_resolve_rejects_unknown_credential(
mock_tool_manager,
mock_session_cls,
mock_db,
app: Flask,
):
mock_tool_manager.get_builtin_provider.return_value = MagicMock()
session = MagicMock()
session.__enter__.return_value = session
session.__exit__.return_value = False
session.execute.return_value.scalar_one_or_none.return_value = None
mock_session_cls.return_value = session
mock_db.engine = MagicMock()
handler = EnterpriseRuntimeCredentialsResolve()
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": "missing", "provider": "langgenius/tavily/tavily", "kind": "tool"}],
}
body, status_code = unwrapped(handler)
assert status_code == 404
assert "credential" in body["message"]
def test_runtime_credentials_resolve_rejects_unknown_kind(app: Flask):
handler = EnterpriseRuntimeCredentialsResolve()
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": "x", "kind": "secret"}],
}
body, status_code = unwrapped(handler)
assert status_code == 400
assert "kind" in body["message"]

View File

@ -5542,6 +5542,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

@ -7,7 +7,6 @@ export type ClientOptions = {
export type SystemFeatureModel = {
app_dsl_version: string
branding: BrandingModel
enable_app_deploy: boolean
enable_change_email: boolean
enable_collaboration_mode: boolean
enable_creators_platform: boolean

View File

@ -89,7 +89,6 @@ export const zWebAppAuthModel = z.object({
export const zSystemFeatureModel = z.object({
app_dsl_version: z.string().default(''),
branding: zBrandingModel,
enable_app_deploy: z.boolean().default(false),
enable_change_email: z.boolean().default(true),
enable_collaboration_mode: z.boolean().default(true),
enable_creators_platform: z.boolean().default(false),

View File

@ -220,7 +220,6 @@ export type SuggestedQuestionsResponse = {
export type SystemFeatureModel = {
app_dsl_version: string
branding: BrandingModel
enable_app_deploy: boolean
enable_change_email: boolean
enable_collaboration_mode: boolean
enable_creators_platform: boolean

View File

@ -366,7 +366,6 @@ export const zWebAppAuthModel = z.object({
export const zSystemFeatureModel = z.object({
app_dsl_version: z.string().default(''),
branding: zBrandingModel,
enable_app_deploy: z.boolean().default(false),
enable_change_email: z.boolean().default(true),
enable_collaboration_mode: z.boolean().default(true),
enable_creators_platform: z.boolean().default(false),

View File

@ -4,85 +4,9 @@ import { oc } from '@orpc/contract'
import * as z from 'zod'
import {
zAccessServiceCreateApiKeyBody,
zAccessServiceCreateApiKeyPath,
zAccessServiceCreateApiKeyResponse,
zAccessServiceDeleteApiKeyPath,
zAccessServiceDeleteApiKeyResponse,
zAccessServiceGetAccessChannelsPath,
zAccessServiceGetAccessChannelsResponse,
zAccessServiceGetAccessPolicyPath,
zAccessServiceGetAccessPolicyResponse,
zAccessServiceListApiKeysPath,
zAccessServiceListApiKeysResponse,
zAccessServicePutAccessPolicyBody,
zAccessServicePutAccessPolicyPath,
zAccessServicePutAccessPolicyResponse,
zAccessServiceUpdateAccessChannelsBody,
zAccessServiceUpdateAccessChannelsPath,
zAccessServiceUpdateAccessChannelsResponse,
zAccessSubjectServiceListAccessSubjectsQuery,
zAccessSubjectServiceListAccessSubjectsResponse,
zAppInstanceServiceCreateAppInstanceBody,
zAppInstanceServiceCreateAppInstanceResponse,
zAppInstanceServiceDeleteAppInstancePath,
zAppInstanceServiceDeleteAppInstanceResponse,
zAppInstanceServiceGetAppInstancePath,
zAppInstanceServiceGetAppInstanceResponse,
zAppInstanceServiceListAppInstancesQuery,
zAppInstanceServiceListAppInstancesResponse,
zAppInstanceServiceUpdateAppInstanceBody,
zAppInstanceServiceUpdateAppInstancePath,
zAppInstanceServiceUpdateAppInstanceResponse,
zConsoleSsoOAuth2LoginResponse,
zConsoleSsoOidcLoginResponse,
zConsoleSsoSamlLoginResponse,
zDeploymentServiceCancelDeploymentBody,
zDeploymentServiceCancelDeploymentPath,
zDeploymentServiceCancelDeploymentResponse,
zDeploymentServiceCreateInitialDeploymentFromDslBody,
zDeploymentServiceCreateInitialDeploymentFromDslResponse,
zDeploymentServiceCreateInitialDeploymentFromSourceAppBody,
zDeploymentServiceCreateInitialDeploymentFromSourceAppResponse,
zDeploymentServiceDeployBody,
zDeploymentServiceDeployPath,
zDeploymentServiceDeployResponse,
zDeploymentServiceListDeploymentsPath,
zDeploymentServiceListDeploymentsQuery,
zDeploymentServiceListDeploymentsResponse,
zDeploymentServiceListEnvironmentDeploymentsPath,
zDeploymentServiceListEnvironmentDeploymentsResponse,
zDeploymentServicePromoteBody,
zDeploymentServicePromotePath,
zDeploymentServicePromoteResponse,
zDeploymentServiceRollbackBody,
zDeploymentServiceRollbackPath,
zDeploymentServiceRollbackResponse,
zDeploymentServiceUndeployBody,
zDeploymentServiceUndeployPath,
zDeploymentServiceUndeployResponse,
zEnvironmentServiceListDeployableEnvironmentsQuery,
zEnvironmentServiceListDeployableEnvironmentsResponse,
zReleaseServiceCreateReleaseFromDslBody,
zReleaseServiceCreateReleaseFromDslResponse,
zReleaseServiceCreateReleaseFromSourceAppBody,
zReleaseServiceCreateReleaseFromSourceAppResponse,
zReleaseServiceDeleteReleasePath,
zReleaseServiceDeleteReleaseResponse,
zReleaseServiceGetDeploymentOptionsFromDslBody,
zReleaseServiceGetDeploymentOptionsFromDslResponse,
zReleaseServiceGetDeploymentOptionsFromSourceAppBody,
zReleaseServiceGetDeploymentOptionsFromSourceAppResponse,
zReleaseServiceGetReleasePath,
zReleaseServiceGetReleaseResponse,
zReleaseServiceListReleaseCredentialCandidatesPath,
zReleaseServiceListReleaseCredentialCandidatesResponse,
zReleaseServiceListReleasesPath,
zReleaseServiceListReleasesQuery,
zReleaseServiceListReleasesResponse,
zReleaseServiceUpdateReleaseBody,
zReleaseServiceUpdateReleasePath,
zReleaseServiceUpdateReleaseResponse,
zWebAppAuthGetGroupSubjectsQuery,
zWebAppAuthGetGroupSubjectsResponse,
zWebAppAuthGetWebAppAccessModeQuery,
@ -97,440 +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 deleteApiKey = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'AccessService_DeleteApiKey',
path: '/enterprise/app-deploy/api-keys/{apiKeyId}',
tags: ['AccessService'],
})
.input(z.object({ params: zAccessServiceDeleteApiKeyPath }))
.output(zAccessServiceDeleteApiKeyResponse)
export const getAccessChannels = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AccessService_GetAccessChannels',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/access-channels',
tags: ['AccessService'],
})
.input(z.object({ params: zAccessServiceGetAccessChannelsPath }))
.output(zAccessServiceGetAccessChannelsResponse)
export const updateAccessChannels = oc
.route({
inputStructure: 'detailed',
method: 'PUT',
operationId: 'AccessService_UpdateAccessChannels',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/access-channels',
tags: ['AccessService'],
})
.input(
z.object({
body: zAccessServiceUpdateAccessChannelsBody,
params: zAccessServiceUpdateAccessChannelsPath,
}),
)
.output(zAccessServiceUpdateAccessChannelsResponse)
export const getAccessPolicy = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AccessService_GetAccessPolicy',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/access-policy',
tags: ['AccessService'],
})
.input(z.object({ params: zAccessServiceGetAccessPolicyPath }))
.output(zAccessServiceGetAccessPolicyResponse)
export const putAccessPolicy = oc
.route({
inputStructure: 'detailed',
method: 'PUT',
operationId: 'AccessService_PutAccessPolicy',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/access-policy',
tags: ['AccessService'],
})
.input(
z.object({
body: zAccessServicePutAccessPolicyBody,
params: zAccessServicePutAccessPolicyPath,
}),
)
.output(zAccessServicePutAccessPolicyResponse)
export const listApiKeys = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AccessService_ListApiKeys',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/api-keys',
tags: ['AccessService'],
})
.input(z.object({ params: zAccessServiceListApiKeysPath }))
.output(zAccessServiceListApiKeysResponse)
export const createApiKey = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'AccessService_CreateApiKey',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/api-keys',
tags: ['AccessService'],
})
.input(z.object({ body: zAccessServiceCreateApiKeyBody, params: zAccessServiceCreateApiKeyPath }))
.output(zAccessServiceCreateApiKeyResponse)
export const accessService = {
deleteApiKey,
getAccessChannels,
updateAccessChannels,
getAccessPolicy,
putAccessPolicy,
listApiKeys,
createApiKey,
}
export const listAppInstances = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'AppInstanceService_ListAppInstances',
path: '/enterprise/app-deploy/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-deploy/app-instances',
tags: ['AppInstanceService'],
})
.input(z.object({ body: zAppInstanceServiceCreateAppInstanceBody }))
.output(zAppInstanceServiceCreateAppInstanceResponse)
export const deleteAppInstance = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'AppInstanceService_DeleteAppInstance',
path: '/enterprise/app-deploy/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-deploy/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-deploy/app-instances/{appInstanceId}',
tags: ['AppInstanceService'],
})
.input(
z.object({
body: zAppInstanceServiceUpdateAppInstanceBody,
params: zAppInstanceServiceUpdateAppInstancePath,
}),
)
.output(zAppInstanceServiceUpdateAppInstanceResponse)
export const appInstanceService = {
listAppInstances,
createAppInstance,
deleteAppInstance,
getAppInstance,
updateAppInstance,
}
export const listDeployments = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'DeploymentService_ListDeployments',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/deployments',
tags: ['DeploymentService'],
})
.input(
z.object({
params: zDeploymentServiceListDeploymentsPath,
query: zDeploymentServiceListDeploymentsQuery.optional(),
}),
)
.output(zDeploymentServiceListDeploymentsResponse)
export const listEnvironmentDeployments = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'DeploymentService_ListEnvironmentDeployments',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environment-deployments',
tags: ['DeploymentService'],
})
.input(z.object({ params: zDeploymentServiceListEnvironmentDeploymentsPath }))
.output(zDeploymentServiceListEnvironmentDeploymentsResponse)
export const cancelDeployment = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'DeploymentService_CancelDeployment',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/cancel',
tags: ['DeploymentService'],
})
.input(
z.object({
body: zDeploymentServiceCancelDeploymentBody,
params: zDeploymentServiceCancelDeploymentPath,
}),
)
.output(zDeploymentServiceCancelDeploymentResponse)
export const deploy = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'DeploymentService_Deploy',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/deploy',
tags: ['DeploymentService'],
})
.input(z.object({ body: zDeploymentServiceDeployBody, params: zDeploymentServiceDeployPath }))
.output(zDeploymentServiceDeployResponse)
export const rollback = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'DeploymentService_Rollback',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/rollback',
tags: ['DeploymentService'],
})
.input(z.object({ body: zDeploymentServiceRollbackBody, params: zDeploymentServiceRollbackPath }))
.output(zDeploymentServiceRollbackResponse)
export const undeploy = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'DeploymentService_Undeploy',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/undeploy',
tags: ['DeploymentService'],
})
.input(z.object({ body: zDeploymentServiceUndeployBody, params: zDeploymentServiceUndeployPath }))
.output(zDeploymentServiceUndeployResponse)
export const promote = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'DeploymentService_Promote',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/promote',
tags: ['DeploymentService'],
})
.input(z.object({ body: zDeploymentServicePromoteBody, params: zDeploymentServicePromotePath }))
.output(zDeploymentServicePromoteResponse)
export const createInitialDeploymentFromDsl = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'DeploymentService_CreateInitialDeploymentFromDSL',
path: '/enterprise/app-deploy/initial-deployments/dsl',
tags: ['DeploymentService'],
})
.input(z.object({ body: zDeploymentServiceCreateInitialDeploymentFromDslBody }))
.output(zDeploymentServiceCreateInitialDeploymentFromDslResponse)
export const createInitialDeploymentFromSourceApp = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'DeploymentService_CreateInitialDeploymentFromSourceApp',
path: '/enterprise/app-deploy/initial-deployments/source-app',
tags: ['DeploymentService'],
})
.input(z.object({ body: zDeploymentServiceCreateInitialDeploymentFromSourceAppBody }))
.output(zDeploymentServiceCreateInitialDeploymentFromSourceAppResponse)
export const deploymentService = {
listDeployments,
listEnvironmentDeployments,
cancelDeployment,
deploy,
rollback,
undeploy,
promote,
createInitialDeploymentFromDsl,
createInitialDeploymentFromSourceApp,
}
export const listReleases = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ReleaseService_ListReleases',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/releases',
tags: ['ReleaseService'],
})
.input(
z.object({
params: zReleaseServiceListReleasesPath,
query: zReleaseServiceListReleasesQuery.optional(),
}),
)
.output(zReleaseServiceListReleasesResponse)
export const getDeploymentOptionsFromDsl = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'ReleaseService_GetDeploymentOptionsFromDSL',
path: '/enterprise/app-deploy/deployment-options/dsl',
tags: ['ReleaseService'],
})
.input(z.object({ body: zReleaseServiceGetDeploymentOptionsFromDslBody }))
.output(zReleaseServiceGetDeploymentOptionsFromDslResponse)
export const getDeploymentOptionsFromSourceApp = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'ReleaseService_GetDeploymentOptionsFromSourceApp',
path: '/enterprise/app-deploy/deployment-options/source-app',
tags: ['ReleaseService'],
})
.input(z.object({ body: zReleaseServiceGetDeploymentOptionsFromSourceAppBody }))
.output(zReleaseServiceGetDeploymentOptionsFromSourceAppResponse)
export const createReleaseFromDsl = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'ReleaseService_CreateReleaseFromDSL',
path: '/enterprise/app-deploy/releases/dsl',
tags: ['ReleaseService'],
})
.input(z.object({ body: zReleaseServiceCreateReleaseFromDslBody }))
.output(zReleaseServiceCreateReleaseFromDslResponse)
export const createReleaseFromSourceApp = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'ReleaseService_CreateReleaseFromSourceApp',
path: '/enterprise/app-deploy/releases/source-app',
tags: ['ReleaseService'],
})
.input(z.object({ body: zReleaseServiceCreateReleaseFromSourceAppBody }))
.output(zReleaseServiceCreateReleaseFromSourceAppResponse)
export const deleteRelease = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'ReleaseService_DeleteRelease',
path: '/enterprise/app-deploy/releases/{releaseId}',
tags: ['ReleaseService'],
})
.input(z.object({ params: zReleaseServiceDeleteReleasePath }))
.output(zReleaseServiceDeleteReleaseResponse)
export const getRelease = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ReleaseService_GetRelease',
path: '/enterprise/app-deploy/releases/{releaseId}',
tags: ['ReleaseService'],
})
.input(z.object({ params: zReleaseServiceGetReleasePath }))
.output(zReleaseServiceGetReleaseResponse)
export const updateRelease = oc
.route({
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'ReleaseService_UpdateRelease',
path: '/enterprise/app-deploy/releases/{releaseId}',
tags: ['ReleaseService'],
})
.input(
z.object({ body: zReleaseServiceUpdateReleaseBody, params: zReleaseServiceUpdateReleasePath }),
)
.output(zReleaseServiceUpdateReleaseResponse)
export const listReleaseCredentialCandidates = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ReleaseService_ListReleaseCredentialCandidates',
path: '/enterprise/app-deploy/releases/{releaseId}/credential-candidates',
tags: ['ReleaseService'],
})
.input(z.object({ params: zReleaseServiceListReleaseCredentialCandidatesPath }))
.output(zReleaseServiceListReleaseCredentialCandidatesResponse)
export const releaseService = {
listReleases,
getDeploymentOptionsFromDsl,
getDeploymentOptionsFromSourceApp,
createReleaseFromDsl,
createReleaseFromSourceApp,
deleteRelease,
getRelease,
updateRelease,
listReleaseCredentialCandidates,
}
export const listDeployableEnvironments = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'EnvironmentService_ListDeployableEnvironments',
path: '/enterprise/app-deploy/deployable-environments',
tags: ['EnvironmentService'],
})
.input(z.object({ query: zEnvironmentServiceListDeployableEnvironmentsQuery.optional() }))
.output(zEnvironmentServiceListDeployableEnvironmentsResponse)
export const environmentService = {
listDeployableEnvironments,
}
export const oAuth2Login = oc
.route({
inputStructure: 'detailed',
@ -643,12 +133,6 @@ export const webAppAuth = {
}
export const contract = {
accessSubjectService,
accessService,
appInstanceService,
deploymentService,
releaseService,
environmentService,
consoleSso,
webAppAuth,
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -10,21 +10,6 @@ type OpenApiDocument = JsonObject & {
paths?: Record<string, unknown>
}
type OpenApiMediaType = JsonObject & {
schema?: unknown
}
type OpenApiOperation = JsonObject & {
operationId?: string
responses?: Record<string, OpenApiResponse>
}
type OpenApiPathItem = Record<string, unknown>
type OpenApiResponse = JsonObject & {
content?: Record<string, OpenApiMediaType>
}
type ContractOperation = {
id: string
operationId?: string
@ -36,26 +21,9 @@ const enterpriseServerDir = process.env.DIFY_ENTERPRISE_SERVER
? path.resolve(process.env.DIFY_ENTERPRISE_SERVER)
: path.resolve(currentDir, '../../../dify-enterprise/server')
const enterpriseOpenApiPath = path.join(enterpriseServerDir, 'pkg/apis/enterprise/openapi.yaml')
const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put'])
const isConsoleApiPath = (routePath: string) => routePath.startsWith('/console/api/')
const isObject = (value: unknown): value is JsonObject => {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
const asOpenApiOperation = (value: unknown): OpenApiOperation | undefined => {
return isObject(value) ? value as OpenApiOperation : undefined
}
const asOpenApiResponse = (value: unknown): OpenApiResponse | undefined => {
return isObject(value) ? value as OpenApiResponse : undefined
}
const asOpenApiMediaType = (value: unknown): OpenApiMediaType | undefined => {
return isObject(value) ? value as OpenApiMediaType : undefined
}
const stripConsoleApiPrefix = (routePath: string) => {
if (isConsoleApiPath(routePath))
return routePath.replace('/console/api', '')
@ -66,17 +34,9 @@ const stripConsoleApiPrefix = (routePath: string) => {
const stripSchemaNamePrefix = (schemaName: string) => {
return schemaName
.replace(/^dify\.enterprise\.api\.enterprise\./, '')
.replace(/^dify\.enterprise\.api\.appdeploy\./, '')
.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]
@ -88,37 +48,7 @@ const contractNameSegments = (operation: ContractOperation) => {
}
const contractPathSegments = (operation: ContractOperation) => {
return [contractTagSegment(operation.tags?.[0]), ...contractNameSegments(operation)]
}
const hasSchemaLessResponseContent = (operation: OpenApiOperation) => {
if (!isObject(operation.responses))
return false
return Object.values(operation.responses).some((response) => {
const openApiResponse = asOpenApiResponse(response)
if (!openApiResponse || !isObject(openApiResponse.content))
return false
return Object.values(openApiResponse.content).some((mediaType) => {
const openApiMediaType = asOpenApiMediaType(mediaType)
return !!openApiMediaType && !('schema' in openApiMediaType)
})
})
}
// protoc-gen-openapi emits google.api.HttpBody responses as `*/*: {}`. Skip these
// raw download operations until the source OpenAPI exposes an explicit schema.
const stripSchemaLessResponseOperations = (pathItem: OpenApiPathItem) => {
return Object.fromEntries(
Object.entries(pathItem).filter(([method, operation]) => {
if (!operationMethods.has(method.toLowerCase()))
return true
const openApiOperation = asOpenApiOperation(operation)
return !openApiOperation || !hasSchemaLessResponseContent(openApiOperation)
}),
)
return [operation.tags?.[0] || 'default', ...contractNameSegments(operation)]
}
const normalizeEnterpriseOpenApi = () => {
@ -133,13 +63,7 @@ const normalizeEnterpriseOpenApi = () => {
document.paths = Object.fromEntries(
Object.entries(paths)
.filter(([routePath]) => isConsoleApiPath(routePath))
.map(([routePath, pathItem]) => {
if (!isObject(pathItem))
return [stripConsoleApiPrefix(routePath), pathItem]
return [stripConsoleApiPrefix(routePath), stripSchemaLessResponseOperations(pathItem)]
})
.filter(([, pathItem]) => !isObject(pathItem) || Object.keys(pathItem).length > 0),
.map(([routePath, pathItem]) => [stripConsoleApiPrefix(routePath), pathItem]),
)
return document

View File

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

View File

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

View File

@ -1,8 +0,0 @@
import { DeployTab } from '@/features/deployments/detail/deploy-tab'
export default async function InstanceDetailInstancesPage({ 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 { GoogleAnalyticsScripts } 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

@ -2,7 +2,7 @@ import type { AnchorHTMLAttributes, 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', () => {
@ -223,24 +216,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

@ -28,6 +28,8 @@ vi.mock('@/context/app-context', () => ({
}))
vi.mock('@remixicon/react', () => ({
RiBook2Fill: () => <div data-testid="active-icon" />,
RiBook2Line: () => <div data-testid="inactive-icon" />,
RiArrowDownSLine: () => <div data-testid="arrow-down-icon" />,
RiArrowRightSLine: () => <div data-testid="arrow-right-icon" />,
RiAddLine: () => <div data-testid="add-icon" />,
@ -139,32 +141,6 @@ describe('DatasetNav', () => {
render(<DatasetNav />)
expect(screen.getByText('common.menus.datasets')).toBeInTheDocument()
})
it('should render when dataset icon info is missing', () => {
const datasetWithoutIcon = {
...mockDataset,
icon_info: null,
}
vi.mocked(useDatasetDetail).mockReturnValue({
data: datasetWithoutIcon,
} as unknown as ReturnType<typeof useDatasetDetail>)
vi.mocked(useDatasetList).mockReturnValue({
data: {
pages: [
{
data: [datasetWithoutIcon],
},
],
},
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<DatasetNav />)
expect(screen.getByRole('button', { name: /Test Dataset/i })).toBeInTheDocument()
})
})
describe('Navigation Items logic', () => {

View File

@ -1,7 +1,11 @@
'use client'
import type { NavItem } from '../nav/nav-selector'
import type { DataSet, IconInfo } from '@/models/datasets'
import type { DataSet } from '@/models/datasets'
import {
RiBook2Fill,
RiBook2Line,
} from '@remixicon/react'
import { flatten } from 'es-toolkit/compat'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -10,13 +14,6 @@ import { useDatasetDetail, useDatasetList } from '@/service/knowledge/use-datase
import { basePath } from '@/utils/var'
import Nav from '../nav'
const DEFAULT_DATASET_ICON_INFO = {
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
} satisfies IconInfo
const DatasetNav = () => {
const { t } = useTranslation()
const router = useRouter()
@ -36,16 +33,15 @@ const DatasetNav = () => {
const curNav = useMemo(() => {
if (!currentDataset)
return
const iconInfo = currentDataset.icon_info ?? DEFAULT_DATASET_ICON_INFO
return {
id: currentDataset.id,
name: currentDataset.name,
icon: iconInfo.icon,
icon_type: iconInfo.icon_type,
icon_background: iconInfo.icon_background,
icon_url: iconInfo.icon_url,
icon: currentDataset.icon_info.icon,
icon_type: currentDataset.icon_info.icon_type,
icon_background: currentDataset.icon_info.icon_background,
icon_url: currentDataset.icon_info.icon_url,
} as Omit<NavItem, 'link'>
}, [currentDataset])
}, [currentDataset?.id, currentDataset?.name, currentDataset?.icon_info])
const getDatasetLink = useCallback((dataset: DataSet) => {
const isPipelineUnpublished = dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
@ -60,15 +56,14 @@ const DatasetNav = () => {
const navigationItems = useMemo(() => {
return datasetItems.map((dataset) => {
const link = getDatasetLink(dataset)
const iconInfo = dataset.icon_info ?? DEFAULT_DATASET_ICON_INFO
return {
id: dataset.id,
name: dataset.name,
link,
icon: iconInfo.icon,
icon_type: iconInfo.icon_type,
icon_background: iconInfo.icon_background,
icon_url: iconInfo.icon_url,
icon: dataset.icon_info.icon,
icon_type: dataset.icon_info.icon_type,
icon_background: dataset.icon_info.icon_background,
icon_url: dataset.icon_info.icon_url,
}
}) as NavItem[]
}, [datasetItems, getDatasetLink])
@ -89,8 +84,8 @@ const DatasetNav = () => {
return (
<Nav
isApp={false}
icon={<span aria-hidden className="i-ri-book-2-line size-4" />}
activeIcon={<span aria-hidden className="i-ri-book-2-fill size-4" />}
icon={<RiBook2Line className="size-4" />}
activeIcon={<RiBook2Fill className="size-4" />}
text={t('menus.datasets', { ns: 'common' })}
activeSegment="datasets"
link="/datasets"

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,14 +37,12 @@ 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])
const logoLabel = isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'
const renderLogo = () => (
@ -77,17 +75,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 +102,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

@ -1,41 +1,7 @@
import {
formatWorkflowRunIdentifier,
getKeyboardKeyCodeBySystem,
} from '../common'
const setUserAgent = (userAgent: string) => {
Object.defineProperty(navigator, 'userAgent', {
value: userAgent,
configurable: true,
})
}
describe('getKeyboardKeyCodeBySystem', () => {
const originalUserAgent = navigator.userAgent
afterEach(() => {
setUserAgent(originalUserAgent)
})
it('should map ctrl to meta on macOS', () => {
setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')
expect(getKeyboardKeyCodeBySystem('ctrl')).toBe('meta')
})
it('should keep ctrl on non-macOS', () => {
setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
expect(getKeyboardKeyCodeBySystem('ctrl')).toBe('ctrl')
})
it('should keep unmapped keys on macOS', () => {
setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')
expect(getKeyboardKeyCodeBySystem('shift')).toBe('shift')
})
})
describe('formatWorkflowRunIdentifier', () => {
it('should return fallback text when finishedAt is undefined', () => {
expect(formatWorkflowRunIdentifier()).toBe(' (Running)')

View File

@ -1,16 +1,3 @@
const MAC_PLATFORM_PATTERN = /mac/i
const specialKeysCodeMap: Record<string, string | undefined> = {
ctrl: 'meta',
}
export const getKeyboardKeyCodeBySystem = (key: string) => {
if (typeof navigator !== 'undefined' && MAC_PLATFORM_PATTERN.test(navigator.userAgent))
return specialKeysCodeMap[key] || key
return key
}
/**
* Format workflow run identifier using finished_at timestamp
* @param finishedAt - Unix timestamp in seconds

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,229 +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 { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
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']
export type SourceAppPickerValue = Pick<App, 'id' | 'name'> & Partial<Pick<App, 'icon_type' | 'icon' | 'icon_background' | 'icon_url'>>
function sourceAppSearchText(app: App) {
return `${app.name} ${app.id} ${app.mode}`.toLowerCase()
}
function SourceAppTrigger({ open, app }: {
open: boolean
app?: SourceAppPickerValue
}) {
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>
)
}
export function SourceAppPicker({ value, onChange, ariaLabel }: {
value?: SourceAppPickerValue
onChange: (app: App) => void
ariaLabel?: string
}) {
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={ariaLabel ?? 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>
)
}

View File

@ -1,50 +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-[calc(100vw-32px)] overflow-hidden p-0">
<DialogCloseButton aria-label={t('deployDrawer.close')} />
<div className="max-h-[calc(100vh-120px)] overflow-y-auto p-5 sm:p-6">
{!drawerAppInstanceId
? <div className="p-4 text-text-tertiary">{t('deployDrawer.notFound')}</div>
: (
<DeployForm
key={formKey}
appInstanceId={drawerAppInstanceId}
lockedEnvId={drawerEnvironmentId}
presetReleaseId={drawerReleaseId}
/>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -1,379 +0,0 @@
'use client'
import type {
CredentialSlot,
Environment,
Release,
} from '@dify/contracts/enterprise/types.gen'
import type { RuntimeCredentialBindingSelections } from '../runtime-credential-bindings-utils'
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 { environmentBackend, environmentMode, environmentName } from '../../environment'
import { createDeploymentIdempotencyKey } from '../../idempotency'
import { formatDate, releaseCommit, releaseLabel } from '../../release'
import { isAvailableDeploymentTarget } from '../../runtime-status'
import { closeDeployDrawerAtom } from '../../store'
import {
RuntimeCredentialBindingsPanel,
} from '../runtime-credential-bindings'
import {
hasMissingRequiredRuntimeCredentialBinding,
runtimeCredentialSlotKey,
selectedDeploymentRuntimeCredentials,
selectedRuntimeCredentialSelections,
} from '../runtime-credential-bindings-utils'
import {
DeploymentSelect,
EnvironmentRow,
Field,
} from './select'
type DeployFormProps = {
appInstanceId: string
lockedEnvId?: string
presetReleaseId?: string
}
type DeployReadyFormProps = DeployFormProps & {
environments: EnvironmentOption[]
releases: Release[]
defaultReleaseId?: string
}
type EnvironmentOption = Environment & { id: string }
const DEPLOY_FORM_FIELD_SKELETON_KEYS = ['environment', 'release']
type BindingSelections = RuntimeCredentialBindingSelections
type BindingOptionsPanelProps = {
slots: CredentialSlot[]
selections: BindingSelections
isLoading: boolean
hasError: boolean
bindingCountLabel: string
onChange: (slot: string, value: string) => void
}
function BindingOptionsPanel({
slots,
selections,
isLoading,
hasError,
bindingCountLabel,
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 (
<RuntimeCredentialBindingsPanel
slots={slots}
selections={selections}
title={t('deployDrawer.runtimeCredentials')}
hint={t('deployDrawer.bindingSelectionHint')}
requiredLabel={t('deployDrawer.requiredBinding')}
noBindingRequiredLabel={t('deployDrawer.noBindingRequired')}
noCredentialCandidatesLabel={t('deployDrawer.noCredentialCandidates')}
selectCredentialLabel={t('deployDrawer.selectCredential')}
missingRequiredLabel={t('deployDrawer.missingRequiredBinding')}
bindingCountLabel={bindingCountLabel}
onChange={onChange}
/>
)
}
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,
}: DeployReadyFormProps) {
const { t } = useTranslation('deployments')
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
const startDeploy = useMutation(consoleQuery.enterprise.deploymentService.deploy.mutationOptions())
const presetRelease = presetReleaseId ? releases.find(r => r.id === presetReleaseId) : undefined
const displayedRelease: Release | 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 hasSelectedEnvironment = Boolean(selectedEnvironmentId && selectedEnvironment)
const bindingOptions = useQuery(consoleQuery.enterprise.releaseService.listReleaseCredentialCandidates.queryOptions({
input: {
params: {
releaseId: targetReleaseId || '',
},
},
enabled: Boolean(appInstanceId && targetReleaseId && hasSelectedEnvironment),
}))
const bindingSlots = bindingOptions.data?.slots?.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
const [manualBindings, setManualBindings] = useState<BindingSelections>({})
const selectedBindings = selectedRuntimeCredentialSelections(bindingSlots, manualBindings)
const deploymentCredentials = selectedDeploymentRuntimeCredentials(bindingSlots, selectedBindings)
const bindingOptionsLoading = Boolean(targetReleaseId && hasSelectedEnvironment && (bindingOptions.isLoading || bindingOptions.isFetching))
const bindingOptionsReady = Boolean(targetReleaseId && hasSelectedEnvironment && bindingOptions.data && !bindingOptionsLoading && !bindingOptions.isError)
const requiredBindingsReady = bindingSlots.every(slot => !hasMissingRequiredRuntimeCredentialBinding(slot, selectedBindings[runtimeCredentialSlotKey(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 = t('deployDrawer.title')
const actionDescription = t('deployDrawer.description')
const submitLabel = isSubmitting ? t('deployDrawer.deploying') : t('deployDrawer.deploy')
const handleDeploy = () => {
if (!canDeploy || !targetReleaseId)
return
const idempotencyKey = createDeploymentIdempotencyKey()
startDeploy.mutate(
{
params: {
appInstanceId,
environmentId: selectedEnvironmentId,
},
body: {
appInstanceId,
environmentId: selectedEnvironmentId,
releaseId: targetReleaseId,
credentials: deploymentCredentials,
idempotencyKey,
},
},
{
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">{formatDate(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} />
: environments.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.noNewEnvironmentAvailable')}
</div>
)
: (
<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')} · ${environmentBackend(env).toUpperCase()}`,
}))}
placeholder={t('deployDrawer.selectEnv')}
/>
)}
</Field>
{targetReleaseId && hasSelectedEnvironment && (
<BindingOptionsPanel
slots={bindingSlots}
selections={selectedBindings}
isLoading={bindingOptionsLoading}
hasError={bindingOptions.isError}
bindingCountLabel={t('deployDrawer.bindingCount', { count: bindingSlots.length })}
onChange={(slot, value) => setManualBindings(prev => ({ ...prev, [slot]: value }))}
/>
)}
<div className="flex justify-end gap-2 pt-4">
<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.releaseService.listReleases.queryOptions({
input: {
params: { appInstanceId },
query: {
pageNumber: 1,
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
},
},
}))
const runtimeInstancesQuery = useQuery(consoleQuery.enterprise.deploymentService.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 runtimeRows = runtimeInstancesQuery.data?.data ?? []
const selectableEnvironmentRows = runtimeRows
.filter(row => lockedEnvId ? Boolean(row.environment?.id) : isAvailableDeploymentTarget(row))
const environments = selectableEnvironmentRows
.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 formKey = `${appInstanceId}-${lockedEnvId ?? 'any'}-${presetReleaseId ?? 'new'}-${defaultReleaseId ?? 'none'}`
return (
<DeployReadyForm
key={formKey}
appInstanceId={appInstanceId}
environments={environments}
releases={releases}
defaultReleaseId={defaultReleaseId}
lockedEnvId={lockedEnvId}
presetReleaseId={presetReleaseId}
/>
)
}

View File

@ -1,94 +0,0 @@
'use client'
import type { Environment } 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 = Environment & {
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">{environmentMode(env)}</span>
</div>
)
}

View File

@ -1,68 +0,0 @@
import type {
CredentialSelectionInput,
CredentialSlot,
} from '@dify/contracts/enterprise/types.gen'
export type RuntimeCredentialBindingSelections = Record<string, string>
export type RuntimeCredentialSelectOption = {
value: string
label: string
}
export function runtimeCredentialSlotKey(slot: CredentialSlot) {
return [slot.providerId ?? '', slot.category ?? ''].join(':')
}
export function runtimeCredentialCandidateOptions(slot: CredentialSlot): RuntimeCredentialSelectOption[] {
return (slot.candidates ?? [])
.filter(candidate => candidate.credentialId)
.map(candidate => ({
value: candidate.credentialId!,
label: [
candidate.displayName,
candidate.providerId,
].filter(Boolean).join(' · ') || candidate.credentialId!,
}))
}
export function hasMissingRequiredRuntimeCredentialBinding(_slot: CredentialSlot, selectedValue?: string) {
return !selectedValue
}
export function selectedRuntimeCredentialSelections(
slots: CredentialSlot[],
manualBindings: RuntimeCredentialBindingSelections,
): RuntimeCredentialBindingSelections {
const next: RuntimeCredentialBindingSelections = {}
for (const slot of slots) {
const slotKey = runtimeCredentialSlotKey(slot)
const candidates = runtimeCredentialCandidateOptions(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
}
export function selectedDeploymentRuntimeCredentials(
slots: CredentialSlot[],
selections: RuntimeCredentialBindingSelections,
): CredentialSelectionInput[] {
return slots
.map((slot): CredentialSelectionInput | undefined => {
const slotKey = runtimeCredentialSlotKey(slot)
const selectedValue = selections[slotKey]
if (!slotKey || !selectedValue)
return undefined
return {
providerId: slot.providerId,
category: slot.category,
credentialId: selectedValue,
}
})
.filter((binding): binding is CredentialSelectionInput => Boolean(binding))
}

View File

@ -1,186 +0,0 @@
'use client'
import type {
CredentialSlot,
} from '@dify/contracts/enterprise/types.gen'
import type {
RuntimeCredentialBindingSelections,
RuntimeCredentialSelectOption,
} from './runtime-credential-bindings-utils'
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 {
hasMissingRequiredRuntimeCredentialBinding,
runtimeCredentialCandidateOptions,
runtimeCredentialSlotKey,
} from './runtime-credential-bindings-utils'
type RuntimeCredentialBindingsPanelProps = {
slots: CredentialSlot[]
selections: RuntimeCredentialBindingSelections
title: string
hint: string
requiredLabel: string
noBindingRequiredLabel: string
noCredentialCandidatesLabel: string
selectCredentialLabel: string
missingRequiredLabel: string
bindingCountLabel?: string
onChange: (slotKey: string, value: string) => void
className?: string
listClassName?: string
}
function RuntimeCredentialSelect({
ariaLabel,
value,
options,
placeholder,
onChange,
}: {
ariaLabel: string
value: string
options: RuntimeCredentialSelectOption[]
placeholder: string
onChange: (value: string) => void
}) {
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
aria-label={ariaLabel}
className={cn(
'h-8 min-w-0 border border-divider-subtle px-2 text-left system-sm-medium hover:border-components-input-border-hover focus:border-components-input-border-active',
!selectedOption && 'text-text-quaternary',
)}
>
{selectedOption?.label ?? placeholder}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{options.map(option => (
<SelectItem key={option.value} value={option.value} title={option.label}>
<SelectItemText>{option.label}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
export function RuntimeCredentialBindingsPanel({
slots,
selections,
title,
hint,
requiredLabel,
noBindingRequiredLabel,
noCredentialCandidatesLabel,
selectCredentialLabel,
missingRequiredLabel,
bindingCountLabel,
onChange,
className,
listClassName,
}: RuntimeCredentialBindingsPanelProps) {
const { t } = useTranslation('plugin')
return (
<div className={cn('overflow-hidden rounded-xl border border-divider-subtle bg-background-default-subtle', className)}>
<div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
<div className="system-xs-medium-uppercase text-text-tertiary">{title}</div>
{slots.length > 0 && (
<span className="shrink-0 rounded-md bg-background-default px-1.5 py-0.5 system-2xs-medium text-text-quaternary">
{bindingCountLabel ?? slots.length}
</span>
)}
</div>
<span className="system-xs-regular text-text-quaternary">{hint}</span>
</div>
{slots.length === 0
? (
<div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{noBindingRequiredLabel}
</div>
)
: (
<div className={cn('max-h-[min(360px,34dvh)] overflow-y-auto border-t border-divider-subtle', listClassName)}>
{slots.map((slot) => {
const slotKey = runtimeCredentialSlotKey(slot)
const candidates = runtimeCredentialCandidateOptions(slot)
const selectedValue = selections[slotKey] ?? ''
const missing = hasMissingRequiredRuntimeCredentialBinding(slot, selectedValue)
const slotName = slot.providerId || slotKey
const categoryLabel = slot.category === 'PLUGIN_CATEGORY_MODEL'
? t('categorySingle.model')
: slot.category === 'PLUGIN_CATEGORY_TOOL'
? t('categorySingle.tool')
: undefined
return (
<div key={slotKey} className="flex flex-col gap-2 border-b border-divider-subtle px-3 py-3 last:border-b-0">
<div className="flex min-w-0 flex-col gap-2.5">
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate font-mono system-xs-semibold text-text-primary" title={slotName}>
{slotName}
</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{categoryLabel && (
<span className="shrink-0 rounded-md bg-util-colors-blue-light-blue-light-50 px-1.5 py-0.5 system-2xs-medium-uppercase text-util-colors-blue-blue-600">
{categoryLabel}
</span>
)}
<span className="shrink-0 rounded-md bg-background-default px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{requiredLabel}
</span>
</div>
</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">
{noCredentialCandidatesLabel}
</div>
)
: (
<RuntimeCredentialSelect
ariaLabel={slotName}
value={selectedValue}
onChange={value => onChange(slotKey, value)}
options={candidates}
placeholder={selectCredentialLabel}
/>
)}
</div>
{missing && (
<div className="system-xs-regular text-text-destructive">
{missingRequiredLabel}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}

View File

@ -1,38 +0,0 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
type EnvironmentMode = 'shared' | 'isolated'
type EnvironmentHealth = 'ready' | 'degraded'
const baseBadge = 'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 system-xs-medium whitespace-nowrap'
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>
)
}

View File

@ -1,48 +0,0 @@
'use client'
import { useTranslation } from 'react-i18next'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import { StepShell } from './layout'
export function DslStep({
dslFile,
isReadingDsl,
readError,
onDslFileChange,
}: {
dslFile?: File
isReadingDsl: boolean
readError: boolean
onDslFileChange: (file?: File) => void
}) {
const { t } = useTranslation('deployments')
return (
<StepShell title={t('createGuide.dsl.title')} description={t('createGuide.dsl.description')} hideHeader>
<div className="flex flex-col gap-4 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-5">
<div className="flex items-start gap-3">
<span className="mt-0.5 i-ri-upload-cloud-2-line size-5 shrink-0 text-text-tertiary" aria-hidden="true" />
<div className="flex min-w-0 flex-col gap-1">
<div className="system-sm-semibold text-text-primary">{t('createGuide.dsl.dropTitle')}</div>
<div className="system-sm-regular text-text-tertiary">{t('createGuide.dsl.dropDescription')}</div>
</div>
</div>
<Uploader
className="mt-0"
file={dslFile}
updateFile={onDslFileChange}
/>
{isReadingDsl && (
<div className="system-xs-regular text-text-tertiary">
{t('createGuide.dsl.reading')}
</div>
)}
{readError && (
<div className="system-xs-regular text-text-destructive">
{t('createGuide.dsl.readFailed')}
</div>
)}
</div>
</StepShell>
)
}

View File

@ -1,71 +0,0 @@
'use client'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
import { GuideActions, GuideCard, GuideFrame } from './layout'
import { CreationSections } from './source-release-sections'
import { TargetReviewSections } from './target-step'
import { useCreateDeploymentGuide } from './use-create-deployment-guide'
export function CreateDeploymentGuide() {
const { t } = useTranslation('deployments')
const {
canContinue,
canSkipDeployment,
creationSectionsProps,
handleBack,
handlePrimaryAction,
handleSkipDeployment,
isDeploying,
showTargetConfiguration,
step,
targetReviewSectionsProps,
} = useCreateDeploymentGuide()
const guideContent = (
<>
{showTargetConfiguration
? (
<div className="flex flex-col gap-7 pb-4">
<TargetReviewSections {...targetReviewSectionsProps} />
</div>
)
: (
<CreationSections {...creationSectionsProps} />
)}
</>
)
return (
<div className="fixed inset-0 z-50 bg-background-overlay-backdrop p-4 backdrop-blur-[6px]">
<div className="mx-auto h-full w-full max-w-[1120px] overflow-hidden rounded-2xl border border-effects-highlight bg-background-default-subtle">
<main className="relative flex h-full min-w-0 grow flex-col overflow-hidden">
<Link
href="/deployments"
aria-label={t('createGuide.nav.back')}
className="absolute top-3 right-3 z-50 flex h-9 w-9 cursor-pointer items-center justify-center rounded-[10px] bg-components-button-tertiary-bg hover:bg-components-button-tertiary-bg-hover"
>
<span aria-hidden="true" className="i-ri-close-large-line h-3.5 w-3.5 text-components-button-tertiary-text" />
</Link>
<GuideFrame activeStep={step}>
<GuideCard
actions={(
<GuideActions
canContinue={canContinue}
canSkipDeployment={canSkipDeployment}
isDeploying={isDeploying}
step={step}
onBack={handleBack}
onPrimaryAction={handlePrimaryAction}
onSkipDeployment={handleSkipDeployment}
/>
)}
>
{guideContent}
</GuideCard>
</GuideFrame>
</main>
</div>
</div>
)
}

View File

@ -1,240 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import type { GuideStep } from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
const GUIDE_PROGRESS_STEPS: GuideStep[] = ['source', 'release', 'target']
function GuideStepIntro({ activeStep }: {
activeStep: GuideStep
}) {
const { t } = useTranslation('deployments')
let title: string
let description: string
if (activeStep === 'source') {
title = t('createGuide.source.title')
description = t('createGuide.method.description')
}
else if (activeStep === 'release') {
title = t('createGuide.release.title')
description = t('createGuide.release.description')
}
else if (activeStep === 'target') {
title = t('createGuide.target.title')
description = t('createGuide.target.description')
}
else {
return null
}
return (
<div className="pb-4">
<h2 className="system-md-semibold text-text-primary">{title}</h2>
<p className="mt-1 max-w-150 system-sm-regular text-text-tertiary">{description}</p>
</div>
)
}
function GuideProgress({ activeStep }: {
activeStep: GuideStep
}) {
const { t } = useTranslation('deployments')
const activeIndex = GUIDE_PROGRESS_STEPS.indexOf(activeStep)
return (
<ol className="grid grid-cols-3 gap-1.5">
{GUIDE_PROGRESS_STEPS.map((step, index) => {
const isActive = step === activeStep
const isComplete = index < activeIndex
const label = t(`createGuide.steps.${step}`)
return (
<li
key={step}
aria-current={isActive ? 'step' : undefined}
title={label}
className={cn(
'flex min-w-0 items-start gap-1.5 px-1 py-1.5 system-xs-medium sm:items-center sm:gap-2 sm:px-2',
isActive
? 'text-text-primary'
: isComplete
? 'text-text-secondary'
: 'text-text-quaternary',
)}
>
<span
aria-hidden
className={cn(
'mt-1 size-2 shrink-0 rounded-full border-[1.5px] sm:mt-0',
isActive
? 'border-text-primary bg-text-primary'
: isComplete
? 'border-text-secondary bg-text-secondary'
: 'border-text-quaternary bg-transparent',
)}
/>
<span className="line-clamp-2 min-w-0 leading-4">{label}</span>
</li>
)
})}
</ol>
)
}
function GuideProgressSummary({ activeStep }: {
activeStep: GuideStep
}) {
const { t } = useTranslation('deployments')
const activeIndex = GUIDE_PROGRESS_STEPS.indexOf(activeStep)
const activeStepNumber = activeIndex + 1
let activeStepLabel: string
if (activeStep === 'source')
activeStepLabel = t('createGuide.steps.source')
else if (activeStep === 'release')
activeStepLabel = t('createGuide.steps.release')
else if (activeStep === 'target')
activeStepLabel = t('createGuide.steps.target')
else
return null
if (activeIndex < 0)
return null
return (
<div className="flex w-full min-w-0 flex-col gap-2">
<div className="flex min-w-0 items-baseline justify-between gap-3">
<span className="truncate system-sm-medium text-text-secondary">{activeStepLabel}</span>
<span className="shrink-0 system-xs-regular text-text-quaternary">
{activeStepNumber}
/
{GUIDE_PROGRESS_STEPS.length}
</span>
</div>
<div className="grid grid-cols-3 gap-1" aria-hidden="true">
{GUIDE_PROGRESS_STEPS.map((step, index) => (
<span
key={step}
className={cn(
'h-1 rounded-full',
index <= activeIndex ? 'bg-text-primary' : 'bg-divider-subtle',
)}
/>
))}
</div>
</div>
)
}
export function StepShell({ title, description, descriptionClassName, hideHeader, children }: {
title: string
description: string
descriptionClassName?: string
hideHeader?: boolean
children: ReactNode
}) {
return (
<section aria-label={hideHeader ? title : undefined} className="flex min-w-0 flex-col gap-4">
{!hideHeader && (
<div className="flex min-w-0 flex-col gap-0.5">
<h2 className="system-md-semibold text-text-primary">{title}</h2>
<p className={cn('system-sm-regular text-text-tertiary', descriptionClassName)}>{description}</p>
</div>
)}
{children}
</section>
)
}
export function GuideCard({ children, actions }: {
children: ReactNode
actions: ReactNode
}) {
return (
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col">
<div className="min-h-0 flex-1">
{children}
</div>
{actions}
</div>
)
}
export function GuideFrame({ activeStep, children }: {
activeStep: GuideStep
children: ReactNode
}) {
const { t } = useTranslation('deployments')
return (
<div className="relative flex h-full min-h-0 overflow-hidden bg-background-default-subtle">
<div className="flex min-w-0 flex-1 shrink-0 justify-center overflow-y-auto">
<section
aria-label={t('createGuide.title')}
className="flex h-full w-full max-w-[840px] flex-col px-5 sm:px-8 lg:px-10"
>
<div className="h-5 sm:h-8 lg:h-12" />
<div className="flex min-w-0 items-start justify-between gap-6 pt-1 pb-4">
<h1 className="title-2xl-semi-bold text-text-primary">{t('createGuide.title')}</h1>
<div className="hidden w-[148px] shrink-0 min-[1120px]:block">
<GuideProgressSummary activeStep={activeStep} />
</div>
</div>
<GuideStepIntro activeStep={activeStep} />
<div className="mb-6 lg:hidden">
<GuideProgress activeStep={activeStep} />
</div>
{children}
</section>
</div>
</div>
)
}
export function GuideActions({
canContinue,
canSkipDeployment,
isDeploying,
step,
onBack,
onPrimaryAction,
onSkipDeployment,
}: {
canContinue: boolean
canSkipDeployment: boolean
isDeploying: boolean
step: GuideStep
onBack: () => void
onPrimaryAction: () => void
onSkipDeployment: () => void
}) {
const { t } = useTranslation('deployments')
const primaryLabel = step === 'target'
? isDeploying ? t('createGuide.actions.deploying') : t('createGuide.actions.createAndDeploy')
: step === 'release' && isDeploying
? t('createGuide.actions.creating')
: t('createGuide.actions.next')
return (
<div className="sticky bottom-0 z-10 -mx-5 mt-auto flex items-center justify-end gap-2 border-t border-divider-subtle bg-background-default-subtle/95 px-5 py-4 backdrop-blur-sm sm:-mx-8 sm:px-8 lg:-mx-10 lg:px-10">
{(step === 'release' || step === 'target') && (
<Button type="button" variant="secondary" onClick={onBack} disabled={isDeploying}>
{t('createGuide.actions.back')}
</Button>
)}
{step === 'target' && (
<Button type="button" variant="secondary" disabled={!canSkipDeployment || isDeploying} onClick={onSkipDeployment}>
{t('createGuide.actions.skipDeploy')}
</Button>
)}
<Button type="button" variant="primary" disabled={!canContinue || isDeploying} onClick={onPrimaryAction}>
{primaryLabel}
</Button>
</div>
)
}

View File

@ -1,78 +0,0 @@
'use client'
import type { GuideMethod } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { StepShell } from './layout'
function MethodCard({ icon, title, description, badge, selected, onClick }: {
icon: string
title: string
description: string
badge?: string
selected: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
`relative box-content h-[84px] w-full cursor-pointer rounded-xl border-[0.5px]
border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3
text-left shadow-xs hover:shadow-md sm:w-[191px]`,
selected && 'shadow-md outline-[1.5px] outline-components-option-card-option-selected-border outline-solid',
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-md border border-divider-subtle bg-background-default-subtle">
<span className={cn('size-4 text-text-tertiary', icon)} aria-hidden="true" />
</span>
<span className="mt-2 mb-0.5 flex min-w-0 items-center gap-1">
<span className="truncate system-sm-semibold text-text-secondary">{title}</span>
{badge && (
<span className="shrink-0 rounded-md bg-background-default-subtle px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{badge}
</span>
)}
</span>
<span className="flex min-w-0 items-start gap-1">
<span className="line-clamp-2 min-w-0 grow system-xs-regular text-text-tertiary" title={description}>
{description}
</span>
</span>
</button>
)
}
export function MethodStep({ method, onSelect }: {
method?: GuideMethod
onSelect: (method: GuideMethod) => void
}) {
const { t } = useTranslation('deployments')
return (
<StepShell
title={t('createGuide.steps.method')}
description={t('createGuide.method.description')}
descriptionClassName="lg:hidden"
hideHeader
>
<div className="flex flex-col gap-2 sm:flex-row">
<MethodCard
icon="i-ri-stack-line"
title={t('createGuide.methods.bindApp.title')}
description={t('createGuide.methods.bindApp.description')}
selected={method === 'bindApp'}
onClick={() => onSelect('bindApp')}
/>
<MethodCard
icon="i-ri-file-code-line"
title={t('createGuide.methods.importDsl.title')}
description={t('createGuide.methods.importDsl.description')}
selected={method === 'importDsl'}
onClick={() => onSelect('importDsl')}
/>
</div>
</StepShell>
)
}

View File

@ -1,108 +0,0 @@
'use client'
import { Input } from '@langgenius/dify-ui/input'
import { useTranslation } from 'react-i18next'
import { StepShell } from './layout'
export function ReleaseStep({
instanceName,
instanceDescription,
releaseName,
releaseDescription,
instanceNamePlaceholder,
releaseNamePlaceholder,
onInstanceNameChange,
onInstanceDescriptionChange,
onReleaseNameChange,
onReleaseDescriptionChange,
}: {
instanceName: string
instanceDescription: string
releaseName: string
releaseDescription: string
instanceNamePlaceholder: string
releaseNamePlaceholder: string
onInstanceNameChange: (value: string) => void
onInstanceDescriptionChange: (value: string) => void
onReleaseNameChange: (value: string) => void
onReleaseDescriptionChange: (value: string) => void
}) {
const { t } = useTranslation('deployments')
return (
<StepShell
title={t('createGuide.release.title')}
description={t('createGuide.release.description')}
hideHeader
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<h3 className="system-sm-semibold text-text-primary">
{t('createGuide.release.deployInfo')}
</h3>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="create-guide-instance-name">
{t('createGuide.release.instanceName')}
</label>
<Input
id="create-guide-instance-name"
value={instanceName}
onChange={event => onInstanceNameChange(event.target.value)}
placeholder={instanceNamePlaceholder}
required
className="h-9"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="create-guide-instance-description">
{t('createGuide.release.instanceDescription')}
</label>
<span className="system-xs-regular text-text-quaternary">{t('versions.optional')}</span>
</div>
<textarea
id="create-guide-instance-description"
value={instanceDescription}
onChange={event => onInstanceDescriptionChange(event.target.value)}
placeholder={t('createGuide.release.instanceDescriptionPlaceholder')}
className="min-h-16 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 flex-col gap-4">
<h3 className="system-sm-semibold text-text-primary">
{t('createGuide.release.firstVersion')}
</h3>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="create-guide-release-name">
{t('createGuide.release.releaseName')}
</label>
<Input
id="create-guide-release-name"
value={releaseName}
onChange={event => onReleaseNameChange(event.target.value)}
placeholder={releaseNamePlaceholder}
required
className="h-9"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="create-guide-release-description">
{t('createGuide.release.releaseDescription')}
</label>
<span className="system-xs-regular text-text-quaternary">{t('versions.optional')}</span>
</div>
<textarea
id="create-guide-release-description"
value={releaseDescription}
onChange={event => onReleaseDescriptionChange(event.target.value)}
placeholder={t('createGuide.release.releaseDescriptionPlaceholder')}
className="min-h-16 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>
</StepShell>
)
}

View File

@ -1,104 +0,0 @@
'use client'
import type { ReactNode } from 'react'
import type { GuideMethod } from './types'
import type { App } from '@/types/app'
import { DslStep } from './dsl-step'
import { MethodStep } from './method-step'
import { ReleaseStep } from './release-step'
import { SourceStep } from './source-step'
export function CreationSections({
children,
defaultedReleaseName,
instanceDescription,
instanceName,
method,
onInstanceDescriptionChange,
onInstanceNameChange,
onReleaseDescriptionChange,
onReleaseNameChange,
onSearchTextChange,
onSelectMethod,
onSelectSourceApp,
onDslFileChange,
releaseDescription,
releaseName,
selectedApp,
sourceApps,
sourceAppsLoading,
sourceName,
sourceSearchText,
stage,
dslFile,
isReadingDsl,
dslReadError,
}: {
children?: ReactNode
defaultedReleaseName: string
instanceDescription: string
instanceName: string
method?: GuideMethod
onInstanceDescriptionChange: (value: string) => void
onInstanceNameChange: (value: string) => void
onReleaseDescriptionChange: (value: string) => void
onReleaseNameChange: (value: string) => void
onSearchTextChange: (value: string) => void
onSelectMethod: (method: GuideMethod) => void
onSelectSourceApp: (app: App) => void
onDslFileChange: (file?: File) => void
releaseDescription: string
releaseName: string
selectedApp?: App
sourceApps: App[]
sourceAppsLoading: boolean
sourceName: string
sourceSearchText: string
stage: 'source' | 'release'
dslFile?: File
isReadingDsl: boolean
dslReadError: boolean
}) {
return (
<div className="flex flex-col gap-7 pb-4">
{stage === 'source' && (
<>
<MethodStep method={method} onSelect={onSelectMethod} />
{method === 'bindApp' && (
<SourceStep
apps={sourceApps}
selectedApp={selectedApp}
searchText={sourceSearchText}
isLoading={sourceAppsLoading}
onSearchTextChange={onSearchTextChange}
onSelectApp={onSelectSourceApp}
/>
)}
{method === 'importDsl' && (
<DslStep
dslFile={dslFile}
isReadingDsl={isReadingDsl}
readError={dslReadError}
onDslFileChange={onDslFileChange}
/>
)}
</>
)}
{stage === 'release' && method && (
<ReleaseStep
instanceName={instanceName}
instanceDescription={instanceDescription}
releaseName={releaseName}
releaseDescription={releaseDescription}
instanceNamePlaceholder={sourceName}
releaseNamePlaceholder={defaultedReleaseName}
onInstanceNameChange={onInstanceNameChange}
onInstanceDescriptionChange={onInstanceDescriptionChange}
onReleaseNameChange={onReleaseNameChange}
onReleaseDescriptionChange={onReleaseDescriptionChange}
/>
)}
{children}
</div>
)
}

View File

@ -1,158 +0,0 @@
'use client'
import type { App } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { toAppMode } from '../app-mode'
import { StepShell } from './layout'
const sourceAppSkeletonKeys = ['first-source-app', 'second-source-app', 'third-source-app']
function sourceAppSearchText(app: App) {
return `${app.name} ${app.id} ${app.mode}`.toLowerCase()
}
function SourceAppSkeleton() {
return (
<div className="divide-y divide-divider-subtle">
{sourceAppSkeletonKeys.map(key => (
<SkeletonRow key={key} className="h-14 px-3 py-2">
<SkeletonRectangle className="my-0 size-7 animate-pulse rounded-lg" />
<div className="flex min-w-0 grow flex-col gap-1">
<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>
</SkeletonRow>
))}
</div>
)
}
function SourceAppOption({ app, selected, onSelect }: {
app: App
selected: boolean
onSelect: () => void
}) {
const { t } = useTranslation('deployments')
const mode = toAppMode(app.mode)
return (
<label
className={cn(
'group flex min-h-14 cursor-pointer items-center gap-3 border-b border-l-2 border-b-divider-subtle px-3 py-2 transition-colors first:rounded-t-lg last:rounded-b-lg last:border-b-0',
selected
? 'border-l-state-accent-solid bg-background-default hover:bg-state-base-hover'
: 'border-l-transparent bg-background-default hover:bg-state-base-hover',
)}
>
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<span className="flex min-w-0 grow flex-col gap-0.5">
<span className="truncate system-sm-medium text-text-primary">{app.name}</span>
<span className={cn('truncate system-xs-regular', selected ? 'text-text-secondary' : 'text-text-tertiary')}>{t(`appMode.${mode}`)}</span>
</span>
<input
type="radio"
name="source-app"
checked={selected}
onChange={onSelect}
className="sr-only"
/>
<span
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full',
selected ? 'bg-state-accent-active text-text-accent' : 'text-transparent',
)}
aria-hidden="true"
>
<span className="i-ri-check-line size-4" />
</span>
</label>
)
}
export function SourceStep({
apps,
selectedApp,
searchText,
isLoading,
onSearchTextChange,
onSelectApp,
}: {
apps: App[]
selectedApp?: App
searchText: string
isLoading: boolean
onSearchTextChange: (value: string) => void
onSelectApp: (app: App) => void
}) {
const { t } = useTranslation('deployments')
const effectiveSelectedAppId = selectedApp?.id ?? apps[0]?.id
const filteredApps = searchText.trim()
? apps.filter(app => sourceAppSearchText(app).includes(searchText.trim().toLowerCase()))
: apps
return (
<StepShell
title={t('createGuide.source.title')}
description={t('createGuide.source.description')}
descriptionClassName="lg:hidden"
hideHeader
>
<div className="flex flex-col gap-3">
<div className="relative">
<span className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" aria-hidden="true" />
<Input
id="create-guide-source-search"
aria-label={t('createGuide.source.sourceApp')}
value={searchText}
onChange={event => onSearchTextChange(event.target.value)}
placeholder={t('createGuide.source.searchPlaceholder')}
className="h-9 pr-8 pl-8"
/>
{searchText && (
<button
type="button"
aria-label={t('createGuide.source.clearSearch')}
onClick={() => onSearchTextChange('')}
className="absolute top-1/2 right-2.5 flex size-4 -translate-y-1/2 items-center justify-center text-text-quaternary hover:text-text-secondary"
>
<span className="i-ri-close-circle-fill size-4" aria-hidden="true" />
</button>
)}
</div>
<div className="max-h-64 overflow-y-auto rounded-lg border border-divider-subtle bg-background-default">
{isLoading
? <SourceAppSkeleton />
: filteredApps.length === 0
? (
<div className="px-4 py-10 text-center system-sm-regular text-text-tertiary">
{t('createGuide.source.empty')}
</div>
)
: (
<div>
{filteredApps.map(app => (
<SourceAppOption
key={app.id}
app={app}
selected={effectiveSelectedAppId === app.id}
onSelect={() => onSelectApp(app)}
/>
))}
</div>
)}
</div>
</div>
</StepShell>
)
}

View File

@ -1,219 +0,0 @@
'use client'
import type {
CredentialSlot,
} from '@dify/contracts/enterprise/types.gen'
import type { BindingSelections, EnvironmentOption } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import {
RuntimeCredentialBindingsPanel,
} from '../components/runtime-credential-bindings'
import { environmentBackend, environmentMode, environmentName } from '../environment'
import { StepShell } from './layout'
const targetEnvironmentSkeletonKeys = ['first-target-environment', 'second-target-environment']
const targetBindingSkeletonKeys = ['first-target-binding', 'second-target-binding']
function EnvironmentOptionRow({ environment, selected, onSelect }: {
environment: EnvironmentOption
selected: boolean
onSelect: () => void
}) {
const { t } = useTranslation('deployments')
const mode = environmentMode(environment)
return (
<label
className={cn(
'flex cursor-pointer items-center gap-3 rounded-xl border p-3',
selected
? 'border-state-accent-solid bg-state-accent-hover shadow-xs'
: 'border-components-option-card-option-border bg-components-option-card-option-bg hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
)}
>
<input
type="radio"
name="target-environment"
checked={selected}
onChange={onSelect}
className="size-4 shrink-0 accent-primary-600"
/>
<span className="flex min-w-0 grow flex-col gap-1">
<span className={cn('truncate system-sm-semibold', selected ? 'text-text-accent' : 'text-text-primary')}>{environmentName(environment)}</span>
<span className={cn('flex flex-wrap items-center gap-1.5 system-xs-regular', selected ? 'text-text-secondary' : 'text-text-tertiary')}>
<span>{t(mode === 'isolated' ? 'mode.isolated' : 'mode.shared')}</span>
<span>{environmentBackend(environment)}</span>
</span>
</span>
</label>
)
}
function TargetEnvironmentSkeleton() {
return (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{targetEnvironmentSkeletonKeys.map(key => (
<SkeletonRow key={key} className="h-17 rounded-xl border border-divider-subtle px-3 py-3">
<SkeletonRectangle className="my-0 size-4 animate-pulse rounded-full" />
<div className="flex min-w-0 grow flex-col gap-1.5">
<SkeletonRectangle className="my-0 h-3.5 w-1/2 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-2/3 animate-pulse" />
</div>
</SkeletonRow>
))}
</div>
)
}
function TargetBindingSkeleton() {
return (
<div className="border-t border-divider-subtle">
{targetBindingSkeletonKeys.map(key => (
<SkeletonRow key={key} className="h-15 px-3 py-3">
<div className="flex min-w-0 grow flex-col gap-1.5">
<SkeletonRectangle className="my-0 h-3.5 w-1/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-1/2 animate-pulse" />
</div>
<SkeletonRectangle className="my-0 h-8 w-48 animate-pulse rounded-lg" />
</SkeletonRow>
))}
</div>
)
}
function TargetStep({
environments,
bindingSlots,
selectedEnvironmentId,
bindingSelections,
isEnvironmentLoading,
isEnvironmentError,
isBindingLoading,
isBindingError,
onSelectEnvironment,
onSelectBinding,
}: {
environments: EnvironmentOption[]
bindingSlots: CredentialSlot[]
selectedEnvironmentId: string
bindingSelections: BindingSelections
isEnvironmentLoading: boolean
isEnvironmentError: boolean
isBindingLoading: boolean
isBindingError: boolean
onSelectEnvironment: (environmentId: string) => void
onSelectBinding: (slot: string, value: string) => void
}) {
const { t } = useTranslation('deployments')
const hasEnvironmentOptions = environments.length > 0
return (
<StepShell
title={t('createGuide.target.title')}
description={t('createGuide.target.description')}
hideHeader
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('createGuide.target.environment')}</div>
{hasEnvironmentOptions
? (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{environments.map(environment => (
<EnvironmentOptionRow
key={environment.id}
environment={environment}
selected={selectedEnvironmentId === environment.id}
onSelect={() => onSelectEnvironment(environment.id)}
/>
))}
</div>
)
: isEnvironmentLoading
? <TargetEnvironmentSkeleton />
: (
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{isEnvironmentError
? t('createGuide.target.loadEnvironmentsFailed')
: t('createGuide.target.noEnvironmentOptions')}
</div>
)}
</div>
{isBindingLoading || isBindingError
? (
<div className="overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg">
<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('createGuide.target.bindings')}</div>
<span className="system-xs-regular text-text-quaternary">{t('createGuide.target.bindingHint')}</span>
</div>
{isBindingLoading
? <TargetBindingSkeleton />
: (
<div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{t('createGuide.target.loadBindingsFailed')}
</div>
)}
</div>
)
: (
<RuntimeCredentialBindingsPanel
slots={bindingSlots}
selections={bindingSelections}
title={t('createGuide.target.bindings')}
hint={t('createGuide.target.bindingHint')}
requiredLabel={t('createGuide.target.required')}
noBindingRequiredLabel={t('createGuide.target.noBindingRequired')}
noCredentialCandidatesLabel={t('createGuide.target.noCredentialCandidates')}
selectCredentialLabel={t('createGuide.target.selectCredential')}
missingRequiredLabel={t('createGuide.target.missingRequiredBinding')}
bindingCountLabel={t('createGuide.target.bindingCount', { count: bindingSlots.length })}
onChange={onSelectBinding}
className="border-components-option-card-option-border bg-components-option-card-option-bg"
/>
)}
</div>
</StepShell>
)
}
export function TargetReviewSections({
bindingSelections,
bindingSlots,
environments,
isBindingError,
isBindingLoading,
isEnvironmentError,
isEnvironmentLoading,
onSelectBinding,
onSelectEnvironment,
selectedEnvironmentId,
}: {
bindingSelections: BindingSelections
bindingSlots: CredentialSlot[]
environments: EnvironmentOption[]
isBindingError: boolean
isBindingLoading: boolean
isEnvironmentError: boolean
isEnvironmentLoading: boolean
onSelectBinding: (slot: string, value: string) => void
onSelectEnvironment: (environmentId: string) => void
selectedEnvironmentId: string
}) {
return (
<TargetStep
environments={environments}
bindingSlots={bindingSlots}
selectedEnvironmentId={selectedEnvironmentId}
bindingSelections={bindingSelections}
isEnvironmentLoading={isEnvironmentLoading}
isEnvironmentError={isEnvironmentError}
isBindingLoading={isBindingLoading}
isBindingError={isBindingError}
onSelectEnvironment={onSelectEnvironment}
onSelectBinding={onSelectBinding}
/>
)
}

View File

@ -1,9 +0,0 @@
import type {
Environment,
} from '@dify/contracts/enterprise/types.gen'
import type { RuntimeCredentialBindingSelections } from '../components/runtime-credential-bindings-utils'
export type GuideMethod = 'bindApp' | 'importDsl'
export type GuideStep = 'source' | 'release' | 'target'
export type EnvironmentOption = Environment & { id: string }
export type BindingSelections = RuntimeCredentialBindingSelections

View File

@ -1,409 +0,0 @@
'use client'
import type {
Environment,
} from '@dify/contracts/enterprise/types.gen'
import type {
BindingSelections,
EnvironmentOption,
GuideMethod,
GuideStep,
} from './types'
import type { App } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query'
import { load as yamlLoad } from 'js-yaml'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import {
hasMissingRequiredRuntimeCredentialBinding,
runtimeCredentialSlotKey,
selectedDeploymentRuntimeCredentials,
selectedRuntimeCredentialSelections,
} from '../components/runtime-credential-bindings-utils'
import { SOURCE_APPS_PAGE_SIZE } from '../data'
import { createDeploymentIdempotencyKey } from '../idempotency'
type DslMetadata = {
app?: {
name?: unknown
}
}
function hasEnvironmentId(environment?: Environment): environment is EnvironmentOption {
return Boolean(environment?.id)
}
function encodeUtf8Base64(value: string) {
const bytes = new TextEncoder().encode(value)
const chunkSize = 0x8000
const chunks: string[] = []
for (let offset = 0; offset < bytes.length; offset += chunkSize)
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)))
return btoa(chunks.join(''))
}
function dslAppName(content: string) {
try {
const parsed = yamlLoad(content) as DslMetadata | undefined
const name = parsed?.app?.name
return typeof name === 'string' ? name.trim() : ''
}
catch {
return ''
}
}
export function useCreateDeploymentGuide() {
const { t } = useTranslation('deployments')
const router = useRouter()
const createInitialDeploymentFromSourceApp = useMutation(consoleQuery.enterprise.deploymentService.createInitialDeploymentFromSourceApp.mutationOptions())
const createInitialDeploymentFromDsl = useMutation(consoleQuery.enterprise.deploymentService.createInitialDeploymentFromDsl.mutationOptions())
const [step, setStep] = useState<GuideStep>('source')
const [method, setMethod] = useState<GuideMethod>('bindApp')
const [sourceSearchText, setSourceSearchText] = useState('')
const [selectedApp, setSelectedApp] = useState<App>()
const [dslFile, setDslFile] = useState<File>()
const [dslContent, setDslContent] = useState('')
const [dslDefaultAppName, setDslDefaultAppName] = useState('')
const [isReadingDsl, setIsReadingDsl] = useState(false)
const [dslReadError, setDslReadError] = useState(false)
const [instanceName, setInstanceName] = useState('')
const [instanceDescription, setInstanceDescription] = useState('')
const [releaseName, setReleaseName] = useState('')
const [releaseDescription, setReleaseDescription] = useState('')
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState('')
const [manualBindingSelections, setManualBindingSelections] = useState<BindingSelections>({})
const dslReadTokenRef = useRef(0)
const sourceAppsQuery = useInfiniteQuery({
...consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
page: Number(pageParam),
limit: SOURCE_APPS_PAGE_SIZE,
name: sourceSearchText,
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
})
const sourceApps = sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []
const effectiveSelectedApp = selectedApp ?? sourceApps[0]
const hasDslContent = Boolean(dslContent.trim())
const encodedDslContent = hasDslContent ? encodeUtf8Base64(dslContent) : ''
const shouldResolveDeploymentTarget = step === 'target'
const shouldLoadSourceDeploymentTarget = method === 'bindApp' && Boolean(effectiveSelectedApp?.id) && shouldResolveDeploymentTarget
const shouldLoadDslDeploymentTarget = method === 'importDsl' && hasDslContent && shouldResolveDeploymentTarget
const shouldLoadDeploymentTarget = shouldLoadSourceDeploymentTarget || shouldLoadDslDeploymentTarget
const deployableEnvironmentsQuery = useQuery(consoleQuery.enterprise.environmentService.listDeployableEnvironments.queryOptions({
input: {
query: {},
},
enabled: shouldLoadDeploymentTarget,
}))
const sourceDeploymentOptionsQuery = useQuery(consoleQuery.enterprise.releaseService.getDeploymentOptionsFromSourceApp.queryOptions({
input: {
body: {
sourceAppId: effectiveSelectedApp?.id ?? '',
},
},
enabled: shouldLoadSourceDeploymentTarget,
}))
const dslDeploymentOptionsQuery = useQuery(consoleQuery.enterprise.releaseService.getDeploymentOptionsFromDsl.queryOptions({
input: {
body: {
dsl: encodedDslContent,
},
},
enabled: shouldLoadDslDeploymentTarget,
}))
const deploymentOptionsQuery = method === 'importDsl' ? dslDeploymentOptionsQuery : sourceDeploymentOptionsQuery
const deploymentOptions = deploymentOptionsQuery.data?.options
const environments = shouldLoadDeploymentTarget
? deployableEnvironmentsQuery.data?.data?.filter(hasEnvironmentId) ?? []
: []
const bindingSlots = shouldLoadDeploymentTarget
? deploymentOptions?.credentialSlots?.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
: []
const effectiveSelectedEnvironmentId = selectedEnvironmentId || environments[0]?.id || ''
const selectedEnvironment = environments.find(env => env.id === effectiveSelectedEnvironmentId) ?? environments[0]
const bindingSelections = selectedRuntimeCredentialSelections(bindingSlots, manualBindingSelections)
const requiredBindingsReady = bindingSlots.every(slot => !hasMissingRequiredRuntimeCredentialBinding(slot, bindingSelections[runtimeCredentialSlotKey(slot)]))
const isEnvironmentLoading = shouldLoadDeploymentTarget && (deployableEnvironmentsQuery.isLoading || (deployableEnvironmentsQuery.isFetching && !deployableEnvironmentsQuery.data))
const isBindingLoading = shouldLoadDeploymentTarget && (deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data))
const isDeploying = createInitialDeploymentFromSourceApp.isPending || createInitialDeploymentFromDsl.isPending
const sourceName = method === 'importDsl'
? dslDefaultAppName || t('createGuide.dsl.defaultAppName')
: method === 'bindApp'
? effectiveSelectedApp?.name ?? ''
: ''
const defaultedReleaseName = t('createGuide.release.defaultName')
const submittedInstanceName = instanceName.trim()
const submittedReleaseName = releaseName.trim()
const submittedReleaseDescription = releaseDescription.trim()
const isSourceReady = Boolean(method && (method === 'importDsl' ? hasDslContent && !isReadingDsl && !dslReadError : effectiveSelectedApp?.id))
const isInitialReleaseReady = Boolean(isSourceReady && submittedInstanceName && submittedReleaseName)
const showTargetConfiguration = Boolean(method && step === 'target')
function selectMethod(nextMethod: GuideMethod) {
setMethod(nextMethod)
setSelectedEnvironmentId('')
setManualBindingSelections({})
}
function handleDslFileChange(file?: File) {
const readToken = dslReadTokenRef.current + 1
dslReadTokenRef.current = readToken
setDslFile(file)
setDslContent('')
setDslDefaultAppName('')
setDslReadError(false)
setSelectedEnvironmentId('')
setManualBindingSelections({})
if (!file) {
setIsReadingDsl(false)
return
}
setIsReadingDsl(true)
void file.text()
.then((content) => {
if (dslReadTokenRef.current !== readToken)
return
setDslContent(content)
setDslDefaultAppName(dslAppName(content))
})
.catch(() => {
if (dslReadTokenRef.current !== readToken)
return
setDslReadError(true)
})
.finally(() => {
if (dslReadTokenRef.current !== readToken)
return
setIsReadingDsl(false)
})
}
function handleSelectMethod(nextMethod: GuideMethod) {
selectMethod(nextMethod)
setStep('source')
}
function canContinueCurrentStep() {
if (step === 'source')
return isSourceReady
if (step === 'release') {
return isInitialReleaseReady
}
if (step === 'target') {
const deploymentTargetReady = shouldLoadDeploymentTarget
&& !isEnvironmentLoading
&& !deployableEnvironmentsQuery.isError
&& !isBindingLoading
&& !deploymentOptionsQuery.isError
return Boolean(
selectedEnvironment?.id
&& deploymentTargetReady
&& requiredBindingsReady
&& isInitialReleaseReady,
)
}
return false
}
function handleBack() {
if (isDeploying)
return
if (step === 'release')
setStep('source')
else if (step === 'target')
setStep('release')
}
async function createReleaseArtifactsAndContinue() {
if (method === 'bindApp' && (!effectiveSelectedApp?.id || isDeploying))
return
if (method === 'importDsl' && (!hasDslContent || isReadingDsl || dslReadError || isDeploying))
return
setSelectedEnvironmentId('')
setManualBindingSelections({})
setStep('target')
}
function applyReleaseDefaults() {
const nextInstanceName = sourceName.trim()
if (!instanceName.trim() && nextInstanceName)
setInstanceName(nextInstanceName)
if (!releaseName.trim())
setReleaseName(defaultedReleaseName)
}
async function createDeploymentAndRelease({ deployToEnvironment }: {
deployToEnvironment: boolean
}) {
if (isDeploying || !isInitialReleaseReady)
return
if (deployToEnvironment && !selectedEnvironment?.id)
return
if (method === 'bindApp' && !effectiveSelectedApp?.id)
return
if (method === 'importDsl' && !hasDslContent)
return
try {
if (deployToEnvironment) {
const missingRequiredBinding = bindingSlots.some(slot => hasMissingRequiredRuntimeCredentialBinding(slot, bindingSelections[runtimeCredentialSlotKey(slot)]))
if (missingRequiredBinding)
throw new Error('Missing required deployment binding.')
}
const idempotencyKey = createDeploymentIdempotencyKey()
const response = method === 'importDsl'
? await createInitialDeploymentFromDsl.mutateAsync({
body: {
dsl: encodedDslContent,
environmentId: deployToEnvironment ? selectedEnvironment?.id : undefined,
appInstanceName: submittedInstanceName,
appInstanceDescription: instanceDescription.trim() || undefined,
releaseName: submittedReleaseName,
releaseDescription: submittedReleaseDescription || undefined,
credentials: deployToEnvironment ? selectedDeploymentRuntimeCredentials(bindingSlots, bindingSelections) : undefined,
idempotencyKey,
expectedDslDigest: deployToEnvironment ? deploymentOptions?.dslDigest : undefined,
},
})
: effectiveSelectedApp?.id
? await createInitialDeploymentFromSourceApp.mutateAsync({
body: {
sourceAppId: effectiveSelectedApp.id,
environmentId: deployToEnvironment ? selectedEnvironment?.id : undefined,
appInstanceName: submittedInstanceName,
appInstanceDescription: instanceDescription.trim() || undefined,
releaseName: submittedReleaseName,
releaseDescription: submittedReleaseDescription || undefined,
credentials: deployToEnvironment ? selectedDeploymentRuntimeCredentials(bindingSlots, bindingSelections) : undefined,
idempotencyKey,
expectedDslDigest: deployToEnvironment ? deploymentOptions?.dslDigest : undefined,
},
})
: undefined
const appInstanceId = response?.appInstance?.id ?? response?.release?.appInstanceId
if (!appInstanceId)
throw new Error('Create initial deployment did not return an app instance.')
router.push(`/deployments/${appInstanceId}/overview`)
}
catch {
toast.error(t(deployToEnvironment ? 'createGuide.errors.deployFailed' : 'createGuide.errors.createReleaseFailed'))
}
}
async function handleDeploy() {
await createDeploymentAndRelease({ deployToEnvironment: true })
}
async function handleSkipDeployment() {
await createDeploymentAndRelease({ deployToEnvironment: false })
}
function handlePrimaryAction() {
if (!canContinueCurrentStep())
return
if (step === 'source') {
if (method === 'bindApp' && effectiveSelectedApp)
setSelectedApp(effectiveSelectedApp)
applyReleaseDefaults()
setStep('release')
return
}
if (step === 'release') {
if (method === 'bindApp' && effectiveSelectedApp)
setSelectedApp(effectiveSelectedApp)
void createReleaseArtifactsAndContinue()
return
}
if (step === 'target') {
void handleDeploy()
}
}
return {
canContinue: canContinueCurrentStep(),
canSkipDeployment: Boolean(step === 'target' && isInitialReleaseReady),
creationSectionsProps: {
defaultedReleaseName,
dslFile,
dslReadError,
instanceDescription,
instanceName,
isReadingDsl,
method,
onDslFileChange: handleDslFileChange,
onInstanceDescriptionChange: (value: string) => {
setInstanceDescription(value)
setStep('release')
},
onInstanceNameChange: (value: string) => {
setInstanceName(value)
setStep('release')
},
onReleaseDescriptionChange: (value: string) => {
setReleaseDescription(value)
setStep('release')
},
onReleaseNameChange: (value: string) => {
setReleaseName(value)
setStep('release')
},
onSearchTextChange: setSourceSearchText,
onSelectMethod: handleSelectMethod,
onSelectSourceApp: (app: App) => {
setSelectedApp(app)
},
releaseDescription,
releaseName,
selectedApp: effectiveSelectedApp,
sourceApps,
sourceAppsLoading: sourceAppsQuery.isLoading || (sourceAppsQuery.isFetching && sourceApps.length === 0),
sourceName,
sourceSearchText,
stage: step === 'release' ? 'release' as const : 'source' as const,
},
handleBack,
handlePrimaryAction,
handleSkipDeployment,
isDeploying,
showTargetConfiguration,
step,
targetReviewSectionsProps: {
bindingSelections,
bindingSlots,
environments,
isBindingError: deploymentOptionsQuery.isError,
isBindingLoading,
isEnvironmentError: deployableEnvironmentsQuery.isError,
isEnvironmentLoading,
onSelectBinding: (slot: string, value: string) => {
setManualBindingSelections(prev => ({ ...prev, [slot]: value }))
},
onSelectEnvironment: setSelectedEnvironmentId,
selectedEnvironmentId: effectiveSelectedEnvironmentId,
},
}
}

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,77 +0,0 @@
import type { DeploymentUiStatus } from './runtime-status'
export type DeploymentStatusTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info'
const STATUS_TONE: Record<DeploymentUiStatus, DeploymentStatusTone> = {
deploy_failed: 'danger',
deploying: 'info',
drifted: 'warning',
invalid: 'danger',
not_deployed: 'neutral',
ready: 'success',
unknown: 'neutral',
}
const TONE_CLASS_NAMES: Record<DeploymentStatusTone, { badge: string, dot: string, icon: string }> = {
danger: {
badge: 'border-util-colors-red-red-200 bg-util-colors-red-red-50 text-util-colors-red-red-700',
dot: 'bg-util-colors-red-red-500',
icon: 'text-util-colors-red-red-600',
},
info: {
badge: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700',
dot: 'bg-util-colors-blue-blue-500',
icon: 'text-util-colors-blue-blue-600',
},
neutral: {
badge: 'border-divider-subtle bg-background-section-burn text-text-tertiary',
dot: 'bg-text-quaternary',
icon: 'text-text-tertiary',
},
success: {
badge: 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700',
dot: 'bg-util-colors-green-green-500',
icon: 'text-util-colors-green-green-600',
},
warning: {
badge: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
dot: 'bg-util-colors-warning-warning-500',
icon: 'text-util-colors-warning-warning-600',
},
}
const STATUS_LABEL_KEYS = {
deploy_failed: 'status.deployFailed',
deploying: 'status.deploying',
drifted: 'status.drifted',
invalid: 'status.invalid',
not_deployed: 'status.notDeployed',
ready: 'status.ready',
unknown: 'status.unknown',
} as const satisfies Record<DeploymentUiStatus, string>
const STATUS_ICON_CLASS_NAMES: Record<DeploymentUiStatus, string> = {
deploy_failed: 'i-ri-alert-line',
deploying: 'i-ri-loader-4-line animate-spin motion-reduce:animate-none',
drifted: 'i-ri-loop-left-line',
invalid: 'i-ri-error-warning-line',
not_deployed: 'i-ri-circle-line',
ready: 'i-ri-check-line',
unknown: 'i-ri-question-line',
}
export function deploymentStatusLabelKey(status: DeploymentUiStatus) {
return STATUS_LABEL_KEYS[status]
}
export function deploymentStatusTone(status: DeploymentUiStatus) {
return STATUS_TONE[status]
}
export function deploymentStatusToneClassNames(status: DeploymentUiStatus) {
return TONE_CLASS_NAMES[deploymentStatusTone(status)]
}
export function deploymentStatusIconClassName(status: DeploymentUiStatus) {
return STATUS_ICON_CLASS_NAMES[status]
}

View File

@ -1,74 +0,0 @@
'use client'
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import type { DeploymentUiStatus } from './runtime-status'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import {
deploymentStatusIconClassName,
deploymentStatusLabelKey,
deploymentStatusToneClassNames,
} from './deployment-ui-utils'
import { environmentName } from './environment'
import { deploymentStatus } from './runtime-status'
export function DeploymentStatusBadge({
status,
className,
compact,
}: {
status: DeploymentUiStatus
className?: string
compact?: boolean
}) {
const { t } = useTranslation('deployments')
const toneClassNames = deploymentStatusToneClassNames(status)
return (
<span
className={cn(
'inline-flex h-6 max-w-full items-center gap-1.5 rounded-md border px-2 system-xs-medium',
toneClassNames.badge,
className,
)}
>
<span
aria-hidden
className={cn('size-3.5 shrink-0', deploymentStatusIconClassName(status), toneClassNames.icon)}
/>
{!compact && <span className="truncate">{t(deploymentStatusLabelKey(status))}</span>}
</span>
)
}
export function EnvironmentDeploymentBadge({
row,
className,
showStatus = true,
}: {
row: EnvironmentDeployment
className?: string
showStatus?: boolean
}) {
const { t } = useTranslation('deployments')
const name = environmentName(row.environment)
const status = deploymentStatus(row)
const toneClassNames = deploymentStatusToneClassNames(status)
const label = `${name} · ${t(deploymentStatusLabelKey(status))}`
const visibleLabel = showStatus ? label : name
return (
<span
title={label}
aria-label={label}
className={cn(
'inline-flex h-6 max-w-full cursor-default items-center gap-1.5 rounded-md border px-2 system-xs-medium',
toneClassNames.badge,
className,
)}
>
<span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', toneClassNames.dot, status === 'deploying' && 'animate-pulse')} />
<span className="truncate">{visibleLabel}</span>
</span>
)
}

View File

@ -1,15 +0,0 @@
'use client'
import { AccessChannelsSection } from './settings-tab/access/channels-section'
import { AccessPermissionsSection } from './settings-tab/access/permissions-section'
export function AccessTab({ appInstanceId }: {
appInstanceId: string
}) {
return (
<div className="flex w-full min-w-0 flex-col gap-y-5 px-6 py-6 sm:py-8">
<AccessPermissionsSection appInstanceId={appInstanceId} />
<AccessChannelsSection appInstanceId={appInstanceId} />
</div>
)
}

View File

@ -1,189 +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'
showDivider?: boolean
}
type DetailEmptyStateProps = {
icon: string
title: ReactNode
description?: ReactNode
action?: ReactNode
variant?: 'list' | 'section'
className?: string
}
type DetailNoticeStateProps = {
children: ReactNode
icon?: string
className?: string
}
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 DetailEmptyState({
icon,
title,
description,
action,
variant = 'list',
className,
}: DetailEmptyStateProps) {
const isList = variant === 'list'
return (
<div
className={cn(
'flex flex-col items-center justify-center border-dashed text-center',
isList
? 'min-h-60 border-y border-divider-subtle px-4 py-12'
: 'min-h-36 rounded-lg border border-divider-subtle bg-background-default-subtle px-6 py-8',
className,
)}
>
<span
className={cn(
'mb-4 flex items-center justify-center border border-components-panel-border bg-background-default-subtle text-text-tertiary',
isList ? 'size-11 rounded-xl' : 'size-10 rounded-lg bg-background-section-burn',
)}
>
<span className={cn(icon, isList ? 'size-5' : 'size-4.5')} aria-hidden="true" />
</span>
<div className={cn(isList ? 'system-md-semibold text-text-primary' : 'system-sm-medium text-text-secondary')}>
{title}
</div>
{description && (
<p className={cn('mt-1 max-w-120 text-text-tertiary', isList ? 'system-sm-regular' : 'system-xs-regular')}>
{description}
</p>
)}
{action && (
<div className={isList ? 'mt-5' : 'mt-4'}>
{action}
</div>
)}
</div>
)
}
export function DetailNoticeState({
children,
icon = 'i-ri-information-line',
className,
}: DetailNoticeStateProps) {
return (
<div className={cn('flex min-h-9 items-start gap-1.5 rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-2 system-xs-regular text-text-tertiary', className)}>
<span className={cn(icon, 'mt-0.5 size-3.5 shrink-0 text-text-quaternary')} aria-hidden="true" />
<span className="min-w-0">{children}</span>
</div>
)
}
export function Section({
title,
description,
action,
children,
layout = 'block',
tone = 'default',
showDivider = true,
}: SectionProps) {
const hasAction = Boolean(action)
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={cn('py-4 first:pt-0 last:pb-0', showDivider && 'border-b border-divider-subtle last:border-b-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">
{hasAction
? (
<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={cn('py-6 first:pt-0 last:pb-0', showDivider && 'border-b border-divider-subtle last:border-b-0')}>
<div className="mb-3 flex min-w-0 flex-col">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
<div className={titleClassName}>
{title}
</div>
{hasAction && (
<div className="shrink-0">
{action}
</div>
)}
</div>
{description && (
<p className={cn(descriptionClassName, 'max-w-150')}>
{description}
</p>
)}
</div>
<div className="min-w-0">
{children}
</div>
</section>
)
}

View File

@ -1,154 +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, hasRuntimeInstanceDeployment } from '../runtime-status'
import { openDeployDrawerAtom } from '../store'
import {
DetailEmptyState,
DetailListState,
} from './common'
import { DeploymentEnvironmentList } from './deploy-tab/deployment-environment-list'
import {
DetailTable,
DetailTableBody,
DetailTableCard,
DetailTableCardList,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from './table'
import {
DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from './table-styles'
export 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 })}
>
<span className="i-ri-rocket-line size-4 shrink-0" aria-hidden="true" />
{t('deployTab.newDeployment')}
</Button>
)
}
const DEPLOYMENT_TABLE_ROW_SKELETON_KEYS = ['production', 'staging']
function DeploymentEnvironmentListSkeleton() {
const { t } = useTranslation('deployments')
return (
<>
<DetailTableCardList className="pc:hidden">
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
<DetailTableCard key={key}>
<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>
</DetailTableCard>
))}
</DetailTableCardList>
<div className="hidden pc:block">
<DetailTable>
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('deployTab.col.environment')}</DetailTableHead>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.status}>{t('deployTab.col.status')}</DetailTableHead>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.currentRelease}>{t('deployTab.col.currentRelease')}</DetailTableHead>
<DetailTableHead className={`${DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.actions} text-right`}>{t('deployTab.col.actions')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
<DetailTableRow key={key}>
<DetailTableCell>
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
</DetailTableCell>
<DetailTableCell>
<SkeletonRectangle className="my-0 h-4 w-18 animate-pulse rounded-md" />
</DetailTableCell>
<DetailTableCell>
<SkeletonRow className="gap-2">
<SkeletonRectangle className="h-3 w-16 animate-pulse" />
<SkeletonRectangle className="h-2.5 w-18 animate-pulse" />
</SkeletonRow>
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</DetailTableCell>
</DetailTableRow>
))}
</DetailTableBody>
</DetailTable>
</div>
</>
)
}
export function DeployTab({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
refetchInterval: query => deploymentStatusPollingInterval(query.state.data),
}))
const environmentDeployments = environmentDeploymentsQuery.data
const rows = environmentDeployments?.data?.filter(hasRuntimeInstanceDeployment) ?? []
const isLoading = environmentDeploymentsQuery.isLoading
const hasError = environmentDeploymentsQuery.isError
return (
<div className="flex w-full min-w-0 flex-col gap-4 px-6 py-6">
{!isLoading && !hasError && rows.length > 0 && (
<div className="flex justify-end">
<NewDeploymentButton appInstanceId={appInstanceId} />
</div>
)}
{isLoading
? <DeploymentEnvironmentListSkeleton />
: hasError
? <DetailListState>{t('common.loadFailed')}</DetailListState>
: rows.length === 0
? (
<DetailEmptyState
icon="i-ri-server-line"
title={t('deployTab.emptyTitle')}
description={t('deployTab.emptyDescription')}
action={<NewDeploymentButton appInstanceId={appInstanceId} />}
/>
)
: (
<DeploymentEnvironmentList appInstanceId={appInstanceId} rows={rows} />
)}
</div>
)
}

View File

@ -1,417 +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,
DropdownMenuSeparator,
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 { createDeploymentIdempotencyKey } from '../../idempotency'
import { releaseCommit, releaseLabel } from '../../release'
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import {
DetailTable,
DetailTableBody,
DetailTableCard,
DetailTableCardList,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../table'
import {
DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES,
DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME,
} from '../table-styles'
import { DeploymentStatusSummary } from './deployment-status-summary'
function EnvironmentSummary({ environment }: {
environment: EnvironmentDeployment['environment']
}) {
return (
<span className="block truncate text-text-primary">
{environmentName(environment)}
</span>
)
}
function CurrentReleaseSummary({ release }: {
release: EnvironmentDeployment['currentRelease']
}) {
if (!release?.id && !release?.name)
return <span className="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 text-text-primary">
{releaseLabel(release)}
</span>
{commit !== '—' && (
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">
{commit}
</span>
)}
</div>
</div>
)
}
type DeploymentErrorDetailsProps = {
error?: EnvironmentDeployment['error']
}
function DeploymentErrorDetails({ error }: DeploymentErrorDetailsProps) {
const { t } = useTranslation('deployments')
const message = error?.message?.trim() || t('deployTab.panel.unknownError')
const metadata = [
error?.phase ? { label: t('deployTab.errorPhase'), value: error.phase } : undefined,
error?.code ? { label: t('deployTab.errorCode'), value: error.code } : undefined,
].filter((item): item is { label: string, value: string } => Boolean(item))
return (
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle p-3">
<div className="system-xs-medium-uppercase text-text-tertiary">
{t('deployTab.errorMessage')}
</div>
<div className="mt-1 whitespace-pre-wrap break-words system-sm-regular text-text-secondary">
{message}
</div>
{metadata.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{metadata.map(item => (
<span
key={item.label}
className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-divider-subtle bg-background-default px-2 py-1 system-xs-regular text-text-tertiary"
>
<span className="shrink-0 text-text-quaternary">{item.label}</span>
<span className="truncate font-mono text-text-secondary">{item.value}</span>
</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.deploymentService.undeploy.mutationOptions())
const isUndeployed = isUndeployedDeploymentRow(row)
const status = deploymentStatus(row)
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
const [showErrorDetail, setShowErrorDetail] = 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 isDeployFailed = status === 'deploy_failed'
const failedReleaseId = row.desiredRelease?.id || row.currentRelease?.id
const deployActionLabel = isUndeployed
? t('deployDrawer.deploy')
: t('deployTab.deployOtherVersion')
function handleDeployAction(releaseId?: string) {
openDeployDrawer({ appInstanceId, environmentId: envId, releaseId })
setActionsOpen(false)
}
function handleViewError() {
setActionsOpen(false)
setShowErrorDetail(true)
}
function handleUndeploy() {
if (!envId || undeployInFlightRef.current)
return
undeployInFlightRef.current = true
setIsUndeploying(true)
undeployDeployment.mutate(
{
params: { appInstanceId, environmentId: envId },
body: {
appInstanceId,
environmentId: envId,
idempotencyKey: createDeploymentIdempotencyKey(),
},
},
{
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_TABLE_ACTION_TRIGGER_CLASS_NAME}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
{actionsOpen && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-44">
{isDeployFailed
? (
<>
<DropdownMenuItem
className="gap-2 px-3"
onClick={handleViewError}
>
<span aria-hidden className="i-ri-error-warning-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{t('deployTab.viewError')}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => handleDeployAction(failedReleaseId)}
>
<span aria-hidden className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{failedReleaseId ? t('deployTab.retry') : t('deployTab.deployOtherVersion')}
</span>
</DropdownMenuItem>
</>
)
: (
<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 && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={undeployActionDisabled}
aria-disabled={undeployActionDisabled}
className={cn(
'gap-2 px-3',
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>
)}
{isDeployFailed && (
<AlertDialog open={showErrorDetail} onOpenChange={setShowErrorDetail}>
<AlertDialogContent className="w-120">
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('deployTab.errorDialogTitle', { name: environmentName(row.environment) })}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{t('deployTab.errorDialogDesc')}
</AlertDialogDescription>
<DeploymentErrorDetails error={row.error} />
</div>
<AlertDialogActions className="pt-3">
<AlertDialogCancelButton variant="secondary">
{t('deployTab.closeError')}
</AlertDialogCancelButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)}
{!isUndeployed && !isDeploying && (
<AlertDialog
open={showUndeployConfirm}
onOpenChange={(open) => {
if (isUndeployRequesting)
return
setShowUndeployConfirm(open)
}}
>
<AlertDialogContent className="w-120">
<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-sm-regular text-text-tertiary">
{t('deployTab.undeployConfirmDesc')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-3">
<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
return (
<DetailTableCard>
<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>
</DetailTableCard>
)
}
function DeploymentEnvironmentDesktopRows({ appInstanceId, rows }: {
appInstanceId: string
rows: EnvironmentDeployment[]
}) {
return (
<>
{rows.map((row) => {
const envId = environmentId(row.environment)
return (
<DetailTableRow key={envId}>
<DetailTableCell>
<EnvironmentSummary environment={row.environment} />
</DetailTableCell>
<DetailTableCell>
<DeploymentStatusSummary row={row} />
</DetailTableCell>
<DetailTableCell>
<CurrentReleaseSummary release={row.currentRelease} />
</DetailTableCell>
<DetailTableCell>
<div className="flex min-h-8 justify-end">
<DeploymentRowActions appInstanceId={appInstanceId} envId={envId} row={row} />
</div>
</DetailTableCell>
</DetailTableRow>
)
})}
</>
)
}
export function DeploymentEnvironmentList({ appInstanceId, rows }: {
appInstanceId: string
rows: EnvironmentDeployment[]
}) {
const { t } = useTranslation('deployments')
return (
<>
<DetailTableCardList className="pc:hidden">
{rows.map(row => (
<DeploymentEnvironmentMobileRow
key={environmentId(row.environment)}
appInstanceId={appInstanceId}
row={row}
/>
))}
</DetailTableCardList>
<div className="hidden pc:block">
<DetailTable>
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('deployTab.col.environment')}</DetailTableHead>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.status}>{t('deployTab.col.status')}</DetailTableHead>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.currentRelease}>{t('deployTab.col.currentRelease')}</DetailTableHead>
<DetailTableHead className={`${DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.actions} text-right`}>{t('deployTab.col.actions')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
<DeploymentEnvironmentDesktopRows appInstanceId={appInstanceId} rows={rows} />
</DetailTableBody>
</DetailTable>
</div>
</>
)
}

View File

@ -1,106 +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 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 targetRelease = row.desiredRelease ?? row.currentRelease
const hasTargetRelease = !!(targetRelease?.name || targetRelease?.id)
return (
<span className="inline-flex items-center gap-1.5 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(targetRelease) })
: t('status.deploying')}
</span>
)
}
if (status === 'deploy_failed') {
const hasRunningRelease = !!row.currentRelease?.id
return (
<span className="inline-flex items-center gap-1.5 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 === 'drifted') {
const hasRunningRelease = !!row.currentRelease?.id
return (
<span className="inline-flex items-center gap-1.5 text-util-colors-warning-warning-700">
<StatusIconSlot>
<span className="i-ri-error-warning-line size-3" />
</StatusIconSlot>
{t(hasRunningRelease ? 'deployTab.status.runningOutOfSync' : 'status.drifted')}
</span>
)
}
if (status === 'invalid') {
return (
<span className="inline-flex items-center gap-1.5 text-util-colors-red-red-700">
<StatusIconSlot>
<span className="i-ri-error-warning-line size-3" />
</StatusIconSlot>
{t('status.invalid')}
</span>
)
}
if (status === 'unknown') {
return (
<span className="inline-flex items-center gap-1.5 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 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,265 +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 { 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-server-line', className)} />
}
function DeploySelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-server-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 AccessIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-shield-user-line', className)} />
}
function AccessSelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-shield-user-fill', className)} />
}
function ApiIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-code-s-slash-line', className)} />
}
function ApiSelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-code-s-slash-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: 'instances', icon: DeployIcon, selectedIcon: DeploySelectedIcon },
{ key: 'releases', icon: VersionsIcon, selectedIcon: VersionsSelectedIcon },
{ key: 'access', icon: AccessIcon, selectedIcon: AccessSelectedIcon },
{ key: 'api-tokens', icon: ApiIcon, selectedIcon: ApiSelectedIcon },
{ 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.getAppInstance.queryOptions({
input: {
params: { appInstanceId },
},
}))
const app = overviewQuery.data?.appInstance
const isLoading = !app?.id && overviewQuery.isLoading
const isUnavailable = !app?.id || overviewQuery.isError
const instanceName = app?.name ?? appInstanceId
const appModeLabel = app?.id ? getAppModeLabel(toAppMode(), tCommon) : ''
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=""
background={null}
/>
</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>
</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(
'hidden shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all pc:flex',
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,13 +0,0 @@
'use client'
import { DeveloperApiSection } from './settings-tab/access/developer-api-section'
export function DeveloperApiTab({ appInstanceId }: {
appInstanceId: string
}) {
return (
<div className="flex w-full min-w-0 flex-col gap-y-5 px-6 py-6 sm:py-8">
<DeveloperApiSection appInstanceId={appInstanceId} />
</div>
)
}

View File

@ -1,95 +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 Link from '@/next/link'
import { useSelectedLayoutSegment } from '@/next/navigation'
import { DeployDrawer } from '../components/deploy-drawer'
import { DeploymentSidebar } from './deployment-sidebar'
import { DeveloperApiHeaderActions, DeveloperApiHeaderSwitch } from './settings-tab/access/developer-api-section'
import { INSTANCE_DETAIL_TAB_KEYS, isInstanceDetailTabKey } from './tabs'
import { CreateReleaseControl } from './versions-tab/create-release-control'
function MobileDetailTabs({ appInstanceId, activeTab }: {
appInstanceId: string
activeTab: InstanceDetailTabKey
}) {
const { t } = useTranslation('deployments')
return (
<nav
aria-label={t('detail.mobileTabs')}
className="border-b border-divider-subtle bg-components-panel-bg px-4 pc:hidden"
>
<div className="flex min-w-0 scrollbar-none gap-1 overflow-x-auto py-2">
{INSTANCE_DETAIL_TAB_KEYS.map(tab => (
<Link
key={tab}
href={`/deployments/${appInstanceId}/${tab}`}
className={`inline-flex h-8 shrink-0 items-center rounded-lg px-3 system-sm-medium ${
activeTab === tab
? 'bg-state-accent-hover text-text-accent'
: 'text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary'
}`}
>
{t(`tabs.${tab}.name`)}
</Link>
))}
</div>
</nav>
)
}
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'
const contentMaxWidthClassName = activeTab === 'settings' ? 'max-w-[872px]' : 'max-w-[1120px]'
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 min-h-full w-full ${contentMaxWidthClassName} flex-col`}>
<div className="flex w-full flex-col gap-y-0.5 px-4 pt-3 pb-2 sm:px-6">
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
{activeTab === 'api-tokens' && (
<div className="shrink-0">
<DeveloperApiHeaderSwitch appInstanceId={appInstanceId} />
</div>
)}
</div>
<div className="system-sm-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
</div>
{(activeTab === 'api-tokens' || activeTab === 'releases') && (
<div className="w-full shrink-0 pt-1 sm:w-auto sm:pt-1.5 [&_button]:w-full sm:[&_button]:w-auto">
{activeTab === 'api-tokens'
? <DeveloperApiHeaderActions appInstanceId={appInstanceId} />
: <CreateReleaseControl appInstanceId={appInstanceId} size="medium" />}
</div>
)}
</div>
</div>
<MobileDetailTabs appInstanceId={appInstanceId} activeTab={activeTab} />
{children}
</div>
</div>
</div>
</div>
<DeployDrawer />
</>
)
}

View File

@ -1,139 +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 { AccessStatusSection, AccessStatusSectionSkeleton } from './overview-tab/access-status-section'
import { EnvironmentStrip, EnvironmentStripSkeleton } from './overview-tab/environment-strip'
import { computeOverviewStats } from './overview-tab/overview-drift'
import { ReleaseHero, ReleaseHeroSkeleton } from './overview-tab/release-hero'
const OVERVIEW_RELEASE_WINDOW = 20
function OverviewLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex w-full min-w-0 flex-col gap-6 px-6 py-6">
{children}
</div>
)
}
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>
)
}
function OverviewLoadingSkeleton({ appInstanceId }: {
appInstanceId: string
}) {
return (
<OverviewLayout>
<div className="flex min-w-0 flex-col gap-6">
<ReleaseOverviewSection appInstanceId={appInstanceId}>
<ReleaseHeroSkeleton />
</ReleaseOverviewSection>
<EnvironmentStripSkeleton />
<AccessStatusSectionSkeleton />
</div>
</OverviewLayout>
)
}
export function OverviewTab({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const input = { params: { appInstanceId } }
const instanceQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({ input }))
const runtimeInstancesQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({ input }))
const releasesQuery = useQuery(consoleQuery.enterprise.releaseService.listReleases.queryOptions({
input: {
params: { appInstanceId },
query: { pageNumber: 1, resultsPerPage: OVERVIEW_RELEASE_WINDOW },
},
}))
const accessChannelsQuery = useQuery(consoleQuery.enterprise.accessService.getAccessChannels.queryOptions({ input }))
const instance = instanceQuery.data?.appInstance
if (instanceQuery.isLoading)
return <OverviewLoadingSkeleton appInstanceId={appInstanceId} />
if (instanceQuery.isError) {
return (
<OverviewLayout>
<SectionState>{t('common.loadFailed')}</SectionState>
</OverviewLayout>
)
}
if (!instance?.id) {
return (
<OverviewLayout>
<SectionState>{t('detail.notFound')}</SectionState>
</OverviewLayout>
)
}
if (releasesQuery.isLoading)
return <OverviewLoadingSkeleton appInstanceId={appInstanceId} />
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)
const accessChannels = accessChannelsQuery.data?.accessChannels
return (
<OverviewLayout>
<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}
isLoading={runtimeInstancesQuery.isLoading}
isError={runtimeInstancesQuery.isError}
/>
<AccessStatusSection appInstanceId={appInstanceId} accessChannels={accessChannels} />
</div>
</OverviewLayout>
)
}

View File

@ -1,149 +0,0 @@
'use client'
import type { AccessChannels } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import Link from '@/next/link'
import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME, OVERVIEW_INTERACTIVE_CARD_CLASS_NAME, OVERVIEW_STATUS_BADGE_CLASS_NAME } from './card-styles'
type AccessStatusSectionProps = {
appInstanceId: string
accessChannels?: AccessChannels
apiKeyCount?: number
}
type AccessStatusItem = {
key: 'webapp' | 'cli' | 'api-tokens'
href: string
icon: string
label: string
enabled: boolean
meta: string
}
const ACCESS_STATUS_SKELETON_KEYS = ['webapp', 'cli', 'api-tokens']
export function AccessStatusSection({ appInstanceId, accessChannels, apiKeyCount }: AccessStatusSectionProps) {
const { t } = useTranslation('deployments')
const items: AccessStatusItem[] = [
{
key: 'webapp',
href: `/deployments/${appInstanceId}/access`,
icon: 'i-ri-global-line',
label: t('card.access.webApp'),
enabled: Boolean(accessChannels?.webAppEnabled),
meta: t('overview.accessMeta.webApp'),
},
{
key: 'cli',
href: `/deployments/${appInstanceId}/access`,
icon: 'i-ri-terminal-box-line',
label: t('card.access.cli'),
enabled: Boolean(accessChannels?.webAppEnabled),
meta: t('overview.accessMeta.cli'),
},
{
key: 'api-tokens',
href: `/deployments/${appInstanceId}/api-tokens`,
icon: 'i-ri-code-s-slash-line',
label: t('card.access.api'),
enabled: Boolean(accessChannels?.developerApiEnabled),
meta: accessChannels?.developerApiEnabled && apiKeyCount != null
? t('overview.apiKeysCount', { count: apiKeyCount })
: t('overview.accessMeta.apiTokens'),
},
]
return (
<section className="flex min-w-0 flex-col gap-3">
<h3 className="system-sm-semibold text-text-primary">
{t('overview.accessStatus')}
</h3>
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,220px),1fr))] gap-3">
{items.map(item => (
<Link
key={item.key}
href={item.href}
className={cn(
OVERVIEW_INTERACTIVE_CARD_CLASS_NAME,
'group flex min-h-18 min-w-0 items-start gap-3',
)}
>
<span
aria-hidden
className={OVERVIEW_ICON_CLASS_NAME}
>
<span className={cn('size-4', item.icon)} />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate system-sm-medium text-text-primary">
{item.label}
</span>
<span className="flex shrink-0 items-center gap-2">
<span
className={cn(
OVERVIEW_STATUS_BADGE_CLASS_NAME,
item.enabled
? 'text-util-colors-green-green-700'
: 'text-text-tertiary',
)}
>
<span
aria-hidden
className={cn(
'size-1.5 shrink-0 rounded-full',
item.enabled ? 'bg-util-colors-green-green-500' : 'bg-text-quaternary',
)}
/>
{item.enabled ? t('overview.enabled') : t('overview.disabled')}
</span>
<span
aria-hidden
className="i-ri-arrow-right-line size-4 text-text-quaternary opacity-60 transition group-hover:translate-x-0.5 group-hover:opacity-100 group-focus-visible:translate-x-0.5 group-focus-visible:opacity-100"
/>
</span>
</span>
<span className="truncate text-xs text-text-tertiary">
{item.meta}
</span>
</span>
</Link>
))}
</div>
</section>
)
}
export function AccessStatusSectionSkeleton() {
const { t } = useTranslation('deployments')
return (
<section className="flex min-w-0 flex-col gap-3">
<h3 className="system-sm-semibold text-text-primary">
{t('overview.accessStatus')}
</h3>
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,220px),1fr))] gap-3">
{ACCESS_STATUS_SKELETON_KEYS.map(key => (
<div
key={key}
data-slot="deployment-overview-access-card-skeleton"
className={cn(OVERVIEW_CARD_CLASS_NAME, 'flex min-h-18 min-w-0 items-start gap-3')}
>
<SkeletonRectangle className="my-0 size-8 shrink-0 animate-pulse rounded-lg" />
<span className="flex min-w-0 flex-1 flex-col gap-2">
<span className="flex min-w-0 items-center justify-between gap-3">
<SkeletonRectangle className="my-0 h-3.5 w-20 animate-pulse" />
<SkeletonRectangle className="my-0 h-6 w-14 shrink-0 animate-pulse rounded-md" />
</span>
<SkeletonRectangle className="my-0 h-3 w-4/5 animate-pulse" />
</span>
</div>
))}
</div>
</section>
)
}

View File

@ -1,12 +0,0 @@
import { cn } from '@langgenius/dify-ui/cn'
export const OVERVIEW_CARD_CLASS_NAME = 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4'
export const OVERVIEW_INTERACTIVE_CARD_CLASS_NAME = cn(
OVERVIEW_CARD_CLASS_NAME,
'transition-colors hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg',
)
export const OVERVIEW_ICON_CLASS_NAME = 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary'
export const OVERVIEW_STATUS_BADGE_CLASS_NAME = 'inline-flex h-6 shrink-0 items-center gap-1.5 rounded-md bg-background-section-burn px-2 system-xs-medium'

View File

@ -1,108 +0,0 @@
'use client'
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { environmentId } from '../../environment'
import { hasRuntimeInstanceDeployment } from '../../runtime-status'
import { SectionState } from '../common'
import { OVERVIEW_CARD_CLASS_NAME } from './card-styles'
import { EnvironmentTile } from './environment-tile'
const OVERVIEW_RUNTIME_INSTANCE_LIMIT = 3
type EnvironmentStripProps = {
appInstanceId: string
rows: EnvironmentDeployment[]
releaseRows: Release[]
isLoading: boolean
isError: boolean
}
export function EnvironmentStrip({ appInstanceId, rows, releaseRows, isLoading, isError }: EnvironmentStripProps) {
const { t } = useTranslation('deployments')
const runtimeRows = rows.filter(hasRuntimeInstanceDeployment)
const previewRows = runtimeRows.slice(0, OVERVIEW_RUNTIME_INSTANCE_LIMIT)
return (
<section className="flex flex-col gap-3">
<h3 className="system-sm-semibold text-text-primary">{t('overview.strip.title')}</h3>
{isLoading
? <CardSkeletons />
: isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: rows.length === 0
? <SectionState>{t('overview.strip.empty')}</SectionState>
: runtimeRows.length === 0
? <SectionState>{t('overview.strip.emptyDeployed')}</SectionState>
: (
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,260px),1fr))] gap-3">
{previewRows.map(row => (
<EnvironmentTile
key={environmentId(row.environment)}
appInstanceId={appInstanceId}
row={row}
releaseRows={releaseRows}
/>
))}
</div>
)}
</section>
)
}
const SKELETON_KEYS = ['a', 'b', 'c']
function CardSkeletons() {
return (
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,260px),1fr))] gap-3">
{SKELETON_KEYS.map(key => (
<EnvironmentTileSkeleton key={key} />
))}
</div>
)
}
function EnvironmentTileSkeleton() {
return (
<article
data-slot="deployment-overview-environment-tile-skeleton"
className={cn(OVERVIEW_CARD_CLASS_NAME, 'flex min-h-28 min-w-0 flex-col justify-between gap-4')}
>
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<SkeletonRectangle className="my-0 size-8 shrink-0 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-3.5 w-28 animate-pulse" />
</div>
<SkeletonRow className="my-0 h-6 shrink-0 gap-1.5 rounded-md bg-background-section-burn px-2">
<span className="size-1.5 shrink-0 rounded-full bg-text-quaternary" />
<span className="h-2.5 w-8 rounded-xs bg-text-quaternary opacity-20" />
</SkeletonRow>
</div>
<div className="flex min-w-0 items-end justify-between gap-3">
<div className="min-w-0">
<SkeletonRectangle className="my-0 h-2.5 w-20 animate-pulse" />
<div className="mt-2 flex min-w-0 items-center gap-2">
<SkeletonRectangle className="my-0 h-3.5 w-24 animate-pulse" />
<SkeletonRectangle className="my-0 h-5 w-16 animate-pulse rounded" />
</div>
</div>
<SkeletonRectangle className="my-0 h-8 w-22 shrink-0 animate-pulse rounded-md" />
</div>
</article>
)
}
export function EnvironmentStripSkeleton() {
const { t } = useTranslation('deployments')
return (
<section className="flex flex-col gap-3">
<h3 className="system-sm-semibold text-text-primary">{t('overview.strip.title')}</h3>
<CardSkeletons />
</section>
)
}

View File

@ -1,250 +0,0 @@
'use client'
import type { EnvironmentDeployment, Release } 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 { OVERVIEW_ICON_CLASS_NAME, OVERVIEW_INTERACTIVE_CARD_CLASS_NAME, OVERVIEW_STATUS_BADGE_CLASS_NAME } from './card-styles'
import { computeDrift, latestReleaseId } from './overview-drift'
type EnvironmentTileProps = {
appInstanceId: string
row: EnvironmentDeployment
releaseRows: Release[]
}
type TileKind = 'empty' | 'latest' | 'behind' | 'older' | 'deploying' | 'failed'
type TileConfig = {
kind: TileKind
dotClass: string
statusClass: string
actionClass: string
showRelease: boolean
intent: 'drawer' | 'navigate' | 'disabled'
releaseId?: string
}
export function EnvironmentTile({ appInstanceId, row, releaseRows }: EnvironmentTileProps) {
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 isDisabled = config.intent === 'disabled'
const release = row.currentRelease
const showRelease = config.showRelease && Boolean(release?.id)
const commit = releaseCommit(release)
const tooltip = isDisabled
? t('overview.chip.needsReleaseFirst')
: config.intent === 'navigate'
? t('overview.chip.openInDeployTab')
: undefined
function handleAction() {
if (config.intent === 'disabled')
return
if (config.intent === 'navigate') {
router.push(`/deployments/${appInstanceId}/instances`)
return
}
openDeployDrawer({ appInstanceId, environmentId: envId, releaseId: config.releaseId })
}
return (
<article
data-slot="deployment-overview-environment-tile"
className={cn(OVERVIEW_INTERACTIVE_CARD_CLASS_NAME, 'flex min-h-28 min-w-0 flex-col justify-between gap-4')}
>
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span aria-hidden className={OVERVIEW_ICON_CLASS_NAME}>
<span className="i-ri-server-line size-4" />
</span>
<h4 className="truncate system-sm-medium text-text-primary">
{environmentName(row.environment)}
</h4>
</div>
<StatusSignal config={config} drift={drift} t={t} />
</div>
<div className="flex min-w-0 items-end justify-between gap-3">
<div className="min-w-0">
<div className="system-2xs-medium-uppercase text-text-tertiary">
{t('deployTab.col.currentRelease')}
</div>
<div className="mt-1 flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate system-sm-semibold text-text-primary">
{showRelease ? releaseLabel(release) : '—'}
</span>
{showRelease && commit !== '—' && (
<span className="shrink-0 rounded bg-background-section-burn px-1.5 py-0.5 font-mono system-xs-regular text-text-tertiary">
{commit}
</span>
)}
</div>
</div>
<button
type="button"
disabled={isDisabled}
title={tooltip}
onClick={handleAction}
className={cn(
'inline-flex h-8 max-w-full min-w-0 shrink-0 items-center justify-center rounded-md px-2.5 system-xs-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg',
config.actionClass,
isDisabled && 'cursor-not-allowed opacity-60',
)}
>
<span className="whitespace-nowrap">{renderActionLabel(config.kind, Boolean(currentReleaseId), t)}</span>
</button>
</div>
</article>
)
}
function StatusSignal({ className, config, drift, t }: {
className?: string
config: TileConfig
drift: ReturnType<typeof computeDrift>
t: ReturnType<typeof useTranslation<'deployments'>>['t']
}) {
return (
<span className={cn(OVERVIEW_STATUS_BADGE_CLASS_NAME, config.statusClass, className)}>
<span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', config.dotClass)} />
<span>{renderStatus(config.kind, drift, t)}</span>
</span>
)
}
function resolveConfig({ drift, status, hasAnyRelease, latestId, currentReleaseId }: {
drift: ReturnType<typeof computeDrift>
status: ReturnType<typeof deploymentStatus>
hasAnyRelease: boolean
latestId: string | undefined
currentReleaseId: string | undefined
}): TileConfig {
if (status === 'deploying') {
return {
kind: 'deploying',
dotClass: 'bg-util-colors-blue-blue-500 animate-pulse',
statusClass: 'text-util-colors-blue-blue-700',
actionClass: 'text-text-secondary hover:bg-state-base-hover hover:text-text-primary',
showRelease: true,
intent: 'navigate',
}
}
if (status === 'deploy_failed') {
return {
kind: 'failed',
dotClass: 'bg-util-colors-red-red-500',
statusClass: 'text-util-colors-red-red-700',
actionClass: 'text-primary-600 hover:bg-state-accent-hover',
showRelease: true,
intent: 'drawer',
releaseId: currentReleaseId ?? latestId,
}
}
if (drift.kind === 'undeployed') {
return {
kind: 'empty',
dotClass: 'bg-text-quaternary',
statusClass: 'text-text-tertiary',
actionClass: hasAnyRelease
? 'text-primary-600 hover:bg-state-accent-hover'
: 'text-text-tertiary',
showRelease: false,
intent: hasAnyRelease ? 'drawer' : 'disabled',
releaseId: latestId,
}
}
if (drift.kind === 'up-to-date') {
return {
kind: 'latest',
dotClass: 'bg-util-colors-green-green-500',
statusClass: 'text-util-colors-green-green-700',
actionClass: 'text-text-secondary hover:bg-state-base-hover hover:text-text-primary',
showRelease: true,
intent: 'drawer',
releaseId: currentReleaseId,
}
}
if (drift.kind === 'behind') {
return {
kind: 'behind',
dotClass: 'bg-util-colors-warning-warning-500',
statusClass: 'text-util-colors-warning-warning-700',
actionClass: 'text-primary-600 hover:bg-state-accent-hover',
showRelease: true,
intent: 'drawer',
releaseId: latestId,
}
}
return {
kind: 'older',
dotClass: 'bg-text-tertiary',
statusClass: 'text-text-tertiary',
actionClass: 'text-primary-600 hover:bg-state-accent-hover',
showRelease: true,
intent: 'drawer',
releaseId: latestId,
}
}
function renderActionLabel(
kind: TileKind,
hasCurrentRelease: boolean,
t: ReturnType<typeof useTranslation<'deployments'>>['t'],
): string {
switch (kind) {
case 'empty':
case 'older':
case 'behind':
return t('overview.cardAction.deployLatest')
case 'latest':
return t('overview.cardAction.redeploy')
case 'deploying':
return t('overview.cardAction.viewProgress')
case 'failed':
return hasCurrentRelease
? t('overview.cardAction.redeploy')
: t('overview.cardAction.deployLatest')
}
}
function renderStatus(
kind: TileKind,
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,70 +0,0 @@
import type { EnvironmentDeployment, Release } 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: Release[],
): 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: Release[]): 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: Release[],
): 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,97 +0,0 @@
'use client'
import type { Release } from '@dify/contracts/enterprise/types.gen'
import type { OverviewStats } from './overview-drift'
import { cn } from '@langgenius/dify-ui/cn'
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'
import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME } from './card-styles'
type ReleaseHeroProps = {
appInstanceId: string
latestRelease?: Release
stats: OverviewStats
}
export function ReleaseHero({ appInstanceId, latestRelease, stats }: ReleaseHeroProps) {
const { t } = 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 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 (hasRelease && stats.total === 0)
metaParts.push({ key: 'untargeted', value: t('overview.hero.untargeted') })
return (
<div className={cn(OVERVIEW_CARD_CLASS_NAME, 'flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6')}>
<div className="flex min-w-0 items-start gap-3">
<span aria-hidden className={OVERVIEW_ICON_CLASS_NAME}>
<span className="i-ri-stack-fill size-4" />
</span>
<div className="flex min-w-0 flex-col gap-2">
<h2 className="truncate system-xl-semibold text-text-primary">
{hasRelease ? releaseLabel(latestRelease) : t('overview.hero.empty')}
</h2>
{hasRelease
? 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>
)
: null
: (
<p className="max-w-[640px] system-sm-regular text-text-tertiary">
{t('overview.hero.emptyDescription')}
</p>
)}
</div>
</div>
<div className="shrink-0">
<CreateReleaseControl appInstanceId={appInstanceId} size="medium" />
</div>
</div>
)
}
export function ReleaseHeroSkeleton() {
return (
<div
data-slot="deployment-overview-release-hero-skeleton"
className={cn(OVERVIEW_CARD_CLASS_NAME, 'flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6')}
>
<div className="flex min-w-0 items-start gap-3">
<SkeletonRectangle className="my-0 size-8 shrink-0 animate-pulse rounded-lg" />
<div className="flex min-w-0 flex-col gap-2">
<SkeletonRectangle className="my-0 h-6 w-40 animate-pulse" />
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-1">
<SkeletonRectangle className="my-0 h-3 w-32 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-14 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-28 animate-pulse" />
</div>
</div>
</div>
<div className="shrink-0">
<SkeletonRectangle className="my-0 h-9 w-32 animate-pulse rounded-lg" />
</div>
</div>
)
}

View File

@ -1,369 +0,0 @@
'use client'
import type { AppInstance } 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 { Input } from '@langgenius/dify-ui/input'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
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 { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { Section, SectionState } from './common'
type AppInstanceWithId = AppInstance & { id: string }
const SETTINGS_CONTENT_CLASS_NAME = 'w-full max-w-[640px]'
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={`${SETTINGS_CONTENT_CLASS_NAME} 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="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={`${SETTINGS_CONTENT_CLASS_NAME} flex min-h-9 items-center`}>
<SkeletonRectangle className="my-0 h-8 w-18 animate-pulse rounded-lg" />
</div>
)
}
function DeleteInstanceButton({
app,
}: {
app: AppInstanceWithId
}) {
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 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="secondary"
tone="destructive"
disabled={deleteInstance.isPending}
onClick={() => setShowDeleteConfirm(true)}
>
{t('settings.delete')}
</Button>
<AlertDialog
open={showDeleteConfirm}
onOpenChange={(open) => {
if (!open && !deleteInstance.isPending)
setShowDeleteConfirm(false)
}}
>
<AlertDialogContent className="w-120">
<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-sm-regular text-text-tertiary">
{t('settings.deleteConfirmDesc', { name: appName })}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-3">
<AlertDialogCancelButton variant="secondary" disabled={deleteInstance.isPending}>
{t('createModal.cancel')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={deleteInstance.isPending} onClick={handleDelete}>
{t('settings.delete')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
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 }: {
app: AppInstanceWithId
}) {
const { t } = useTranslation('deployments')
const updateInstance = useMutation(consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions())
const appName = app.name ?? app.id
const [name, setName] = useState(appName)
const [description, setDescription] = useState(app.description ?? '')
const initialName = appName
const initialDescription = 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={`${SETTINGS_CONTENT_CLASS_NAME} 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}
onValueChange={value => setDescription(value)}
className="min-h-24"
/>
</div>
<div className="flex flex-wrap 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}
loading={updateInstance.isPending}
onClick={handleSave}
>
{t('settings.save')}
</Button>
</div>
</div>
</Section>
)
}
function SettingsFormSection({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const appInput = { params: { appInstanceId } }
const instanceQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: appInput,
}))
const app = instanceQuery.data?.appInstance
if (instanceQuery.isLoading) {
return (
<Section
title={t('settings.general')}
description={t('settings.descriptionHelp')}
layout="row"
>
<SettingsFormSkeleton />
</Section>
)
}
if (instanceQuery.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}-${appName}-${app.description ?? ''}`
const appWithId = {
...app,
id: app.id,
}
return (
<SettingsForm
key={formKey}
app={appWithId}
/>
)
}
function DeleteInstanceControlSection({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const appInput = { params: { appInstanceId } }
const instanceQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: appInput,
}))
const app = instanceQuery.data?.appInstance
if (instanceQuery.isLoading) {
return (
<DangerSection>
<DeleteInstanceSkeleton />
</DangerSection>
)
}
if (instanceQuery.isError) {
return (
<DangerSection>
<SectionState>{t('common.loadFailed')}</SectionState>
</DangerSection>
)
}
if (!app?.id) {
return (
<DangerSection>
<SectionState>{t('detail.notFound')}</SectionState>
</DangerSection>
)
}
const appWithId = {
...app,
id: app.id,
}
return (
<DangerSection>
<DeleteInstanceButton
app={appWithId}
/>
</DangerSection>
)
}
export function SettingsTab({ appInstanceId }: {
appInstanceId: string
}) {
return (
<div className="flex w-full min-w-0 flex-col gap-y-4 px-6 py-6 sm:py-8">
<SettingsFormSection appInstanceId={appInstanceId} />
<DeleteInstanceControlSection appInstanceId={appInstanceId} />
</div>
)
}

View File

@ -1,534 +0,0 @@
'use client'
import type {
ApiKey,
Environment,
} from '@dify/contracts/enterprise/types.gen'
import type { FormEvent } from 'react'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button, type ButtonProps } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectLabel,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { environmentName } from '../../../environment'
import {
DetailTable,
DetailTableBody,
DetailTableCard,
DetailTableCardList,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../../table'
import {
API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from '../../table-styles'
const API_TOKEN_NAME_ADJECTIVES = [
'ancient',
'autumn',
'bright',
'calm',
'crystal',
'gentle',
'golden',
'hidden',
'holy',
'quiet',
'rapid',
'silver',
]
const API_TOKEN_NAME_NOUNS = [
'brook',
'cloud',
'field',
'forest',
'harbor',
'lake',
'meadow',
'moon',
'river',
'stone',
'valley',
'wave',
]
function randomListItem(items: string[]) {
return items[Math.floor(Math.random() * items.length)]!
}
function generateApiTokenName() {
const suffix = Math.floor(1000 + Math.random() * 9000)
return `${randomListItem(API_TOKEN_NAME_ADJECTIVES)}-${randomListItem(API_TOKEN_NAME_NOUNS)}-${suffix}`
}
function ApiKeyName({ apiKey }: {
apiKey: ApiKey
}) {
return (
<span className="block truncate text-text-primary">
{apiKey.name || apiKey.id || '—'}
</span>
)
}
function EnvironmentBadge({ environment }: {
environment?: Environment
}) {
return (
<span className="inline-flex h-5 max-w-36 items-center rounded-md bg-background-section-burn px-1.5 text-xs text-text-tertiary">
<span className="truncate">{environmentName(environment)}</span>
</span>
)
}
function ApiKeyValue({ value }: {
value: string
}) {
return (
<div className="flex h-8 min-w-0 items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-2">
<div className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary">
{value}
</div>
</div>
)
}
function RevokeApiKeyButton({ apiKey }: {
apiKey: ApiKey
}) {
const { t } = useTranslation('deployments')
const queryClient = useQueryClient()
const [showRevokeConfirm, setShowRevokeConfirm] = useState(false)
const revokeApiKey = useMutation(consoleQuery.enterprise.accessService.deleteApiKey.mutationOptions())
const isRevoking = revokeApiKey.isPending
const apiKeyName = apiKey.name || apiKey.id || t('access.api.table.key')
function invalidateApiKeys() {
if (apiKey.appInstanceId && apiKey.environmentId) {
return queryClient.invalidateQueries({
queryKey: consoleQuery.enterprise.accessService.listApiKeys.key({
type: 'query',
input: {
params: {
appInstanceId: apiKey.appInstanceId,
environmentId: apiKey.environmentId,
},
},
}),
})
}
return queryClient.invalidateQueries({
queryKey: consoleQuery.enterprise.accessService.listApiKeys.key({ type: 'query' }),
})
}
function handleRevoke() {
if (!apiKey.id || isRevoking)
return
revokeApiKey.mutate(
{
params: {
apiKeyId: apiKey.id,
},
},
{
onSuccess: async () => {
await invalidateApiKeys()
setShowRevokeConfirm(false)
toast.success(t('access.api.revokeSuccess'))
},
onError: () => {
toast.error(t('access.api.revokeFailed'))
},
},
)
}
function handleRevokeConfirmOpenChange(open: boolean) {
if (isRevoking)
return
setShowRevokeConfirm(open)
}
return (
<>
<button
type="button"
onClick={() => setShowRevokeConfirm(true)}
aria-label={t('access.revoke')}
aria-busy={isRevoking}
disabled={!apiKey.id || isRevoking}
className={cn(
'inline-flex size-8 shrink-0 items-center justify-center rounded-md text-text-tertiary outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
isRevoking
? 'cursor-not-allowed opacity-60'
: 'hover:bg-state-destructive-hover hover:text-text-destructive',
)}
>
<span className={cn(isRevoking ? 'i-ri-loader-2-line animate-spin' : 'i-ri-delete-bin-line', 'size-3.5')} />
</button>
<AlertDialog open={showRevokeConfirm} onOpenChange={handleRevokeConfirmOpenChange}>
<AlertDialogContent>
<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('access.api.revokeConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('access.api.revokeConfirmDescription', { name: apiKeyName })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={isRevoking}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={isRevoking} disabled={isRevoking} onClick={handleRevoke}>
{t('access.revoke')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
function ApiKeyMobileRow({ apiKey, environment }: {
apiKey: ApiKey
environment?: Environment
}) {
const { t } = useTranslation('deployments')
const displayValue = apiKey.maskedToken || apiKey.id || '—'
return (
<DetailTableCard>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<ApiKeyName apiKey={apiKey} />
<div className="mt-1">
<EnvironmentBadge environment={environment} />
</div>
</div>
<RevokeApiKeyButton apiKey={apiKey} />
</div>
<div className="flex min-w-0 flex-col gap-1">
<span className="system-2xs-medium-uppercase text-text-tertiary">
{t('access.api.table.key')}
</span>
<ApiKeyValue value={displayValue} />
</div>
</div>
</DetailTableCard>
)
}
function ApiKeyDesktopRow({ apiKey, environment }: {
apiKey: ApiKey
environment?: Environment
}) {
const displayValue = apiKey.maskedToken || apiKey.id || '—'
return (
<DetailTableRow>
<DetailTableCell>
<ApiKeyName apiKey={apiKey} />
</DetailTableCell>
<DetailTableCell>
<EnvironmentBadge environment={environment} />
</DetailTableCell>
<DetailTableCell>
<ApiKeyValue value={displayValue} />
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<RevokeApiKeyButton apiKey={apiKey} />
</div>
</DetailTableCell>
</DetailTableRow>
)
}
function ApiKeyTableHeader() {
const { t } = useTranslation('deployments')
return (
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.name}>{t('access.api.table.name')}</DetailTableHead>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('access.api.table.environment')}</DetailTableHead>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.key}>{t('access.api.table.key')}</DetailTableHead>
<DetailTableHead className={`${API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t('access.api.table.action')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
)
}
function ApiKeyTable({ apiKeys, environments }: {
apiKeys: ApiKey[]
environments: Environment[]
}) {
const environmentById = new Map(environments.map(environment => [environment.id, environment]))
return (
<>
<DetailTableCardList className={cn('pc:hidden')}>
{apiKeys.map((apiKey, index) => (
<ApiKeyMobileRow
key={apiKey.id ?? apiKey.maskedToken ?? apiKey.name ?? index}
apiKey={apiKey}
environment={apiKey.environmentId ? environmentById.get(apiKey.environmentId) : undefined}
/>
))}
</DetailTableCardList>
<div className="hidden pc:block">
<DetailTable>
<ApiKeyTableHeader />
<DetailTableBody>
{apiKeys.map((apiKey, index) => (
<ApiKeyDesktopRow
key={apiKey.id ?? apiKey.maskedToken ?? apiKey.name ?? index}
apiKey={apiKey}
environment={apiKey.environmentId ? environmentById.get(apiKey.environmentId) : undefined}
/>
))}
</DetailTableBody>
</DetailTable>
</div>
</>
)
}
export function ApiKeyList({ apiKeys, environments }: {
apiKeys: ApiKey[]
environments: Environment[]
}) {
return (
<ApiKeyTable apiKeys={apiKeys} environments={environments} />
)
}
export function ApiKeyGenerateMenu({
appInstanceId,
environments,
onCreatedToken,
triggerVariant = 'secondary',
triggerClassName,
}: {
appInstanceId: string
environments: Environment[]
onCreatedToken: (token: string) => void
triggerVariant?: ButtonProps['variant']
triggerClassName?: string
}) {
const { t } = useTranslation('deployments')
const nameInputId = useId()
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string>()
const [draftName, setDraftName] = useState('')
const [nameError, setNameError] = useState(false)
const generateApiKey = useMutation(consoleQuery.enterprise.accessService.createApiKey.mutationOptions())
const selectableEnvironments = environments.filter(env => env.id)
const selectedEnvironment = selectedEnvironmentId
? environments.find(env => env.id === selectedEnvironmentId)
: undefined
const disabled = selectableEnvironments.length === 0
const isCreating = generateApiKey.isPending
function resetCreateDialog() {
setCreateDialogOpen(false)
setSelectedEnvironmentId(undefined)
setDraftName('')
setNameError(false)
}
function handleOpenCreateDialog() {
const firstEnvironmentId = selectableEnvironments[0]?.id
if (!firstEnvironmentId)
return
setSelectedEnvironmentId(firstEnvironmentId)
setDraftName(generateApiTokenName())
setNameError(false)
setCreateDialogOpen(true)
}
function handleEnvironmentChange(environmentId: string) {
setSelectedEnvironmentId(environmentId)
setNameError(false)
}
function handleDialogOpenChange(nextOpen: boolean) {
if (nextOpen || isCreating)
return
resetCreateDialog()
}
function handleGenerateApiKey(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const name = draftName.trim()
if (!selectedEnvironmentId || !name) {
setNameError(true)
return
}
generateApiKey.mutate(
{
params: {
appInstanceId,
environmentId: selectedEnvironmentId,
},
body: {
appInstanceId,
environmentId: selectedEnvironmentId,
name,
},
},
{
onSuccess: (response) => {
if (response.token)
onCreatedToken(response.token)
resetCreateDialog()
},
},
)
}
return (
<>
<Button
type="button"
variant={triggerVariant}
disabled={disabled}
onClick={handleOpenCreateDialog}
className={cn('gap-1.5', triggerClassName)}
>
<span className="i-ri-add-line size-4" aria-hidden="true" />
{t('access.api.newKey')}
</Button>
<Dialog open={createDialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] overflow-hidden p-0">
<DialogCloseButton disabled={isCreating} />
<form onSubmit={handleGenerateApiKey}>
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('access.api.createKeyTitle')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('access.api.description')}
</DialogDescription>
</div>
<div className="flex flex-col gap-4 px-6 py-5">
<div>
<label
htmlFor={nameInputId}
className="mb-1 block system-sm-medium text-text-secondary"
>
{t('access.api.nameLabel')}
</label>
<Input
id={nameInputId}
value={draftName}
disabled={isCreating}
autoFocus
aria-invalid={nameError || undefined}
aria-describedby={nameError ? `${nameInputId}-error` : undefined}
placeholder={t('access.api.namePlaceholder')}
onChange={(event) => {
setDraftName(event.target.value)
if (nameError && event.target.value.trim())
setNameError(false)
}}
/>
{nameError && (
<div id={`${nameInputId}-error`} className="mt-1 system-xs-regular text-text-destructive">
{t('access.api.nameRequired')}
</div>
)}
</div>
<div>
<Select
value={selectedEnvironmentId ?? null}
disabled={isCreating}
onValueChange={value => value && handleEnvironmentChange(value)}
>
<SelectLabel className="mb-1 block system-sm-medium text-text-secondary">
{t('access.api.table.environment')}
</SelectLabel>
<SelectTrigger>
{environmentName(selectedEnvironment)}
</SelectTrigger>
<SelectContent>
{selectableEnvironments.map(env => (
<SelectItem key={env.id} value={env.id!}>
<SelectItemText>{environmentName(env)}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<Button
type="button"
variant="secondary"
disabled={isCreating}
onClick={() => handleDialogOpenChange(false)}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
type="submit"
variant="primary"
loading={isCreating}
disabled={!selectedEnvironmentId || !draftName.trim()}
>
{t('access.api.createKey')}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -1,249 +0,0 @@
'use client'
import type { AccessChannels, Environment } from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
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 { DetailEmptyState, DetailNoticeState, Section, SectionState } from '../../common'
import { CopyPill, EndpointRow } from './common'
import { getUrlOrigin } from './url'
const ACCESS_CHANNEL_SKELETON_SECTIONS = [
{ key: 'webapp' },
{ key: 'cli' },
]
function AccessChannelsSwitch({ appInstanceId, checked, accessChannels, disabled }: {
appInstanceId: string
checked: boolean
accessChannels?: AccessChannels
disabled?: boolean
}) {
const toggleAccessChannel = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
return (
<Switch
checked={checked}
disabled={disabled}
loading={toggleAccessChannel.isPending}
onCheckedChange={(enabled) => {
toggleAccessChannel.mutate({
params: { appInstanceId },
body: {
appInstanceId,
webAppEnabled: enabled,
developerApiEnabled: accessChannels?.developerApiEnabled ?? false,
},
})
}}
/>
)
}
function AccessChannelsSkeleton() {
return (
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
{ACCESS_CHANNEL_SKELETON_SECTIONS.map(section => (
<SkeletonRow
key={section.key}
className="flex flex-col gap-3 border-t border-divider-subtle px-4 py-4 first:border-t-0 lg:flex-row lg:items-start"
>
<div className="flex min-w-0 gap-2.5 lg:w-70">
<SkeletonRectangle className="my-0 size-7 shrink-0 animate-pulse rounded-lg" />
<div className="flex min-w-0 flex-col gap-1.5">
<SkeletonRectangle className="h-3.5 w-24 animate-pulse" />
<SkeletonRectangle className="h-3 w-40 animate-pulse" />
</div>
</div>
<SkeletonRectangle className="my-0 h-8 min-w-0 flex-1 animate-pulse rounded-lg" />
</SkeletonRow>
))}
</div>
)
}
function ChannelInfo({ icon, title, description }: {
icon: ReactNode
title: string
description: string
}) {
return (
<div className="flex min-w-0 items-start gap-2.5">
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary">
{icon}
</span>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="system-sm-medium text-text-primary">{title}</span>
</div>
<div className="system-xs-regular text-text-tertiary">{description}</div>
</div>
</div>
)
}
function ChannelRow({ info, children }: {
info: ReactNode
children: ReactNode
}) {
return (
<div className="flex flex-col gap-3 border-t border-divider-subtle px-4 py-4 first:border-t-0 lg:flex-row lg:items-start">
<div className="min-w-0 lg:w-70">
{info}
</div>
<div className="min-w-0 flex-1">
{children}
</div>
</div>
)
}
export function AccessChannelsSection({
appInstanceId,
}: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const accessChannelsQuery = useQuery(consoleQuery.enterprise.accessService.getAccessChannels.queryOptions({
input: {
params: { appInstanceId },
},
}))
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
}))
const accessChannels = accessChannelsQuery.data?.accessChannels
const runEnabled = accessChannels?.webAppEnabled ?? false
const webappRows = environmentDeploymentsQuery.data?.data
?.map(row => row.environment)
.filter((environment): environment is Environment & { id: string, runtimeEndpoint: string } => Boolean(environment?.id && environment.runtimeEndpoint)) ?? []
const cliDomain = getUrlOrigin(webappRows[0]?.runtimeEndpoint)
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
const isLoading = accessChannelsQuery.isLoading || (runEnabled && environmentDeploymentsQuery.isLoading)
const isError = accessChannelsQuery.isError || (runEnabled && environmentDeploymentsQuery.isError)
return (
<Section
title={t('access.channels.title')}
description={t('access.channels.description')}
action={(
isLoading
? <SwitchSkeleton />
: (
<div className="flex items-center gap-2">
<span className="system-xs-medium text-text-tertiary">
{runEnabled ? t('overview.enabled') : t('overview.disabled')}
</span>
<AccessChannelsSwitch
appInstanceId={appInstanceId}
checked={runEnabled}
accessChannels={accessChannels}
disabled={isError}
/>
</div>
)
)}
>
{isLoading
? <AccessChannelsSkeleton />
: isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: runEnabled
? (
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
<ChannelRow
info={(
<ChannelInfo
icon={<span className="i-ri-global-line size-3.5" aria-hidden="true" />}
title={t('access.runAccess.webapp')}
description={t('access.runAccess.webappDesc')}
/>
)}
>
{webappRows.length > 0
? (
<div className="flex flex-col gap-1.5">
{webappRows.map((environment) => {
const endpointUrl = webappUrl(environment.runtimeEndpoint ?? '')
return (
<EndpointRow
key={`webapp-${environment.id ?? environment.runtimeEndpoint}`}
envName={environmentName(environment)}
label={t('access.runAccess.urlLabel')}
value={endpointUrl}
openLabel={t('access.runAccess.openWebapp')}
/>
)
})}
</div>
)
: (
<DetailNoticeState>
{t('access.runAccess.webappEmpty')}
</DetailNoticeState>
)}
</ChannelRow>
<ChannelRow
info={(
<ChannelInfo
icon={<span className="i-ri-terminal-box-line size-3.5" aria-hidden="true" />}
title={t('access.cli.title')}
description={t('access.cli.description')}
/>
)}
>
{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>
)
: (
<DetailNoticeState>
{t('access.cli.empty')}
</DetailNoticeState>
)}
</ChannelRow>
</div>
)
: (
<DetailEmptyState
variant="section"
icon="i-ri-toggle-line"
title={t('access.channels.disabled')}
description={t('access.channels.disabledHint')}
/>
)}
</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,476 +0,0 @@
'use client'
import type {
AccessChannels,
ApiKey,
Environment,
} from '@dify/contracts/enterprise/types.gen'
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 { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQueries, useQuery } from '@tanstack/react-query'
import { atom, useAtom, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { useClipboard } from '@/hooks/use-clipboard'
import { consoleQuery } from '@/service/client'
import { DetailEmptyState, SectionState } from '../../common'
import {
DetailTable,
DetailTableBody,
DetailTableCard,
DetailTableCardList,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../../table'
import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../../table-styles'
import { ApiKeyGenerateMenu, ApiKeyList } from './api-keys'
import { CopyPill } from './common'
type CreatedApiToken = {
appInstanceId: string
token: string
}
const createdApiTokenAtom = atom<CreatedApiToken | undefined>(undefined)
const DEVELOPER_API_KEY_SKELETON_KEYS = ['primary-key', 'secondary-key']
function buildCurlExample(apiUrl: string, token: string) {
return `curl -X POST '${apiUrl}' \\
--header 'Authorization: Bearer ${token}' \\
--header 'Content-Type: application/json' \\
--data-raw '{
"inputs": {},
"response_mode": "streaming",
"user": "abc-123"
}'`
}
function deploymentEnvironment(row: { environment?: Environment }): Environment | undefined {
return row.environment?.id ? row.environment : undefined
}
function useDeveloperApiStatus(appInstanceId: string) {
const accessChannelsQuery = useQuery(consoleQuery.enterprise.accessService.getAccessChannels.queryOptions({
input: {
params: { appInstanceId },
},
}))
const accessChannels = accessChannelsQuery.data?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
return {
apiEnabled,
accessChannels,
isLoading: accessChannelsQuery.isLoading,
isError: accessChannelsQuery.isError,
}
}
function useDeveloperApiResources(appInstanceId: string) {
const {
apiEnabled,
isLoading: accessChannelsLoading,
isError: accessChannelsError,
} = useDeveloperApiStatus(appInstanceId)
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
}))
const environments = environmentDeploymentsQuery.data?.data
?.map(deploymentEnvironment)
.filter((environment): environment is Environment & { id: string } => Boolean(environment)) ?? []
const apiKeyQueries = useQueries({
queries: environments.map(environment => consoleQuery.enterprise.accessService.listApiKeys.queryOptions({
input: {
params: {
appInstanceId,
environmentId: environment.id,
},
},
enabled: Boolean(apiEnabled),
})),
})
const apiKeys: ApiKey[] = apiKeyQueries.flatMap(query => query.data?.data ?? [])
const apiUrl = apiKeyQueries.find(query => query.data?.apiUrl)?.data?.apiUrl
const apiKeysLoading = apiKeyQueries.some(query => query.isLoading)
const apiKeysError = apiKeyQueries.some(query => query.isError)
return {
apiEnabled,
apiUrl,
environments,
apiKeys,
isLoading: accessChannelsLoading || environmentDeploymentsQuery.isLoading || (apiEnabled && apiKeysLoading),
isError: accessChannelsError || environmentDeploymentsQuery.isError || (apiEnabled && apiKeysError),
}
}
function DeveloperApiSwitch({ appInstanceId, checked, accessChannels, disabled }: {
appInstanceId: string
checked: boolean
accessChannels?: AccessChannels
disabled?: boolean
}) {
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
return (
<Switch
checked={checked}
disabled={disabled}
loading={toggleDeveloperAPI.isPending}
onCheckedChange={(enabled) => {
toggleDeveloperAPI.mutate({
params: { appInstanceId },
body: {
appInstanceId,
webAppEnabled: accessChannels?.webAppEnabled ?? false,
developerApiEnabled: enabled,
},
})
}}
/>
)
}
export function DeveloperApiHeaderSwitch({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const {
apiEnabled,
accessChannels,
isLoading,
isError,
} = useDeveloperApiStatus(appInstanceId)
if (isLoading)
return <SwitchSkeleton />
return (
<div className="flex items-center gap-2">
<span className="system-xs-medium text-text-tertiary">
{apiEnabled ? t('overview.enabled') : t('overview.disabled')}
</span>
<DeveloperApiSwitch
appInstanceId={appInstanceId}
checked={apiEnabled}
accessChannels={accessChannels}
disabled={isError}
/>
</div>
)
}
export function DeveloperApiHeaderActions({ appInstanceId }: {
appInstanceId: string
}) {
const setCreatedApiToken = useSetAtom(createdApiTokenAtom)
const {
apiEnabled,
apiKeys,
environments,
isLoading,
} = useDeveloperApiResources(appInstanceId)
if (isLoading) {
return <SkeletonRectangle className="my-0 h-8 w-32 animate-pulse rounded-lg" />
}
if (!apiEnabled)
return null
if (apiKeys.length === 0)
return null
return (
<ApiKeyGenerateMenu
appInstanceId={appInstanceId}
environments={environments}
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
/>
)
}
function CurlExample({ apiUrl, token }: {
apiUrl: string
token: string
}) {
const { t } = useTranslation('deployments')
const curlExample = buildCurlExample(apiUrl, token)
const { copied, copy } = useClipboard({
onCopyError: () => {
toast.error(t('access.copyFailed'))
},
})
return (
<div className="min-w-0 overflow-hidden rounded-lg border border-components-input-border-active bg-components-input-bg-normal">
<div className="flex h-8 items-center justify-between gap-2 border-b border-divider-subtle pr-1.5 pl-3">
<div className="min-w-0 truncate system-xs-semibold-uppercase text-text-secondary">
{t('access.api.curlExampleTitle')}
</div>
<button
type="button"
onClick={() => copy(curlExample)}
aria-label={t('access.api.copyCurlExample')}
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>
<pre className="max-h-40 overflow-auto px-3 py-3 font-mono system-xs-regular whitespace-pre text-text-secondary">
<code>{curlExample}</code>
</pre>
</div>
)
}
function CreatedApiTokenDialog({ token, apiUrl, onDismiss }: {
token: string
apiUrl?: string
onDismiss: () => void
}) {
const { t } = useTranslation('deployments')
return (
<Dialog open={Boolean(token)} onOpenChange={open => !open && onDismiss()} disablePointerDismissal>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] overflow-hidden p-0">
<DialogCloseButton />
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('access.api.newTokenTitle')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('access.api.newTokenDescription')}
</DialogDescription>
</div>
<div className="flex flex-col gap-5 px-6 py-5">
<CopyPill
label={t('access.api.newTokenLabel')}
value={token}
/>
{apiUrl && (
<CurlExample
apiUrl={apiUrl}
token={token}
/>
)}
</div>
<div className="flex justify-end border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<Button variant="primary" onClick={onDismiss}>
{t('operation.confirm', { ns: 'common' })}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
function DeveloperApiSkeleton() {
return (
<div className="flex flex-col gap-4" data-slot="deployment-developer-api-skeleton">
<ApiUrlSkeleton />
<ApiKeyTableSkeleton />
</div>
)
}
function ApiUrlSkeleton() {
return (
<div
className="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"
data-slot="deployment-developer-api-url-skeleton"
>
<SkeletonRectangle className="my-0 h-5 w-16 shrink-0 animate-pulse rounded-md" />
<SkeletonRectangle className="my-0 h-4 min-w-0 flex-1 animate-pulse" />
<div className="h-3.5 w-px shrink-0 bg-divider-regular" />
<SkeletonRectangle className="my-0 size-6 shrink-0 animate-pulse rounded-md" />
</div>
)
}
function ApiKeyTableSkeleton() {
return (
<>
<DetailTableCardList className="pc:hidden">
{DEVELOPER_API_KEY_SKELETON_KEYS.map(key => (
<DetailTableCard key={key} data-slot="deployment-developer-api-mobile-row-skeleton">
<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="my-0 h-3.5 w-32 animate-pulse" />
<SkeletonRectangle className="mt-2 h-5 w-20 animate-pulse rounded-md" />
</div>
<SkeletonRectangle className="my-0 size-8 shrink-0 animate-pulse rounded-md" />
</div>
<div className="flex min-w-0 flex-col gap-1">
<SkeletonRectangle className="my-0 h-2.5 w-14 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</div>
</div>
</DetailTableCard>
))}
</DetailTableCardList>
<div className="hidden pc:block">
<DetailTable>
<ApiKeyTableHeaderSkeleton />
<DetailTableBody>
{DEVELOPER_API_KEY_SKELETON_KEYS.map(key => (
<ApiKeyDesktopRowSkeleton key={key} />
))}
</DetailTableBody>
</DetailTable>
</div>
</>
)
}
function ApiKeyTableHeaderSkeleton() {
const { t } = useTranslation('deployments')
return (
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.name}>{t('access.api.table.name')}</DetailTableHead>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('access.api.table.environment')}</DetailTableHead>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.key}>{t('access.api.table.key')}</DetailTableHead>
<DetailTableHead className={`${API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t('access.api.table.action')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
)
}
function ApiKeyDesktopRowSkeleton() {
return (
<DetailTableRow data-slot="deployment-developer-api-desktop-row-skeleton">
<DetailTableCell>
<SkeletonRectangle className="my-0 h-3.5 w-32 animate-pulse" />
</DetailTableCell>
<DetailTableCell>
<SkeletonRectangle className="my-0 h-5 w-20 animate-pulse rounded-md" />
</DetailTableCell>
<DetailTableCell>
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</DetailTableCell>
</DetailTableRow>
)
}
function ApiKeyListSection({ apiKeys, environments }: {
apiKeys: ApiKey[]
environments: Environment[]
}) {
const { t } = useTranslation('deployments')
return (
<div className="flex flex-col gap-2">
<div className="system-xs-semibold-uppercase text-text-tertiary">
{t('access.api.keyList')}
</div>
<ApiKeyList
apiKeys={apiKeys}
environments={environments}
/>
</div>
)
}
export function DeveloperApiSection({
appInstanceId,
}: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const [createdApiToken, setCreatedApiToken] = useAtom(createdApiTokenAtom)
const {
apiEnabled,
apiUrl,
apiKeys,
environments,
isLoading,
isError,
} = useDeveloperApiResources(appInstanceId)
const visibleCreatedApiToken = createdApiToken?.appInstanceId === appInstanceId
? createdApiToken.token
: undefined
return (
<>
{isLoading
? <DeveloperApiSkeleton />
: isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: apiEnabled
? (
<div className="flex flex-col gap-4">
{apiUrl && (
<CopyPill
label={t('access.api.endpoint')}
value={apiUrl}
/>
)}
{apiKeys.length === 0
? (
<DetailEmptyState
variant="section"
icon={environments.length === 0 ? 'i-ri-rocket-line' : 'i-ri-key-2-line'}
title={environments.length === 0
? t('access.api.emptyTitle')
: t('access.api.noKeysTitle')}
description={environments.length === 0
? t('access.api.empty')
: t('access.api.noKeys')}
action={(
environments.length > 0
? (
<ApiKeyGenerateMenu
appInstanceId={appInstanceId}
environments={environments}
triggerVariant="primary"
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
/>
)
: undefined
)}
/>
)
: (
<ApiKeyListSection
apiKeys={apiKeys}
environments={environments}
/>
)}
{visibleCreatedApiToken && (
<CreatedApiTokenDialog
token={visibleCreatedApiToken}
apiUrl={apiUrl}
onDismiss={() => setCreatedApiToken(undefined)}
/>
)}
</div>
)
: (
<DetailEmptyState
variant="section"
icon="i-ri-toggle-line"
title={t('access.api.disabled')}
description={t('access.api.disabledHint')}
/>
)}
</>
)
}

View File

@ -1,115 +0,0 @@
'use client'
import type { Environment } from '@dify/contracts/enterprise/types.gen'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { DetailEmptyState, Section, SectionState } from '../../common'
import {
DetailTable,
DetailTableBody,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../../table'
import {
ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from '../../table-styles'
import { EnvironmentPermissionRow } from './permissions'
const ACCESS_PERMISSIONS_SKELETON_KEYS = ['production', 'staging', 'development']
function hasEnvironment(environment?: Environment): environment is Environment & { id: string } {
return Boolean(environment?.id)
}
function AccessPermissionsSkeleton() {
const { t } = useTranslation('deployments')
return (
<DetailTable className="block pc:table">
<DetailTableHeader className="hidden pc:table-header-group">
<DetailTableRow>
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('access.permissions.col.environment')}</DetailTableHead>
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.permission}>{t('access.permissions.col.permission')}</DetailTableHead>
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.subjects}>{t('access.permissions.col.subjects')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody className="block pc:table-row-group">
{ACCESS_PERMISSIONS_SKELETON_KEYS.map(key => (
<DetailTableRow key={key} className="block h-auto pc:table-row pc:h-8">
<DetailTableCell className="block h-auto max-w-none px-4 pt-3 pb-1 pc:table-cell pc:max-w-[200px] pc:px-2.5 pc:py-[5px] pc:pl-3">
<SkeletonRectangle className="h-4 w-32 animate-pulse" />
</DetailTableCell>
<DetailTableCell className="block h-auto max-w-none px-4 py-1 pc:table-cell pc:max-w-[200px] pc:px-2.5 pc:py-[5px] pc:pl-3">
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</DetailTableCell>
<DetailTableCell className="block h-auto max-w-none px-4 pt-1 pb-3 pc:table-cell pc:max-w-[200px] pc:px-2.5 pc:py-[5px] pc:pl-3">
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</DetailTableCell>
</DetailTableRow>
))}
</DetailTableBody>
</DetailTable>
)
}
export function AccessPermissionsSection({
appInstanceId,
}: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
}))
const environments = environmentDeploymentsQuery.data?.data
?.map(row => row.environment)
.filter(hasEnvironment) ?? []
return (
<Section
title={t('access.permissions.title')}
description={t('access.permissions.description')}
showDivider={false}
>
{environmentDeploymentsQuery.isLoading
? <AccessPermissionsSkeleton />
: environmentDeploymentsQuery.isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: environments.length === 0
? (
<DetailEmptyState
variant="section"
icon="i-ri-rocket-line"
title={t('access.runAccess.noEnvsTitle')}
description={t('access.runAccess.noEnvs')}
/>
)
: (
<DetailTable className="block pc:table">
<DetailTableHeader className="hidden pc:table-header-group">
<DetailTableRow>
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('access.permissions.col.environment')}</DetailTableHead>
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.permission}>{t('access.permissions.col.permission')}</DetailTableHead>
<DetailTableHead className={ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES.subjects}>{t('access.permissions.col.subjects')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody className="block pc:table-row-group">
{environments.map(environment => (
<EnvironmentPermissionRow
key={environment.id}
appInstanceId={appInstanceId}
environment={environment}
/>
))}
</DetailTableBody>
</DetailTable>
)}
</Section>
)
}

View File

@ -1,573 +0,0 @@
'use client'
import type {
AccessPolicy,
AccessSubject,
Environment,
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'
import {
DetailTableCell,
DetailTableRow,
} from '../../table'
type AccessPermissionKind = 'organization' | 'specific' | 'anyone'
type AccessMode = NonNullable<AccessPolicy['mode']>
type AccessSubjectType = NonNullable<AccessSubject['subjectType']>
const ACCESS_MODE_PUBLIC = 'ACCESS_MODE_PUBLIC' satisfies AccessMode
const ACCESS_MODE_PRIVATE = 'ACCESS_MODE_PRIVATE' satisfies AccessMode
const ACCESS_MODE_PRIVATE_ALL = 'ACCESS_MODE_PRIVATE_ALL' satisfies AccessMode
const SUBJECT_TYPE_ACCOUNT = 'SUBJECT_TYPE_ACCOUNT' satisfies AccessSubjectType
const SUBJECT_TYPE_GROUP = 'SUBJECT_TYPE_GROUP' satisfies AccessSubjectType
const ACCESS_SUBJECT_LABEL_PAGE_SIZE = 100
const ACCESS_SUBJECT_SEARCH_PAGE_SIZE = 50
const ACCESS_SUBJECT_SEARCH_DEBOUNCE = 300
function accessModeToPermissionKey(mode?: AccessPolicy['mode']): AccessPermissionKind {
if (mode === ACCESS_MODE_PRIVATE)
return 'specific'
if (mode === ACCESS_MODE_PUBLIC)
return 'anyone'
return 'organization'
}
function permissionKeyToAccessMode(key: AccessPermissionKind): AccessMode {
if (key === 'organization')
return ACCESS_MODE_PRIVATE_ALL
if (key === 'specific')
return ACCESS_MODE_PRIVATE
return ACCESS_MODE_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, loading, onChange }: {
value: AccessPermissionKind
disabled?: boolean
loading?: 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={cn(loading ? 'i-ri-loader-2-line animate-spin' : 'i-ri-arrow-down-s-line', 'size-4 shrink-0 text-text-tertiary motion-reduce:animate-none')} />
</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: AccessSubjectType
name?: string
memberCount?: number
}
function subjectTypeFromSubject(subject: Subject): AccessSubjectType {
if (subject.subjectType === SUBJECT_TYPE_GROUP || subject.groupData)
return SUBJECT_TYPE_GROUP
return SUBJECT_TYPE_ACCOUNT
}
function normalizeSubject(subject: Subject): SelectableAccessSubject | undefined {
const id = subject.subjectId || subject.accountData?.id || subject.groupData?.id
if (!id)
return undefined
return {
id,
subjectType: subjectTypeFromSubject(subject),
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?: AccessPolicy, labelSubjects: SelectableAccessSubject[] = []) {
return policy?.subjects
?.map((subject): SelectableAccessSubject | undefined => {
if (!subject.subjectId || !subject.subjectType)
return undefined
const matchedSubject = labelSubjects.find(labelSubject =>
labelSubject.id === subject.subjectId && labelSubject.subjectType === subject.subjectType,
)
return {
id: subject.subjectId,
subjectType: subject.subjectType,
name: matchedSubject?.name,
memberCount: matchedSubject?.memberCount,
}
})
.filter((subject): subject is SelectableAccessSubject => Boolean(subject)) ?? []
}
function SubjectIcon({ subject }: {
subject: SelectableAccessSubject
}) {
const isGroup = subject.subjectType === SUBJECT_TYPE_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 AccessSubjectComboboxProps = {
disabled?: boolean
loading?: boolean
selectedSubjects: SelectableAccessSubject[]
onChange: (subjects: SelectableAccessSubject[]) => void
}
function AccessSubjectCombobox({
disabled,
loading,
selectedSubjects,
onChange,
}: AccessSubjectComboboxProps) {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const debouncedKeyword = useDebounce(keyword, { wait: ACCESS_SUBJECT_SEARCH_DEBOUNCE })
const trimmedKeyword = keyword.trim()
const searchKeyword = debouncedKeyword.trim()
const isSearchDebouncing = trimmedKeyword !== searchKeyword
const isInteractionDisabled = Boolean(disabled || loading)
const subjectsQuery = useQuery(consoleQuery.enterprise.accessSubjectService.listAccessSubjects.queryOptions({
input: {
query: {
keyword: searchKeyword || undefined,
pageNumber: 1,
resultsPerPage: ACCESS_SUBJECT_SEARCH_PAGE_SIZE,
},
},
enabled: open && !isInteractionDisabled,
}))
const subjects = isSearchDebouncing
? []
: subjectsQuery.data?.subjects
?.map(normalizeSubject)
.filter((subject): subject is SelectableAccessSubject => Boolean(subject)) ?? []
const selectedItems = selectedSubjects.filter(selectedSubject =>
!subjects.some(subject => isSameSubject(subject, selectedSubject)),
)
const items = [...subjects, ...selectedItems]
const isResultLoading = subjectsQuery.isLoading || isSearchDebouncing
const shouldShowEmpty = !isResultLoading && !subjectsQuery.isError && subjects.length === 0
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen && isInteractionDisabled)
return
if (!nextOpen)
setKeyword('')
setOpen(nextOpen)
}
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
if (!isInteractionDisabled && details.reason !== 'item-press')
setKeyword(inputValue)
}
const handleValueChange = (nextSubjects: SelectableAccessSubject[]) => {
if (isInteractionDisabled)
return
setKeyword('')
onChange(nextSubjects)
}
return (
<Combobox<SelectableAccessSubject, true>
multiple
open={open}
value={selectedSubjects}
inputValue={keyword}
items={items}
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 items-start overflow-hidden py-1 pr-1">
<ComboboxChips>
<ComboboxValue>
{(selectedValue: SelectableAccessSubject[]) => (
<>
{selectedValue.map(subject => (
<ComboboxChip
key={subjectKey(subject)}
className="shrink-0 cursor-default rounded-full border border-divider-subtle bg-components-badge-white-to-dark select-none"
>
<SubjectIcon subject={subject} />
<span className="max-w-32 truncate">{getSubjectLabel(subject)}</span>
{subject.subjectType === SUBJECT_TYPE_GROUP && subject.memberCount != null && (
<span className="system-2xs-regular text-text-tertiary">{subject.memberCount}</span>
)}
<ComboboxChipRemove
disabled={isInteractionDisabled}
aria-label={t('operation.remove', { ns: 'common' })}
>
<span className="i-ri-close-circle-fill size-3.5" aria-hidden="true" />
</ComboboxChipRemove>
</ComboboxChip>
))}
<ComboboxInput
name="access-subjects"
disabled={disabled}
readOnly={isInteractionDisabled}
aria-label={t('access.members.pickPlaceholder')}
placeholder={selectedValue.length ? '' : t('access.members.pickPlaceholder')}
className={cn('px-1 py-0.5 system-sm-medium', selectedValue.length ? 'min-w-16' : 'min-w-0')}
/>
</>
)}
</ComboboxValue>
</ComboboxChips>
<ComboboxInputTrigger className="mt-0.5" disabled={isInteractionDisabled}>
{loading
? (
<span
className="i-ri-loader-2-line size-4 animate-spin text-text-tertiary motion-reduce:animate-none"
aria-hidden="true"
/>
)
: undefined}
</ComboboxInputTrigger>
</ComboboxInputGroup>
<ComboboxContent
popupClassName="max-w-none p-0 aria-disabled:pointer-events-none"
popupProps={{
'aria-busy': subjectsQuery.isFetching || isSearchDebouncing || undefined,
'aria-disabled': isInteractionDisabled || undefined,
}}
>
{isResultLoading
? (
<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>
)
: (
<>
{subjectsQuery.isFetching && (
<ComboboxStatus className="border-b border-divider-subtle px-3 py-2 system-xs-regular">
{t('common.loading')}
</ComboboxStatus>
)}
<ComboboxList className="p-1">
{items.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 === SUBJECT_TYPE_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>
{shouldShowEmpty && (
selectedItems.length > 0
? (
<ComboboxStatus className="px-3 py-5 text-center system-xs-regular">
{t('access.members.empty')}
</ComboboxStatus>
)
: (
<ComboboxEmpty className="px-3 py-5 text-center system-xs-regular">
{t('access.members.empty')}
</ComboboxEmpty>
)
)}
</>
)}
</ComboboxContent>
</Combobox>
)
}
type EnvironmentPermissionRowProps = {
appInstanceId: string
environment: Environment
summaryPolicy?: AccessPolicy
}
export function EnvironmentPermissionRow({
appInstanceId,
environment,
summaryPolicy,
}: EnvironmentPermissionRowProps) {
const { t } = useTranslation('deployments')
const environmentId = environment.id
const accessPolicyQuery = useQuery(consoleQuery.enterprise.accessService.getAccessPolicy.queryOptions({
input: {
params: {
appInstanceId,
environmentId: environmentId ?? '',
},
},
enabled: Boolean(environmentId),
}))
const setEnvironmentAccessPolicy = useMutation(consoleQuery.enterprise.accessService.putAccessPolicy.mutationOptions())
const policy = accessPolicyQuery.data?.policy ?? summaryPolicy
const policyKind = accessModeToPermissionKey(policy?.mode)
const accessSubjectsQuery = useQuery(consoleQuery.enterprise.accessSubjectService.listAccessSubjects.queryOptions({
input: {
query: {
pageNumber: 1,
resultsPerPage: ACCESS_SUBJECT_LABEL_PAGE_SIZE,
},
},
enabled: policyKind === 'specific',
}))
const accessSubjects = accessSubjectsQuery.data?.subjects
?.map(normalizeSubject)
.filter((subject): subject is SelectableAccessSubject => Boolean(subject)) ?? []
const policySubjectFingerprint = policy?.subjects
?.map(subject => `${subject.subjectType ?? ''}:${subject.subjectId ?? ''}`)
.join(',')
const policyFingerprint = [
policy?.mode ?? '',
policySubjectFingerprint ?? '',
].join(':')
const [draft, setDraft] = useState<{
fingerprint?: string
kind?: AccessPermissionKind
subjects?: SelectableAccessSubject[]
}>({})
const subjectLabelCandidates = [
...(draft.subjects ?? []),
...accessSubjects,
]
const hasDraft = draft.fingerprint === policyFingerprint
const permissionKind = hasDraft && draft.kind ? draft.kind : policyKind
const policySelectedSubjects = policyKind === 'specific' ? selectedSubjectsFromPolicy(policy, subjectLabelCandidates) : []
const subjects = hasDraft && draft.subjects ? draft.subjects : accessSubjectsQuery.isLoading ? [] : policySelectedSubjects
const isSaving = setEnvironmentAccessPolicy.isPending
const controlsDisabled = isSaving || accessPolicyQuery.isLoading || accessPolicyQuery.isError
const persistPolicy = (nextKind: AccessPermissionKind, nextSubjects: SelectableAccessSubject[]) => {
if (!environmentId)
return
if (nextKind === 'specific' && nextSubjects.length === 0)
return
setEnvironmentAccessPolicy.mutate(
{
params: {
appInstanceId,
environmentId,
},
body: {
appInstanceId,
environmentId,
mode: permissionKeyToAccessMode(nextKind),
subjects: nextKind === 'specific' ? policySubjects(nextSubjects) : [],
},
},
{
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[]) => {
setDraft({
fingerprint: policyFingerprint,
kind: 'specific',
subjects: nextSubjects,
})
persistPolicy('specific', nextSubjects)
}
return (
<DetailTableRow className="block h-auto pc:table-row pc:h-8">
<DetailTableCell className="block h-auto max-w-none px-4 pt-3 pb-1 align-top pc:table-cell pc:max-w-[200px] pc:px-2.5 pc:py-[5px] pc:pl-3">
<div className="system-2xs-medium-uppercase text-text-tertiary pc:hidden">
{t('access.permissions.col.environment')}
</div>
<div className="mt-1 flex min-h-8 min-w-0 items-center pc:mt-0">
<span className="min-w-0 truncate text-text-primary">
{environmentName(environment)}
</span>
</div>
</DetailTableCell>
<DetailTableCell className="block h-auto max-w-none px-4 py-1 align-top pc:table-cell pc:max-w-[200px] pc:px-2.5 pc:py-[5px] pc:pl-3">
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary pc:hidden">
{t('access.permissions.col.permission')}
</div>
<PermissionPicker
value={permissionKind}
disabled={controlsDisabled}
loading={isSaving}
onChange={handlePermissionChange}
/>
</DetailTableCell>
<DetailTableCell className="block h-auto max-w-none px-4 pt-1 pb-3 align-top pc:table-cell pc:max-w-[200px] pc:px-2.5 pc:py-[5px] pc:pl-3">
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary pc:hidden">
{t('access.permissions.col.subjects')}
</div>
{permissionKind === 'specific'
? (
<>
<AccessSubjectCombobox
selectedSubjects={subjects}
disabled={accessPolicyQuery.isLoading || accessPolicyQuery.isError || accessSubjectsQuery.isLoading}
loading={isSaving}
onChange={handleSubjectsChange}
/>
{!accessSubjectsQuery.isLoading && subjects.length === 0 && (
<span className="mt-1.5 flex min-h-7 items-start gap-1.5 rounded-lg border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 px-2 py-1.5 system-xs-regular text-util-colors-warning-warning-700">
<span className="i-ri-error-warning-line mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
<span className="min-w-0">
{t('access.members.emptySelection')}
</span>
</span>
)}
</>
)
: (
<div className="flex min-h-8 items-center system-xs-regular text-text-tertiary">
<span className="min-w-0">
{t(`access.permission.${permissionKind}Desc`)}
</span>
</div>
)}
</DetailTableCell>
</DetailTableRow>
)
}

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,37 +0,0 @@
import { cn } from '@langgenius/dify-ui/cn'
export const DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
actions: 'w-14',
currentRelease: 'w-[34%]',
environment: 'w-[34%]',
status: 'w-[24%]',
}
export const RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
action: 'w-14',
author: 'w-[15%]',
createdAt: 'w-[16%]',
deployedTo: 'w-[22%]',
release: 'w-[24%]',
sourceApp: 'w-[18%]',
}
export const ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
environment: 'w-[20%]',
permission: 'w-[36%]',
subjects: 'w-[44%]',
}
export const API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
action: 'w-16',
environment: 'w-[20%]',
key: 'w-[38%]',
name: 'w-[28%]',
}
export const DETAIL_TABLE_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',
)

View File

@ -1,91 +0,0 @@
import type { ComponentProps } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
type DetailTableProps = ComponentProps<'table'> & {
containerClassName?: string
}
export function DetailTable({ className, containerClassName, ...props }: DetailTableProps) {
return (
<div
data-slot="deployment-detail-table-container"
className={cn('relative w-full pc:overflow-x-auto', containerClassName)}
>
<table
data-slot="deployment-detail-table"
className={cn('w-full max-w-full min-w-0 caption-bottom border-collapse border-0 text-sm pc:min-w-[700px]', className)}
{...props}
/>
</div>
)
}
export function DetailTableHeader({ className, ...props }: ComponentProps<'thead'>) {
return (
<thead
data-slot="deployment-detail-table-header"
className={cn('h-8 border-b border-divider-subtle text-xs/8 font-medium text-text-tertiary uppercase', className)}
{...props}
/>
)
}
export function DetailTableBody({ className, ...props }: ComponentProps<'tbody'>) {
return (
<tbody
data-slot="deployment-detail-table-body"
className={cn('text-text-secondary', className)}
{...props}
/>
)
}
export function DetailTableRow({ className, ...props }: ComponentProps<'tr'>) {
return (
<tr
data-slot="deployment-detail-table-row"
className={cn('h-8 border-b border-divider-subtle transition-colors hover:bg-background-default-hover', className)}
{...props}
/>
)
}
export function DetailTableHead({ className, ...props }: ComponentProps<'th'>) {
return (
<th
data-slot="deployment-detail-table-head"
className={cn('max-w-[200px] px-2.5 py-0 pl-3 text-left align-middle font-medium whitespace-nowrap text-text-tertiary', className)}
{...props}
/>
)
}
export function DetailTableCell({ className, ...props }: ComponentProps<'td'>) {
return (
<td
data-slot="deployment-detail-table-cell"
className={cn('max-w-[200px] px-2.5 py-[5px] pl-3 align-middle', className)}
{...props}
/>
)
}
export function DetailTableCardList({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="deployment-detail-table-card-list"
className={cn('overflow-hidden rounded-lg border border-divider-subtle bg-background-default', className)}
{...props}
/>
)
}
export function DetailTableCard({ className, ...props }: ComponentProps<'div'>) {
return (
<div
data-slot="deployment-detail-table-card"
className={cn('border-b border-divider-subtle last:border-b-0 hover:bg-background-default-hover', className)}
{...props}
/>
)
}

View File

@ -1,9 +0,0 @@
export const INSTANCE_DETAIL_TAB_KEYS = ['overview', 'instances', 'releases', 'access', 'api-tokens', '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,12 +0,0 @@
'use client'
import { ReleaseHistoryTable } from './versions-tab/release-history-table'
export function VersionsTab({ appInstanceId }: {
appInstanceId: string
}) {
return (
<div className="flex w-full min-w-0 flex-col gap-4 px-6 py-6">
<ReleaseHistoryTable appInstanceId={appInstanceId} />
</div>
)
}

View File

@ -1,197 +0,0 @@
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DeployReleaseMenu } from '../deploy-release-menu'
type DeleteReleaseVariables = {
params: {
releaseId: string
}
}
type QueryOptionsArgs = {
input?: unknown
enabled?: boolean
}
const mockDeleteRelease = vi.hoisted(() =>
vi.fn<(variables: DeleteReleaseVariables) => Promise<Record<string, never>>>(),
)
const mockToastSuccess = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn())
const mockEnvironmentDeployments = vi.hoisted(() => ({
data: [] as EnvironmentDeployment[],
}))
vi.mock('@langgenius/dify-ui/toast', async (importOriginal) => {
const actual = await importOriginal<typeof import('@langgenius/dify-ui/toast')>()
return {
...actual,
toast: {
...actual.toast,
success: mockToastSuccess,
error: mockToastError,
},
}
})
vi.mock('../release-dsl-export', () => ({
exportReleaseDsl: vi.fn(),
}))
vi.mock('@/service/client', () => {
const createKey = (name: string) => vi.fn((args?: unknown) => [name, args])
return {
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: vi.fn((args: QueryOptionsArgs) => ({
queryKey: ['appInstance', args.input],
queryFn: () => Promise.resolve({ appInstance: { id: 'app-1', name: 'Demo app' } }),
enabled: args.enabled,
})),
key: createKey('appInstance'),
},
listAppInstances: {
key: createKey('appInstances'),
},
},
deploymentService: {
listEnvironmentDeployments: {
queryOptions: vi.fn((args: QueryOptionsArgs) => ({
queryKey: ['environmentDeployments', args.input],
queryFn: () => Promise.resolve({ data: mockEnvironmentDeployments.data }),
enabled: args.enabled,
})),
key: createKey('environmentDeployments'),
},
},
releaseService: {
deleteRelease: {
mutationOptions: vi.fn(() => ({
mutationFn: mockDeleteRelease,
})),
},
getRelease: {
key: createKey('release'),
},
listReleases: {
key: createKey('releases'),
},
},
},
},
}
})
function createQueryClient() {
return new QueryClient({
defaultOptions: {
mutations: { retry: false },
queries: { retry: false },
},
})
}
function renderWithQueryClient(children: ReactNode, queryClient: QueryClient) {
return render(
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>,
)
}
function createRelease(overrides: Partial<Release> = {}): Release & { id: string } {
return {
id: 'release-1',
appInstanceId: 'app-1',
name: 'Release 1',
createdAt: '2026-05-27T10:00:00Z',
...overrides,
}
}
describe('DeployReleaseMenu', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEnvironmentDeployments.data = []
mockDeleteRelease.mockResolvedValue({})
})
// Scenario: a release with no runtime usage can be deleted after explicit confirmation.
it('should delete release after confirmation when release is not in use', async () => {
const user = userEvent.setup()
const queryClient = createQueryClient()
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
const removeSpy = vi.spyOn(queryClient, 'removeQueries')
const onDeleted = vi.fn()
renderWithQueryClient(
<DeployReleaseMenu
appInstanceId="app-1"
releaseId="release-1"
releaseRows={[createRelease()]}
onDeleted={onDeleted}
/>,
queryClient,
)
await user.click(screen.getByRole('button', { name: 'deployments.versions.moreActions' }))
await waitFor(() => {
expect(screen.getByRole('menuitem', { name: 'deployments.versions.deleteRelease' })).not.toHaveAttribute('aria-disabled', 'true')
})
await user.click(screen.getByRole('menuitem', { name: 'deployments.versions.deleteRelease' }))
expect(screen.getByRole('heading', { name: 'deployments.versions.deleteConfirmTitle' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'deployments.versions.deleteRelease' }))
await waitFor(() => {
expect(mockDeleteRelease).toHaveBeenCalled()
})
expect(mockDeleteRelease.mock.calls[0]?.[0]).toEqual({ params: { releaseId: 'release-1' } })
expect(invalidateSpy).toHaveBeenCalled()
expect(removeSpy).toHaveBeenCalled()
expect(mockToastSuccess).toHaveBeenCalledWith(expect.stringContaining('deployments.versions.deleteSuccess'))
expect(mockToastError).not.toHaveBeenCalled()
expect(onDeleted).toHaveBeenCalledTimes(1)
})
// Scenario: active or pending runtime usage mirrors the backend guard and blocks delete.
it('should disable delete action when release is in use by an environment', async () => {
const user = userEvent.setup()
mockEnvironmentDeployments.data = [
{
appInstanceId: 'app-1',
environment: { id: 'env-1', name: 'Production' },
currentRelease: createRelease(),
status: 'RUNTIME_INSTANCE_STATUS_READY',
},
]
renderWithQueryClient(
<DeployReleaseMenu
appInstanceId="app-1"
releaseId="release-1"
releaseRows={[createRelease()]}
/>,
createQueryClient(),
)
await user.click(screen.getByRole('button', { name: 'deployments.versions.moreActions' }))
await waitFor(() => {
expect(screen.getByRole('menuitem', { name: 'deployments.versions.deleteRelease' })).toHaveAttribute('aria-disabled', 'true')
})
await user.click(screen.getByRole('menuitem', { name: 'deployments.versions.deleteRelease' }))
expect(screen.queryByRole('heading', { name: 'deployments.versions.deleteConfirmTitle' })).not.toBeInTheDocument()
expect(mockDeleteRelease).not.toHaveBeenCalled()
})
})

View File

@ -1,414 +0,0 @@
'use client'
import type { CreateReleaseReply } from '@dify/contracts/enterprise/types.gen'
import type { ButtonProps } from '@langgenius/dify-ui/button'
import type { SourceAppPickerValue } from '../../components/create-instance-modal'
import type { App } from '@/types/app'
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 { Input } from '@langgenius/dify-ui/input'
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import { consoleQuery } from '@/service/client'
import { SourceAppPicker } from '../../components/create-instance-modal'
type ReleaseSourceMode = 'sourceApp' | 'dsl'
const DESCRIPTION_MAX_LENGTH = 512
const DESCRIPTION_WARN_THRESHOLD = 460
const DEFAULT_RELEASE_SOURCE_MODE: ReleaseSourceMode = 'sourceApp'
const DEFAULT_SOURCE_RELEASE_PAGE_SIZE = 1
function encodeUtf8Base64(value: string) {
const bytes = new TextEncoder().encode(value)
const chunkSize = 0x8000
const chunks: string[] = []
for (let offset = 0; offset < bytes.length; offset += chunkSize)
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)))
return btoa(chunks.join(''))
}
function selectedReleaseSourceMode(value: readonly ReleaseSourceMode[] | undefined) {
return value?.[0]
}
export function CreateReleaseControl({ appInstanceId, variant = 'primary', size = 'small', label, className }: {
appInstanceId: string
variant?: ButtonProps['variant']
size?: ButtonProps['size']
label?: string
className?: string
}) {
const { t } = useTranslation('deployments')
const createReleaseFromSourceApp = useMutation(consoleQuery.enterprise.releaseService.createReleaseFromSourceApp.mutationOptions())
const createReleaseFromDsl = useMutation(consoleQuery.enterprise.releaseService.createReleaseFromDsl.mutationOptions())
const [isCreating, setIsCreating] = useState(false)
const [releaseSourceMode, setReleaseSourceMode] = useState<ReleaseSourceMode>(DEFAULT_RELEASE_SOURCE_MODE)
const [sourceApp, setSourceApp] = useState<App>()
const [dslFile, setDslFile] = useState<File>()
const [dslContent, setDslContent] = useState('')
const [isReadingDsl, setIsReadingDsl] = useState(false)
const [dslReadError, setDslReadError] = useState(false)
const [releaseName, setReleaseName] = useState('')
const [releaseNameTouched, setReleaseNameTouched] = useState(false)
const [description, setDescription] = useState('')
const dslReadTokenRef = useRef(0)
const latestReleaseQuery = useQuery(consoleQuery.enterprise.releaseService.listReleases.queryOptions({
input: {
params: { appInstanceId },
query: {
pageNumber: 1,
resultsPerPage: DEFAULT_SOURCE_RELEASE_PAGE_SIZE,
},
},
enabled: isCreating,
}))
const latestSourceAppId = latestReleaseQuery.data?.data?.[0]?.sourceAppId
const defaultSourceAppId = isCreating && latestSourceAppId && !sourceApp ? latestSourceAppId : ''
const defaultSourceAppQuery = useQuery(consoleQuery.apps.byAppId.get.queryOptions({
input: {
params: { app_id: defaultSourceAppId },
},
enabled: Boolean(defaultSourceAppId),
}))
const defaultSourceApp: SourceAppPickerValue | undefined = latestSourceAppId
? {
id: latestSourceAppId,
name: defaultSourceAppQuery.data?.name || latestSourceAppId,
}
: undefined
const selectedSourceApp = sourceApp ?? defaultSourceApp
const selectedSourceAppId = selectedSourceApp?.id
const isCreatePending = createReleaseFromSourceApp.isPending || createReleaseFromDsl.isPending
function resetDslState() {
dslReadTokenRef.current += 1
setDslFile(undefined)
setDslContent('')
setIsReadingDsl(false)
setDslReadError(false)
}
function closeDialog() {
setIsCreating(false)
setReleaseSourceMode(DEFAULT_RELEASE_SOURCE_MODE)
setSourceApp(undefined)
resetDslState()
setReleaseName('')
setReleaseNameTouched(false)
setDescription('')
}
function handleReleaseSourceModeChange(nextMode: ReleaseSourceMode) {
if (nextMode === releaseSourceMode)
return
setReleaseSourceMode(nextMode)
if (nextMode === 'sourceApp')
resetDslState()
else
setSourceApp(undefined)
}
function handleDslFileChange(file?: File) {
const readToken = dslReadTokenRef.current + 1
dslReadTokenRef.current = readToken
setDslFile(file)
setDslContent('')
setIsReadingDsl(false)
setDslReadError(false)
if (!file)
return
setIsReadingDsl(true)
void file.text()
.then((content) => {
if (dslReadTokenRef.current !== readToken)
return
setDslContent(content)
})
.catch(() => {
if (dslReadTokenRef.current !== readToken)
return
setDslReadError(true)
})
.finally(() => {
if (dslReadTokenRef.current !== readToken)
return
setIsReadingDsl(false)
})
}
function handleCreateRelease(form: HTMLFormElement) {
if (isCreatePending)
return
const submittedReleaseName = releaseName.trim()
const releaseDescription = description.trim()
if (!submittedReleaseName) {
setReleaseNameTouched(true)
return
}
const handleSuccess = (response: CreateReleaseReply) => {
if (!response.release?.id) {
toast.error(t('versions.createFailed'))
return
}
const createdName = response.release.name ?? submittedReleaseName
toast.success(t('versions.createSuccess', { name: createdName }))
form.reset()
closeDialog()
}
const handleError = () => {
toast.error(t('versions.createFailed'))
}
if (releaseSourceMode === 'dsl') {
if (!dslContent.trim() || isReadingDsl || dslReadError)
return
createReleaseFromDsl.mutate({
body: {
appInstanceId,
dsl: encodeUtf8Base64(dslContent),
name: submittedReleaseName,
description: releaseDescription || undefined,
createAppInstance: false,
},
}, {
onSuccess: handleSuccess,
onError: handleError,
})
return
}
if (!selectedSourceAppId)
return
createReleaseFromSourceApp.mutate({
body: {
appInstanceId,
sourceAppId: selectedSourceAppId,
name: submittedReleaseName,
description: releaseDescription || undefined,
createAppInstance: false,
},
}, {
onSuccess: handleSuccess,
onError: handleError,
})
}
const descriptionLength = description.length
const isNearLimit = descriptionLength >= DESCRIPTION_WARN_THRESHOLD
const hasReleaseName = Boolean(releaseName.trim())
const releaseNameRequired = releaseNameTouched && !hasReleaseName
const hasDslContent = Boolean(dslContent.trim())
const canCreateFromSourceApp = releaseSourceMode === 'sourceApp' && Boolean(selectedSourceAppId)
const canCreateFromDsl = releaseSourceMode === 'dsl' && hasDslContent && !isReadingDsl && !dslReadError
const canCreate = Boolean(hasReleaseName && !isCreatePending && (canCreateFromSourceApp || canCreateFromDsl))
return (
<>
<Button
size={size}
variant={variant}
className={className}
disabled={isCreatePending}
onClick={() => setIsCreating(true)}
>
{label ?? t('versions.createRelease')}
</Button>
<Dialog
open={isCreating}
onOpenChange={(open) => {
if (!open)
closeDialog()
else
setIsCreating(true)
}}
>
<DialogContent className="w-140 max-w-[calc(100vw-32px)] overflow-hidden p-0">
<DialogCloseButton />
{isCreating && (
<form
noValidate
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">
<div className="flex flex-wrap items-center justify-between gap-3">
<label id="release-source-mode-label" className="system-xs-medium-uppercase text-text-tertiary">
{t('versions.releaseSourceLabel')}
</label>
<SegmentedControl<ReleaseSourceMode>
aria-labelledby="release-source-mode-label"
value={[releaseSourceMode]}
onValueChange={(value) => {
const nextMode = selectedReleaseSourceMode(value)
if (nextMode)
handleReleaseSourceModeChange(nextMode)
}}
className="shrink-0"
>
<SegmentedControlItem value="sourceApp" className="gap-1.5">
<span className="i-ri-apps-2-line size-4 shrink-0" aria-hidden="true" />
<span>{t('versions.sourceAppOption')}</span>
</SegmentedControlItem>
<SegmentedControlItem value="dsl" className="gap-1.5">
<span className="i-ri-upload-cloud-2-line size-4 shrink-0" aria-hidden="true" />
<span>{t('versions.manualDslOption')}</span>
</SegmentedControlItem>
</SegmentedControl>
</div>
<div className="min-h-12">
{releaseSourceMode === 'sourceApp'
? (
<div className="flex min-h-12 items-center">
<SourceAppPicker
value={selectedSourceApp}
onChange={setSourceApp}
ariaLabel={t('versions.sourceAppOption')}
/>
</div>
)
: (
<div className="flex min-h-12 flex-col gap-2">
<Uploader
file={dslFile}
updateFile={handleDslFileChange}
className="mt-0"
/>
{isReadingDsl && (
<div className="system-xs-regular text-text-tertiary">
{t('versions.dslReading')}
</div>
)}
{dslReadError && (
<div role="alert" className="system-xs-regular text-util-colors-red-red-600">
{t('versions.dslReadFailed')}
</div>
)}
</div>
)}
</div>
</div>
<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}
value={releaseName}
aria-invalid={releaseNameRequired || undefined}
aria-describedby={releaseNameRequired ? 'release-name-error' : undefined}
onBlur={() => setReleaseNameTouched(true)}
onChange={(event) => {
setReleaseName(event.target.value)
if (releaseNameTouched && event.target.value.trim())
setReleaseNameTouched(false)
}}
autoFocus
className="h-9"
/>
{releaseNameRequired && (
<div id="release-name-error" role="alert" className="system-xs-regular text-text-destructive">
{t('versions.releaseNameRequired')}
</div>
)}
</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={isCreatePending}
onClick={closeDialog}
>
{t('versions.cancelCreate')}
</Button>
<Button
type="submit"
variant="primary"
className="min-w-22"
disabled={!canCreate}
>
{isCreatePending ? t('versions.creating') : t('versions.create')}
</Button>
</div>
</div>
</form>
)}
</DialogContent>
</Dialog>
</>
)
}

View File

@ -1,373 +0,0 @@
'use client'
import type {
Environment,
EnvironmentDeployment,
Release,
} 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 { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery, useQueryClient } 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 { releaseLabel } from '../../release'
import { releaseDeploymentAction } from '../../release-action'
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../table-styles'
import { exportReleaseDsl } from './release-dsl-export'
type EnvironmentOption = Environment & {
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'
}
function releaseUsageCount(releaseId: string, deploymentRows: EnvironmentDeployment[]) {
const environmentIds = new Set<string>()
deploymentRows.forEach((row) => {
const usesRelease = row.currentRelease?.id === releaseId || row.desiredRelease?.id === releaseId
const envId = environmentId(row.environment)
if (usesRelease && envId)
environmentIds.add(envId)
})
return environmentIds.size
}
export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows, onDeleted }: {
appInstanceId: string
releaseId: string
releaseRows: Release[]
onDeleted?: () => void
}) {
const { t } = useTranslation('deployments')
const queryClient = useQueryClient()
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const [open, setOpen] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isExportingDsl, setIsExportingDsl] = useState(false)
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
enabled: open,
}))
const { data: appInstanceData } = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: {
params: { appInstanceId },
},
enabled: open,
}))
const deleteRelease = useMutation(consoleQuery.enterprise.releaseService.deleteRelease.mutationOptions())
const environments: EnvironmentOption[] = (environmentDeploymentsQuery.data?.data ?? [])
.map(row => row.environment)
.filter((env): env is EnvironmentOption => Boolean(env?.id))
const deploymentRows = environmentDeploymentsQuery.data?.data?.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? []
const targetRelease = releaseRows.find((release): release is Release & { id: string } => release.id === releaseId)
const appInstanceName = appInstanceData?.appInstance?.name
if (!targetRelease)
return null
const deleteUsageCount = releaseUsageCount(releaseId, deploymentRows)
const isCheckingDeleteUsage = open && environmentDeploymentsQuery.isLoading
const isReleaseInUse = deleteUsageCount > 0
const isDeletingRelease = deleteRelease.isPending
const deleteDisabledReason = isCheckingDeleteUsage
? t('versions.disabledReason.checkingDeployments')
: isReleaseInUse
? t('versions.disabledReason.releaseInUse', { count: deleteUsageCount })
: undefined
const deleteActionDisabled = isDeletingRelease || isCheckingDeleteUsage || isReleaseInUse
const handleExportDsl = async () => {
if (isExportingDsl)
return
setIsExportingDsl(true)
try {
await exportReleaseDsl({ release: targetRelease, appInstanceName })
setOpen(false)
}
catch {
toast.error(t('versions.exportDslFailed'))
}
finally {
setIsExportingDsl(false)
}
}
function invalidateReleaseData() {
return Promise.all([
queryClient.invalidateQueries({
queryKey: consoleQuery.enterprise.releaseService.listReleases.key({
type: 'query',
input: { params: { appInstanceId } },
}),
}),
queryClient.invalidateQueries({
queryKey: consoleQuery.enterprise.appInstanceService.getAppInstance.key({
type: 'query',
input: { params: { appInstanceId } },
}),
}),
queryClient.invalidateQueries({
queryKey: consoleQuery.enterprise.appInstanceService.listAppInstances.key(),
}),
queryClient.invalidateQueries({
queryKey: consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.key({
type: 'query',
input: { params: { appInstanceId } },
}),
}),
])
}
function handleDeleteRelease() {
if (deleteActionDisabled)
return
deleteRelease.mutate(
{
params: {
releaseId,
},
},
{
onSuccess: async () => {
await invalidateReleaseData()
queryClient.removeQueries({
queryKey: consoleQuery.enterprise.releaseService.getRelease.key({
type: 'query',
input: { params: { releaseId } },
}),
})
setShowDeleteConfirm(false)
toast.success(t('versions.deleteSuccess', { name: releaseLabel(targetRelease) }))
onDeleted?.()
},
onError: () => {
toast.error(t('versions.deleteFailed'))
},
},
)
}
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_TABLE_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">
<DropdownMenuItem
disabled={isExportingDsl}
aria-disabled={isExportingDsl}
className={cn(
'gap-2 px-3',
isExportingDsl && 'cursor-not-allowed opacity-60',
)}
onClick={handleExportDsl}
>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{isExportingDsl ? t('versions.exportingDsl') : t('versions.exportDsl')}
</span>
</DropdownMenuItem>
{groupedRows.length > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
{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>
))}
<div className="my-1 border-t border-divider-subtle" aria-hidden />
<DropdownMenuItem
variant="destructive"
disabled={deleteActionDisabled}
title={deleteDisabledReason}
aria-disabled={deleteActionDisabled}
className={cn(
'gap-2 px-3',
deleteActionDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={() => {
if (deleteActionDisabled)
return
setOpen(false)
setShowDeleteConfirm(true)
}}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="system-sm-regular">{t('versions.deleteRelease')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
)}
</DropdownMenu>
<AlertDialog
open={showDeleteConfirm}
onOpenChange={(nextOpen) => {
if (isDeletingRelease)
return
setShowDeleteConfirm(nextOpen)
}}
>
<AlertDialogContent className="w-120">
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('versions.deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-sm-regular text-text-tertiary">
{t('versions.deleteConfirmDesc', { name: releaseLabel(targetRelease) })}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-3">
<AlertDialogCancelButton variant="secondary" disabled={isDeletingRelease}>
{t('versions.cancelDelete')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={isDeletingRelease}
disabled={isDeletingRelease}
onClick={handleDeleteRelease}
>
{t('versions.deleteRelease')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@ -1,48 +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 text-xs',
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" />}
<span>{item.environmentName}</span>
<span className="opacity-70">·</span>
<span>{statusLabel}</span>
</span>
)}
/>
<TooltipContent>
{statusLabel}
{' · '}
{item.environmentName}
</TooltipContent>
</Tooltip>
)
}

View File

@ -1,50 +0,0 @@
import type { EnvironmentDeployment, Release } 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') || normalized.includes('invalid'))
return 'failed'
return 'active'
}
function dedupeReleaseDeployments(items: ReleaseDeployment[]) {
return items.filter((item, index) => {
return items.findIndex(candidate => candidate.environmentId === item.environmentId) === index
})
}
export function getReleaseDeployments(row: Release, deploymentRows: EnvironmentDeployment[]) {
const releaseId = row.id
if (!releaseId)
return []
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)
}

View File

@ -1,43 +0,0 @@
import type { Release } from '@dify/contracts/enterprise/types.gen'
import { get } from '@/service/base'
import { downloadBlob } from '@/utils/download'
const YAML_EXTENSION_PATTERN = /\.ya?ml$/i
const INVALID_FILENAME_CHARS_PATTERN = /[\\/:*?"<>|]+/g
const FILENAME_SEPARATOR_PATTERN = /[\s-]+/g
function sanitizeFileNamePart(value?: string) {
return value
?.trim()
.replace(YAML_EXTENSION_PATTERN, '')
.replace(INVALID_FILENAME_CHARS_PATTERN, '-')
.replace(FILENAME_SEPARATOR_PATTERN, '-')
.replace(/^-+|-+$/g, '') ?? ''
}
function releaseDslFileName({ release, appInstanceName }: {
release: Release
appInstanceName?: string
}) {
const projectName = sanitizeFileNamePart(appInstanceName)
const releaseName = sanitizeFileNamePart(release.name || release.id) || 'release'
const baseName = [projectName, releaseName].filter(Boolean).join('-')
return `${baseName}.yaml`
}
export async function exportReleaseDsl({ release, appInstanceName }: {
release: Release & { id: string }
appInstanceName?: string
}) {
const response = await get<Response>(
`enterprise/app-deploy/releases/${encodeURIComponent(release.id)}/dsl`,
{},
{ needAllResponseContent: true, silent: true },
)
const data = await response.blob()
downloadBlob({
data,
fileName: releaseDslFileName({ release, appInstanceName }),
})
}

View File

@ -1,455 +0,0 @@
'use client'
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
import type { ReleaseDeployment } from './release-deployments'
import { Pagination } from '@langgenius/dify-ui/pagination'
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 { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { RELEASE_HISTORY_PAGE_SIZE } from '../../data'
import {
formatDate,
releaseCommit,
releaseLabel,
} from '../../release'
import { isUndeployedDeploymentRow } from '../../runtime-status'
import {
DetailEmptyState,
DetailListState,
} from '../common'
import {
DetailTable,
DetailTableBody,
DetailTableCard,
DetailTableCardList,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../table'
import {
RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from '../table-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 = Release & {
id: string
}
function hasReleaseId(row: Release): row is ReleaseRowWithId {
return Boolean(row.id)
}
function ReleaseHistoryTableSkeleton() {
const { t } = useTranslation('deployments')
return (
<>
<DetailTableCardList className="pc:hidden">
{RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => (
<DetailTableCard key={key}>
<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>
</DetailTableCard>
))}
</DetailTableCardList>
<div className="hidden pc:block">
<DetailTable className="min-w-[840px]">
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}>{t('versions.col.release')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.sourceApp}>{t('versions.col.sourceApp')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt}>{t('versions.col.createdAt')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author}>{t('versions.col.author')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}>{t('versions.col.deployedTo')}</DetailTableHead>
<DetailTableHead className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t('versions.col.action')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
{RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => (
<DetailTableRow key={key}>
<DetailTableCell>
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
</DetailTableCell>
<DetailTableCell>
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
</DetailTableCell>
<DetailTableCell>
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
</DetailTableCell>
<DetailTableCell>
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
</DetailTableCell>
<DetailTableCell>
<ReleaseDeploymentsSkeleton />
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</DetailTableCell>
</DetailTableRow>
))}
</DetailTableBody>
</DetailTable>
</div>
</>
)
}
function ReleaseHistoryMobileRows({ appInstanceId, releaseRows, deploymentRows, deployedToLoading, deployedToHasError, onReleaseDeleted }: {
appInstanceId: string
releaseRows: ReleaseRowWithId[]
deploymentRows: EnvironmentDeployment[]
deployedToLoading?: boolean
deployedToHasError?: boolean
onReleaseDeleted?: () => void
}) {
const { t } = useTranslation('deployments')
return (
<DetailTableCardList className="pc:hidden">
{releaseRows.map((row) => {
const release = row
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
const hasDeployments = releaseDeployments.length > 0 || deployedToLoading || deployedToHasError
return (
<DetailTableCard key={release.id}>
<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 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>
{release.sourceAppId && (
<>
<span aria-hidden>·</span>
<span className="inline-flex max-w-full min-w-0 items-baseline gap-1">
<span className="shrink-0">{t('versions.col.sourceApp')}</span>
<SourceAppCell sourceAppId={release.sourceAppId} />
</span>
</>
)}
</div>
</div>
<div className="flex shrink-0 justify-end gap-1">
<DeployReleaseMenu
releaseId={release.id}
appInstanceId={appInstanceId}
releaseRows={releaseRows}
onDeleted={onReleaseDeleted}
/>
</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>
</DetailTableCard>
)
})}
</DetailTableCardList>
)
}
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="text-text-tertiary">{loadFailedLabel}</span>
if (items.length === 0)
return <span className="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 SourceAppCell({ sourceAppId }: {
sourceAppId?: string
}) {
const sourceAppQuery = useQuery(consoleQuery.apps.byAppId.get.queryOptions({
input: {
params: { app_id: sourceAppId ?? '' },
},
enabled: Boolean(sourceAppId),
}))
if (!sourceAppId)
return <span className="text-text-quaternary"></span>
const sourceAppName = sourceAppQuery.data?.name
const label = sourceAppName || sourceAppId
const title = sourceAppName ? `${sourceAppName} (${sourceAppId})` : sourceAppId
return (
<Link
href={`/app/${encodeURIComponent(sourceAppId)}/workflow`}
title={title}
className="inline-flex max-w-full min-w-0 items-center gap-1 text-text-secondary hover:text-text-primary"
>
<span className="min-w-0 truncate">{label}</span>
<span className="i-ri-external-link-line size-3.5 shrink-0 text-text-quaternary" aria-hidden="true" />
</Link>
)
}
function ReleaseHistoryRows({ appInstanceId, releaseRows, deploymentRows, deployedToLoading, deployedToHasError, onReleaseDeleted }: {
appInstanceId: string
releaseRows: ReleaseRowWithId[]
deploymentRows: EnvironmentDeployment[]
deployedToLoading?: boolean
deployedToHasError?: boolean
onReleaseDeleted?: () => void
}) {
const { t } = useTranslation('deployments')
return (
<>
<ReleaseHistoryMobileRows
appInstanceId={appInstanceId}
releaseRows={releaseRows}
deploymentRows={deploymentRows}
deployedToLoading={deployedToLoading}
deployedToHasError={deployedToHasError}
onReleaseDeleted={onReleaseDeleted}
/>
<div className="hidden pc:block">
<DetailTable className="min-w-[840px]">
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}>{t('versions.col.release')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.sourceApp}>{t('versions.col.sourceApp')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt}>{t('versions.col.createdAt')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author}>{t('versions.col.author')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}>{t('versions.col.deployedTo')}</DetailTableHead>
<DetailTableHead className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t('versions.col.action')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
{releaseRows.map((row) => {
const release = row
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
return (
<DetailTableRow key={release.id}>
<DetailTableCell>
<Tooltip>
<TooltipTrigger
render={(
<span className="inline-flex max-w-full cursor-default truncate text-text-primary">
{releaseLabel(release)}
</span>
)}
/>
<TooltipContent>
{t('versions.commitTooltip', { commit: releaseCommit(release) })}
</TooltipContent>
</Tooltip>
</DetailTableCell>
<DetailTableCell>
<SourceAppCell sourceAppId={release.sourceAppId} />
</DetailTableCell>
<DetailTableCell className="text-text-secondary">
<CreatedAtCell createdAt={release.createdAt} />
</DetailTableCell>
<DetailTableCell className="truncate text-text-secondary">
{row.createdBy?.name ?? '—'}
</DetailTableCell>
<DetailTableCell>
<div className="flex flex-wrap gap-1">
<ReleaseDeploymentsContent
items={releaseDeployments}
isLoading={deployedToLoading}
hasError={deployedToHasError}
loadFailedLabel={t('common.loadFailed')}
/>
</div>
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<DeployReleaseMenu
releaseId={release.id}
appInstanceId={appInstanceId}
releaseRows={releaseRows}
onDeleted={onReleaseDeleted}
/>
</div>
</DetailTableCell>
</DetailTableRow>
)
})}
</DetailTableBody>
</DetailTable>
</div>
</>
)
}
export function ReleaseHistoryTable({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const [currentPage, setCurrentPage] = useState(0)
const input = { params: { appInstanceId } }
const releaseHistoryQuery = useQuery(consoleQuery.enterprise.releaseService.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 totalReleasePages = Math.ceil(totalReleases / RELEASE_HISTORY_PAGE_SIZE)
const shouldLoadRuntimeInstances = releaseRows.length > 0
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input,
enabled: shouldLoadRuntimeInstances,
}))
const isLoading = releaseHistoryQuery.isLoading
const hasError = releaseHistoryQuery.isError
const deployedToLoading = shouldLoadRuntimeInstances && environmentDeploymentsQuery.isLoading
const deployedToHasError = shouldLoadRuntimeInstances && environmentDeploymentsQuery.isError
const deploymentRows = environmentDeploymentsQuery.data?.data?.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? []
const handleReleaseDeleted = () => {
if (releaseRows.length === 1 && currentPage > 0)
setCurrentPage(page => Math.max(page - 1, 0))
}
if (isLoading) {
return <ReleaseHistoryTableSkeleton />
}
if (hasError) {
return (
<DetailListState>
{t('common.loadFailed')}
</DetailListState>
)
}
if (releaseRows.length === 0) {
return (
<DetailEmptyState
icon="i-ri-stack-line"
title={t('versions.emptyTitle')}
description={t('versions.emptyDescription')}
/>
)
}
return (
<div className="flex flex-col gap-3">
<ReleaseHistoryRows
appInstanceId={appInstanceId}
releaseRows={releaseRows}
deploymentRows={deploymentRows}
deployedToLoading={deployedToLoading}
deployedToHasError={deployedToHasError}
onReleaseDeleted={handleReleaseDeleted}
/>
{totalReleases > RELEASE_HISTORY_PAGE_SIZE && (
<Pagination
className="border-y border-divider-subtle"
page={currentPage + 1}
totalPages={totalReleasePages}
onPageChange={page => setCurrentPage(page - 1)}
/>
)}
</div>
)
}

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