mirror of
https://github.com/langgenius/dify.git
synced 2026-05-30 13:47:52 +08:00
Compare commits
3 Commits
4-27-app-d
...
codex/dify
| Author | SHA1 | Date | |
|---|---|---|---|
| 8685658bcd | |||
| 0b60338ad5 | |||
| 91ac465982 |
@ -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
|
||||
|
||||
|
||||
8
.codex/environments/environment.toml
Normal file
8
.codex/environments/environment.toml
Normal file
@ -0,0 +1,8 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "dify"
|
||||
|
||||
[setup]
|
||||
script = '''
|
||||
pnpm install --frozen-lockfile --prefer-offline
|
||||
'''
|
||||
1
.github/workflows/build-push.yml
vendored
1
.github/workflows/build-push.yml
vendored
@ -9,7 +9,6 @@ on:
|
||||
- "release/e-*"
|
||||
- "hotfix/**"
|
||||
- "feat/hitl-backend"
|
||||
- "4-27-app-deploy"
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
|
||||
@ -467,7 +467,8 @@ class AppListApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
def get(self):
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session):
|
||||
"""Get app list"""
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
@ -504,7 +505,7 @@ class AppListApi(Resource):
|
||||
draft_trigger_app_ids: set[str] = set()
|
||||
if workflow_capable_app_ids:
|
||||
draft_workflows = (
|
||||
db.session.execute(
|
||||
session.execute(
|
||||
select(Workflow).where(
|
||||
Workflow.version == Workflow.VERSION_DRAFT,
|
||||
Workflow.app_id.in_(workflow_capable_app_ids),
|
||||
|
||||
@ -2,6 +2,7 @@ from collections.abc import Sequence
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import register_enum_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
@ -11,6 +12,7 @@ from controllers.console.app.error import (
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
)
|
||||
from controllers.console.app.wraps import with_session
|
||||
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
|
||||
@ -19,7 +21,6 @@ from core.helper.code_executor.javascript.javascript_code_provider import Javasc
|
||||
from core.helper.code_executor.python3.python3_code_provider import Python3CodeProvider
|
||||
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
|
||||
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 login_required
|
||||
@ -158,7 +159,8 @@ class InstructionGenerateApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str):
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, current_tenant_id: str):
|
||||
args = InstructionGeneratePayload.model_validate(console_ns.payload)
|
||||
providers: list[type[CodeNodeProvider]] = [Python3CodeProvider, JavascriptCodeProvider]
|
||||
code_provider: type[CodeNodeProvider] | None = next(
|
||||
@ -168,10 +170,10 @@ class InstructionGenerateApi(Resource):
|
||||
try:
|
||||
# Generate from nothing for a workflow node
|
||||
if (args.current in (code_template, "")) and args.node_id != "":
|
||||
app = db.session.get(App, args.flow_id)
|
||||
app = session.get(App, args.flow_id)
|
||||
if not app:
|
||||
return {"error": f"app {args.flow_id} not found"}, 400
|
||||
workflow = WorkflowService().get_draft_workflow(app_model=app)
|
||||
workflow = WorkflowService().get_draft_workflow(app_model=app, session=session)
|
||||
if not workflow:
|
||||
return {"error": f"workflow {args.flow_id} not found"}, 400
|
||||
nodes: Sequence = workflow.graph_dict["nodes"]
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
@ -15114,7 +15114,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 |
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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
|
||||
|
||||
@ -140,14 +140,21 @@ class WorkflowService:
|
||||
)
|
||||
return db.session.execute(stmt).scalar_one()
|
||||
|
||||
def get_draft_workflow(self, app_model: App, workflow_id: str | None = None) -> Workflow | None:
|
||||
def get_draft_workflow(
|
||||
self, app_model: App, workflow_id: str | None = None, session: Session | None = None
|
||||
) -> Workflow | None:
|
||||
"""
|
||||
Get draft workflow
|
||||
|
||||
When ``session`` is provided, reuse it so callers that already hold a
|
||||
Session avoid checking out an extra request-scoped ``db.session``
|
||||
connection. Falls back to ``db.session`` for backward compatibility.
|
||||
"""
|
||||
if workflow_id:
|
||||
return self.get_published_workflow_by_id(app_model, workflow_id)
|
||||
return self.get_published_workflow_by_id(app_model, workflow_id, session=session)
|
||||
# fetch draft workflow by app_model
|
||||
workflow = db.session.scalar(
|
||||
bind = session if session is not None else db.session
|
||||
workflow = bind.scalar(
|
||||
select(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == app_model.tenant_id,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -7,6 +7,7 @@ from importlib import util
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask.views import MethodView
|
||||
@ -18,6 +19,15 @@ if not hasattr(builtins, "MethodView"):
|
||||
builtins.MethodView = MethodView # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def _unwrap(func):
|
||||
bound_self = getattr(func, "__self__", None)
|
||||
while hasattr(func, "__wrapped__"):
|
||||
func = func.__wrapped__
|
||||
if bound_self is not None:
|
||||
return func.__get__(bound_self, bound_self.__class__)
|
||||
return func
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_module():
|
||||
module_name = "controllers.console.app.app"
|
||||
@ -395,3 +405,46 @@ def test_app_pagination_aliases_per_page_and_has_next(app_models):
|
||||
assert len(serialized["data"]) == 2
|
||||
assert serialized["data"][0]["icon_url"] == "signed:first-icon"
|
||||
assert serialized["data"][1]["icon_url"] is None
|
||||
|
||||
|
||||
def test_app_list_uses_injected_session_for_draft_workflows(app, app_module, monkeypatch):
|
||||
api = app_module.AppListApi()
|
||||
method = _unwrap(api.get)
|
||||
current_user = SimpleNamespace(id="user-1")
|
||||
app_item = SimpleNamespace(
|
||||
id="app-1",
|
||||
name="Workflow App",
|
||||
desc_or_prompt="Summary",
|
||||
mode="workflow",
|
||||
mode_compatible_with_agent="workflow",
|
||||
)
|
||||
app_pagination = SimpleNamespace(page=1, per_page=20, total=1, has_next=False, items=[app_item])
|
||||
workflow = SimpleNamespace(
|
||||
id="workflow-1",
|
||||
app_id="app-1",
|
||||
walk_nodes=lambda: iter([("trigger-1", {"type": "trigger-webhook"})]),
|
||||
)
|
||||
session = MagicMock()
|
||||
session.execute.return_value.scalars.return_value.all.return_value = [workflow]
|
||||
scoped_session = SimpleNamespace(execute=MagicMock(side_effect=AssertionError("db.session should not be used")))
|
||||
|
||||
monkeypatch.setattr(app_module, "current_account_with_tenant", lambda: (current_user, "tenant-1"))
|
||||
monkeypatch.setattr(
|
||||
app_module,
|
||||
"AppService",
|
||||
lambda: SimpleNamespace(get_paginate_apps=lambda *_args, **_kwargs: app_pagination),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_module,
|
||||
"FeatureService",
|
||||
SimpleNamespace(get_system_features=lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False))),
|
||||
)
|
||||
monkeypatch.setattr(app_module, "db", SimpleNamespace(session=scoped_session))
|
||||
|
||||
with app.test_request_context("/console/api/apps?page=1&limit=20", method="GET"):
|
||||
response, status = method(session)
|
||||
|
||||
assert status == 200
|
||||
assert response["data"][0]["has_draft_trigger"] is True
|
||||
session.execute.assert_called_once()
|
||||
scoped_session.execute.assert_not_called()
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@ -24,10 +25,17 @@ def _model_config_payload():
|
||||
|
||||
def _install_workflow_service(monkeypatch: pytest.MonkeyPatch, workflow):
|
||||
class _Service:
|
||||
def get_draft_workflow(self, app_model):
|
||||
app_model = None
|
||||
session = None
|
||||
|
||||
def get_draft_workflow(self, app_model, session=None):
|
||||
self.app_model = app_model
|
||||
self.session = session
|
||||
return workflow
|
||||
|
||||
monkeypatch.setattr(generator_module, "WorkflowService", lambda: _Service())
|
||||
service = _Service()
|
||||
monkeypatch.setattr(generator_module, "WorkflowService", lambda: service)
|
||||
return service
|
||||
|
||||
|
||||
def test_rule_generate_success(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@ -68,7 +76,8 @@ def test_instruction_generate_app_not_found(app, monkeypatch: pytest.MonkeyPatch
|
||||
api = generator_module.InstructionGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: None))
|
||||
session = MagicMock()
|
||||
session.get.return_value = None
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/instruction-generate",
|
||||
@ -80,10 +89,11 @@ def test_instruction_generate_app_not_found(app, monkeypatch: pytest.MonkeyPatch
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response, status = method("t1")
|
||||
response, status = method(session, "t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "app app-1 not found"
|
||||
session.get.assert_called_once_with(generator_module.App, "app-1")
|
||||
|
||||
|
||||
def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@ -91,7 +101,7 @@ def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.Monkey
|
||||
method = _unwrap(api.post)
|
||||
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
|
||||
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
|
||||
_install_workflow_service(monkeypatch, workflow=None)
|
||||
|
||||
with app.test_request_context(
|
||||
@ -104,7 +114,7 @@ def test_instruction_generate_workflow_not_found(app, monkeypatch: pytest.Monkey
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response, status = method("t1")
|
||||
response, status = method(session, "t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "workflow app-1 not found"
|
||||
@ -115,7 +125,7 @@ def test_instruction_generate_node_missing(app, monkeypatch: pytest.MonkeyPatch)
|
||||
method = _unwrap(api.post)
|
||||
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
|
||||
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
|
||||
|
||||
workflow = SimpleNamespace(graph_dict={"nodes": []})
|
||||
_install_workflow_service(monkeypatch, workflow=workflow)
|
||||
@ -130,7 +140,7 @@ def test_instruction_generate_node_missing(app, monkeypatch: pytest.MonkeyPatch)
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response, status = method("t1")
|
||||
response, status = method(session, "t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "node node-1 not found"
|
||||
@ -141,7 +151,7 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
|
||||
method = _unwrap(api.post)
|
||||
|
||||
app_model = SimpleNamespace(id="app-1")
|
||||
monkeypatch.setattr(generator_module.db, "session", SimpleNamespace(get=lambda *_args, **_kwargs: app_model))
|
||||
session = SimpleNamespace(get=lambda *_args, **_kwargs: app_model)
|
||||
|
||||
workflow = SimpleNamespace(
|
||||
graph_dict={
|
||||
@ -150,7 +160,7 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
|
||||
]
|
||||
}
|
||||
)
|
||||
_install_workflow_service(monkeypatch, workflow=workflow)
|
||||
workflow_service = _install_workflow_service(monkeypatch, workflow=workflow)
|
||||
monkeypatch.setattr(generator_module.LLMGenerator, "generate_code", lambda **_kwargs: {"code": "x"})
|
||||
|
||||
with app.test_request_context(
|
||||
@ -163,14 +173,17 @@ def test_instruction_generate_code_node(app, monkeypatch: pytest.MonkeyPatch) ->
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response = method("t1")
|
||||
response = method(session, "t1")
|
||||
|
||||
assert response == {"code": "x"}
|
||||
assert workflow_service.app_model is app_model
|
||||
assert workflow_service.session is session
|
||||
|
||||
|
||||
def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.InstructionGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
session = SimpleNamespace()
|
||||
|
||||
monkeypatch.setattr(
|
||||
generator_module.LLMGenerator,
|
||||
@ -189,7 +202,7 @@ def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response = method("t1")
|
||||
response = method(session, "t1")
|
||||
|
||||
assert response == {"instruction": "ok"}
|
||||
|
||||
@ -197,6 +210,7 @@ def test_instruction_generate_legacy_modify(app, monkeypatch: pytest.MonkeyPatch
|
||||
def test_instruction_generate_incompatible_params(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.InstructionGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
session = SimpleNamespace()
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/instruction-generate",
|
||||
@ -209,7 +223,7 @@ def test_instruction_generate_incompatible_params(app, monkeypatch: pytest.Monke
|
||||
"model_config": _model_config_payload(),
|
||||
},
|
||||
):
|
||||
response, status = method("t1")
|
||||
response, status = method(session, "t1")
|
||||
|
||||
assert status == 400
|
||||
assert response["error"] == "incompatible parameters"
|
||||
|
||||
@ -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"]
|
||||
@ -346,6 +346,19 @@ class TestWorkflowService:
|
||||
|
||||
assert result == mock_workflow
|
||||
|
||||
def test_get_draft_workflow_uses_provided_session(self, workflow_service, mock_db_session):
|
||||
"""Test get_draft_workflow can reuse an injected SQLAlchemy session."""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock()
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = mock_workflow
|
||||
|
||||
result = workflow_service.get_draft_workflow(app, session=session)
|
||||
|
||||
assert result == mock_workflow
|
||||
session.scalar.assert_called_once()
|
||||
mock_db_session.session.scalar.assert_not_called()
|
||||
|
||||
def test_get_draft_workflow_returns_none(self, workflow_service, mock_db_session):
|
||||
"""Test get_draft_workflow returns None when no draft exists."""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
@ -370,6 +383,21 @@ class TestWorkflowService:
|
||||
|
||||
assert result == mock_workflow
|
||||
|
||||
def test_get_draft_workflow_with_workflow_id_reuses_provided_session(self, workflow_service):
|
||||
"""Test get_draft_workflow passes an injected session to published workflow lookup."""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
workflow_id = "workflow-123"
|
||||
session = MagicMock()
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(version="v1")
|
||||
|
||||
with patch.object(
|
||||
workflow_service, "get_published_workflow_by_id", return_value=mock_workflow
|
||||
) as mock_get_published:
|
||||
result = workflow_service.get_draft_workflow(app, workflow_id=workflow_id, session=session)
|
||||
|
||||
assert result == mock_workflow
|
||||
mock_get_published.assert_called_once_with(app, workflow_id, session=session)
|
||||
|
||||
# ==================== Get Published Workflow Tests ====================
|
||||
# These tests verify retrieval of published workflows (versioned snapshots)
|
||||
|
||||
|
||||
@ -5052,11 +5052,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/features/deployments/detail/versions-tab/release-dsl-export.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/hooks/use-async-window-open.spec.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 6
|
||||
@ -5150,6 +5145,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/models/access-control.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/models/app.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 2
|
||||
@ -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
|
||||
@ -5557,6 +5562,11 @@
|
||||
"count": 17
|
||||
}
|
||||
},
|
||||
"web/utils/clipboard.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/utils/completion-params.spec.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -4,505 +4,135 @@ 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,
|
||||
zAppInstanceServiceCreateAppInstanceBody,
|
||||
zAppInstanceServiceCreateAppInstanceResponse,
|
||||
zAppInstanceServiceDeleteAppInstancePath,
|
||||
zAppInstanceServiceDeleteAppInstanceResponse,
|
||||
zAppInstanceServiceGetAppInstancePath,
|
||||
zAppInstanceServiceGetAppInstanceResponse,
|
||||
zAppInstanceServiceListAppInstancesQuery,
|
||||
zAppInstanceServiceListAppInstancesResponse,
|
||||
zAppInstanceServiceUpdateAppInstanceBody,
|
||||
zAppInstanceServiceUpdateAppInstancePath,
|
||||
zAppInstanceServiceUpdateAppInstanceResponse,
|
||||
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,
|
||||
zConsoleSsoOAuth2LoginResponse,
|
||||
zConsoleSsoOidcLoginResponse,
|
||||
zConsoleSsoSamlLoginResponse,
|
||||
zWebAppAuthGetGroupSubjectsQuery,
|
||||
zWebAppAuthGetGroupSubjectsResponse,
|
||||
zWebAppAuthGetWebAppAccessModeQuery,
|
||||
zWebAppAuthGetWebAppAccessModeResponse,
|
||||
zWebAppAuthGetWebAppWhitelistSubjectsQuery,
|
||||
zWebAppAuthGetWebAppWhitelistSubjectsResponse,
|
||||
zWebAppAuthIsUserAllowedToAccessWebAppQuery,
|
||||
zWebAppAuthIsUserAllowedToAccessWebAppResponse,
|
||||
zWebAppAuthSearchForWhilteListCandidatesQuery,
|
||||
zWebAppAuthSearchForWhilteListCandidatesResponse,
|
||||
zWebAppAuthUpdateWebAppWhitelistSubjectsBody,
|
||||
zWebAppAuthUpdateWebAppWhitelistSubjectsResponse,
|
||||
} from './zod.gen'
|
||||
|
||||
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
|
||||
export const oAuth2Login = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AccessService_GetAccessChannels',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/access-channels',
|
||||
tags: ['AccessService'],
|
||||
operationId: 'ConsoleSSO_OAuth2Login',
|
||||
path: '/enterprise/sso/oauth2/login',
|
||||
tags: ['ConsoleSSO'],
|
||||
})
|
||||
.input(z.object({ params: zAccessServiceGetAccessChannelsPath }))
|
||||
.output(zAccessServiceGetAccessChannelsResponse)
|
||||
.output(zConsoleSsoOAuth2LoginResponse)
|
||||
|
||||
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
|
||||
export const oidcLogin = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AccessService_GetAccessPolicy',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/access-policy',
|
||||
tags: ['AccessService'],
|
||||
operationId: 'ConsoleSSO_OIDCLogin',
|
||||
path: '/enterprise/sso/oidc/login',
|
||||
tags: ['ConsoleSSO'],
|
||||
})
|
||||
.input(z.object({ params: zAccessServiceGetAccessPolicyPath }))
|
||||
.output(zAccessServiceGetAccessPolicyResponse)
|
||||
.output(zConsoleSsoOidcLoginResponse)
|
||||
|
||||
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
|
||||
export const samlLogin = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AccessService_ListApiKeys',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/api-keys',
|
||||
tags: ['AccessService'],
|
||||
operationId: 'ConsoleSSO_SAMLLogin',
|
||||
path: '/enterprise/sso/saml/login',
|
||||
tags: ['ConsoleSSO'],
|
||||
})
|
||||
.input(z.object({ params: zAccessServiceListApiKeysPath }))
|
||||
.output(zAccessServiceListApiKeysResponse)
|
||||
.output(zConsoleSsoSamlLoginResponse)
|
||||
|
||||
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 consoleSso = {
|
||||
oAuth2Login,
|
||||
oidcLogin,
|
||||
samlLogin,
|
||||
}
|
||||
|
||||
export const listAppInstances = oc
|
||||
export const getWebAppAccessMode = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AppInstanceService_ListAppInstances',
|
||||
path: '/enterprise/app-deploy/app-instances',
|
||||
tags: ['AppInstanceService'],
|
||||
operationId: 'WebAppAuth_GetWebAppAccessMode',
|
||||
path: '/enterprise/webapp/app/access-mode',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(z.object({ query: zAppInstanceServiceListAppInstancesQuery.optional() }))
|
||||
.output(zAppInstanceServiceListAppInstancesResponse)
|
||||
.input(z.object({ query: zWebAppAuthGetWebAppAccessModeQuery.optional() }))
|
||||
.output(zWebAppAuthGetWebAppAccessModeResponse)
|
||||
|
||||
export const createAppInstance = oc
|
||||
export const updateWebAppWhitelistSubjects = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'AppInstanceService_CreateAppInstance',
|
||||
path: '/enterprise/app-deploy/app-instances',
|
||||
tags: ['AppInstanceService'],
|
||||
operationId: 'WebAppAuth_UpdateWebAppWhitelistSubjects',
|
||||
path: '/enterprise/webapp/app/access-mode',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(z.object({ body: zAppInstanceServiceCreateAppInstanceBody }))
|
||||
.output(zAppInstanceServiceCreateAppInstanceResponse)
|
||||
.input(z.object({ body: zWebAppAuthUpdateWebAppWhitelistSubjectsBody }))
|
||||
.output(zWebAppAuthUpdateWebAppWhitelistSubjectsResponse)
|
||||
|
||||
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
|
||||
export const searchForWhilteListCandidates = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AppInstanceService_GetAppInstance',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}',
|
||||
tags: ['AppInstanceService'],
|
||||
operationId: 'WebAppAuth_SearchForWhilteListCandidates',
|
||||
path: '/enterprise/webapp/app/subject/search',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(z.object({ params: zAppInstanceServiceGetAppInstancePath }))
|
||||
.output(zAppInstanceServiceGetAppInstanceResponse)
|
||||
.input(z.object({ query: zWebAppAuthSearchForWhilteListCandidatesQuery.optional() }))
|
||||
.output(zWebAppAuthSearchForWhilteListCandidatesResponse)
|
||||
|
||||
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
|
||||
export const getWebAppWhitelistSubjects = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'DeploymentService_ListDeployments',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/deployments',
|
||||
tags: ['DeploymentService'],
|
||||
operationId: 'WebAppAuth_GetWebAppWhitelistSubjects',
|
||||
path: '/enterprise/webapp/app/subjects',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
params: zDeploymentServiceListDeploymentsPath,
|
||||
query: zDeploymentServiceListDeploymentsQuery.optional(),
|
||||
}),
|
||||
)
|
||||
.output(zDeploymentServiceListDeploymentsResponse)
|
||||
.input(z.object({ query: zWebAppAuthGetWebAppWhitelistSubjectsQuery.optional() }))
|
||||
.output(zWebAppAuthGetWebAppWhitelistSubjectsResponse)
|
||||
|
||||
export const listEnvironmentDeployments = oc
|
||||
export const getGroupSubjects = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'DeploymentService_ListEnvironmentDeployments',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environment-deployments',
|
||||
tags: ['DeploymentService'],
|
||||
operationId: 'WebAppAuth_GetGroupSubjects',
|
||||
path: '/enterprise/webapp/group/subjects',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(z.object({ params: zDeploymentServiceListEnvironmentDeploymentsPath }))
|
||||
.output(zDeploymentServiceListEnvironmentDeploymentsResponse)
|
||||
.input(z.object({ query: zWebAppAuthGetGroupSubjectsQuery.optional() }))
|
||||
.output(zWebAppAuthGetGroupSubjectsResponse)
|
||||
|
||||
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
|
||||
export const isUserAllowedToAccessWebApp = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'ReleaseService_ListReleases',
|
||||
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/releases',
|
||||
tags: ['ReleaseService'],
|
||||
operationId: 'WebAppAuth_IsUserAllowedToAccessWebApp',
|
||||
path: '/enterprise/webapp/permission',
|
||||
tags: ['WebAppAuth'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
params: zReleaseServiceListReleasesPath,
|
||||
query: zReleaseServiceListReleasesQuery.optional(),
|
||||
}),
|
||||
)
|
||||
.output(zReleaseServiceListReleasesResponse)
|
||||
.input(z.object({ query: zWebAppAuthIsUserAllowedToAccessWebAppQuery.optional() }))
|
||||
.output(zWebAppAuthIsUserAllowedToAccessWebAppResponse)
|
||||
|
||||
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 webAppAuth = {
|
||||
getWebAppAccessMode,
|
||||
updateWebAppWhitelistSubjects,
|
||||
searchForWhilteListCandidates,
|
||||
getWebAppWhitelistSubjects,
|
||||
getGroupSubjects,
|
||||
isUserAllowedToAccessWebApp,
|
||||
}
|
||||
|
||||
export const contract = {
|
||||
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
@ -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
|
||||
|
||||
@ -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} />
|
||||
}
|
||||
@ -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} />
|
||||
}
|
||||
@ -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} />
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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} />
|
||||
}
|
||||
@ -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`)
|
||||
}
|
||||
@ -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} />
|
||||
}
|
||||
@ -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} />
|
||||
}
|
||||
@ -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 />
|
||||
}
|
||||
@ -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 />
|
||||
}
|
||||
@ -6,7 +6,7 @@ import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
|
||||
import Zendesk from '@/app/components/base/zendesk'
|
||||
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
|
||||
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 { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
|
||||
import ReadmePanel from '@/app/components/plugins/readme-panel'
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -1,98 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SpecificGroupsOrMembersProps } from './specific-groups-or-members'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import AccessControlItem from './access-control-item'
|
||||
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
|
||||
|
||||
type AccessControlDialogContentProps = {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
accessLabel?: ReactNode
|
||||
hideExternal?: boolean
|
||||
hideExternalTip?: boolean
|
||||
saving?: boolean
|
||||
confirmDisabled?: boolean
|
||||
specificGroupsOrMembersProps?: SpecificGroupsOrMembersProps
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
export function AccessControlDialogContent({
|
||||
title,
|
||||
description,
|
||||
accessLabel,
|
||||
hideExternal = false,
|
||||
hideExternalTip = false,
|
||||
saving = false,
|
||||
confirmDisabled = false,
|
||||
specificGroupsOrMembersProps,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: AccessControlDialogContentProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="pt-6 pr-14 pb-3 pl-6">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{title ?? t('accessControlDialog.title', { ns: 'app' })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">
|
||||
{description ?? t('accessControlDialog.description', { ns: 'app' })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-1 px-6 pb-3">
|
||||
<div className="leading-6">
|
||||
<p className="system-sm-medium text-text-tertiary">
|
||||
{accessLabel ?? t('accessControlDialog.accessLabel', { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<span className="i-ri-building-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<p className="system-sm-medium text-text-primary">
|
||||
{t('accessControlDialog.accessItems.organization', { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
|
||||
<SpecificGroupsOrMembers {...specificGroupsOrMembersProps} />
|
||||
</AccessControlItem>
|
||||
{!hideExternal && (
|
||||
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<span className="i-ri-verified-badge-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<p className="system-sm-medium text-text-primary">
|
||||
{t('accessControlDialog.accessItems.external', { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
{!hideExternalTip && <WebAppSSONotEnabledTip />}
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
)}
|
||||
<AccessControlItem type={AccessMode.PUBLIC}>
|
||||
<div className="flex items-center gap-x-2 p-3">
|
||||
<span className="i-ri-global-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<p className="system-sm-medium text-text-primary">
|
||||
{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
|
||||
<Button disabled={saving} onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button disabled={confirmDisabled || saving} loading={saving} variant="primary" onClick={onConfirm}>
|
||||
{t('operation.confirm', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,25 +1,34 @@
|
||||
'use client'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
import { AccessControlOptionCard } from '@/app/components/base/access-control-option-card'
|
||||
import useAccessControlStore from '@/context/access-control-store'
|
||||
|
||||
type AccessControlItemProps = PropsWithChildren<{
|
||||
type: AccessMode
|
||||
}>
|
||||
|
||||
function AccessControlItem({ type, children }: AccessControlItemProps) {
|
||||
const AccessControlItem: FC<AccessControlItemProps> = ({ type, children }) => {
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
|
||||
const selected = currentMenu === type
|
||||
if (currentMenu !== type) {
|
||||
return (
|
||||
<div
|
||||
className="cursor-pointer rounded-[10px] border
|
||||
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"
|
||||
onClick={() => setCurrentMenu(type)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessControlOptionCard
|
||||
selected={selected}
|
||||
onSelect={selected ? undefined : () => setCurrentMenu(type)}
|
||||
<div className="rounded-[10px] border-[1.5px]
|
||||
border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</AccessControlOptionCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,28 +1,369 @@
|
||||
'use client'
|
||||
|
||||
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
|
||||
import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
AccessSubjectAddButton,
|
||||
} from '@/app/components/base/access-subject-selector'
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
ComboboxItem,
|
||||
ComboboxItemText,
|
||||
ComboboxList,
|
||||
ComboboxStatus,
|
||||
ComboboxTrigger,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
import { SubjectType } from '@/models/access-control'
|
||||
import { useSearchForWhiteListCandidates } from '@/service/access-control'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import Loading from '../../base/loading'
|
||||
|
||||
export default function AddMemberOrGroupDialog() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const scrollRootRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
|
||||
|
||||
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
|
||||
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, open)
|
||||
const pages = data?.pages ?? []
|
||||
const subjects = pages.flatMap(page => page.subjects ?? [])
|
||||
const selectedSubjects = [
|
||||
...specificGroups.map(groupToSubject),
|
||||
...specificMembers.map(memberToSubject),
|
||||
]
|
||||
const hasResults = pages.length > 0 && subjects.length > 0
|
||||
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
|
||||
const hasMore = pages[pages.length - 1]?.hasMore ?? false
|
||||
|
||||
useEffect(() => {
|
||||
let observer: IntersectionObserver | undefined
|
||||
if (anchorRef.current) {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0]!.isIntersecting && !isLoading && hasMore)
|
||||
fetchNextPage()
|
||||
}, { root: scrollRootRef.current, rootMargin: '20px' })
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, fetchNextPage, hasMore])
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen)
|
||||
setKeyword('')
|
||||
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
|
||||
if (details.reason !== 'item-press')
|
||||
setKeyword(inputValue)
|
||||
}
|
||||
|
||||
const handleValueChange = (nextSubjects: Subject[]) => {
|
||||
const nextGroups: AccessControlGroup[] = []
|
||||
const nextMembers: AccessControlAccount[] = []
|
||||
|
||||
for (const subject of nextSubjects) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
nextGroups.push((subject as SubjectGroup).groupData)
|
||||
else
|
||||
nextMembers.push((subject as SubjectAccount).accountData)
|
||||
}
|
||||
|
||||
setSpecificGroups(nextGroups)
|
||||
setSpecificMembers(nextMembers)
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessSubjectAddButton
|
||||
selectedGroups={specificGroups}
|
||||
selectedMembers={specificMembers}
|
||||
breadcrumbGroups={selectedGroupsForBreadcrumb}
|
||||
onBreadcrumbGroupsChange={setSelectedGroupsForBreadcrumb}
|
||||
onChange={({ groups, members }) => {
|
||||
setSpecificGroups(groups)
|
||||
setSpecificMembers(members)
|
||||
}}
|
||||
/>
|
||||
<Combobox<Subject, true>
|
||||
multiple
|
||||
open={open}
|
||||
value={selectedSubjects}
|
||||
inputValue={keyword}
|
||||
items={subjects}
|
||||
itemToStringLabel={getSubjectLabel}
|
||||
itemToStringValue={getSubjectValue}
|
||||
isItemEqualToValue={isSameSubject}
|
||||
filter={null}
|
||||
onOpenChange={handleOpenChange}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<ComboboxTrigger
|
||||
aria-label={t('operation.add', { ns: 'common' })}
|
||||
icon={false}
|
||||
size="small"
|
||||
className="flex h-6 w-auto shrink-0 items-center gap-x-0.5 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover"
|
||||
>
|
||||
<RiAddCircleFill className="size-4" aria-hidden="true" />
|
||||
<span>{t('operation.add', { ns: 'common' })}</span>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
placement="bottom-end"
|
||||
alignOffset={300}
|
||||
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
<div ref={scrollRootRef} className="min-h-0 overflow-y-auto">
|
||||
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<ComboboxInput
|
||||
aria-label={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
|
||||
placeholder={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
|
||||
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
{isLoading
|
||||
? (
|
||||
<ComboboxStatus className="p-1">
|
||||
<Loading />
|
||||
</ComboboxStatus>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{shouldShowBreadcrumb && (
|
||||
<div className="flex h-7 items-center px-2 py-0.5">
|
||||
<SelectedGroupsBreadCrumb />
|
||||
</div>
|
||||
)}
|
||||
{hasResults
|
||||
? (
|
||||
<>
|
||||
<ComboboxList className="max-h-none p-1">
|
||||
{(subject: Subject) => <SubjectItem key={getSubjectValue(subject)} subject={subject} />}
|
||||
</ComboboxList>
|
||||
{isFetchingNextPage && <Loading />}
|
||||
<div ref={anchorRef} className="h-0" />
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5">
|
||||
{t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })}
|
||||
</ComboboxEmpty>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
function groupToSubject(group: AccessControlGroup): SubjectGroup {
|
||||
return {
|
||||
subjectId: group.id,
|
||||
subjectType: SubjectType.GROUP,
|
||||
groupData: group,
|
||||
}
|
||||
}
|
||||
|
||||
function memberToSubject(member: AccessControlAccount): SubjectAccount {
|
||||
return {
|
||||
subjectId: member.id,
|
||||
subjectType: SubjectType.ACCOUNT,
|
||||
accountData: member,
|
||||
}
|
||||
}
|
||||
|
||||
function getSubjectLabel(subject: Subject) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
return (subject as SubjectGroup).groupData.name
|
||||
|
||||
return (subject as SubjectAccount).accountData.name
|
||||
}
|
||||
|
||||
function getSubjectValue(subject: Subject) {
|
||||
return `${subject.subjectType}:${subject.subjectId}`
|
||||
}
|
||||
|
||||
function isSameSubject(item: Subject, value: Subject) {
|
||||
return item.subjectId === value.subjectId && item.subjectType === value.subjectType
|
||||
}
|
||||
|
||||
function SubjectItem({ subject }: { subject: Subject }) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
return <GroupItem group={(subject as SubjectGroup).groupData} subject={subject} />
|
||||
|
||||
return <MemberItem member={(subject as SubjectAccount).accountData} subject={subject} />
|
||||
}
|
||||
|
||||
function SelectedGroupsBreadCrumb() {
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleBreadCrumbClick = (index: number) => {
|
||||
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
|
||||
setSelectedGroupsForBreadcrumb(newGroups)
|
||||
}
|
||||
const handleReset = () => {
|
||||
setSelectedGroupsForBreadcrumb([])
|
||||
}
|
||||
const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex h-7 items-center gap-x-0.5 px-2 py-0.5">
|
||||
{hasBreadcrumb
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<span className="system-xs-regular text-text-tertiary">{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}</span>
|
||||
)}
|
||||
{selectedGroupsForBreadcrumb.map((group, index) => {
|
||||
const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary">
|
||||
<span>/</span>
|
||||
{isLastGroup
|
||||
? <span>{group.name}</span>
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={() => handleBreadCrumbClick(index)}
|
||||
>
|
||||
{group.name}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type GroupItemProps = {
|
||||
group: AccessControlGroup
|
||||
subject: Subject
|
||||
}
|
||||
function GroupItem({ group, subject }: GroupItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||
const isChecked = specificGroups.some(g => g.id === group.id)
|
||||
|
||||
const handleExpandClick = () => {
|
||||
setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
|
||||
<BaseItem subject={subject}>
|
||||
<SelectionBox checked={isChecked} />
|
||||
<ComboboxItemText className="flex grow items-center px-0">
|
||||
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
|
||||
<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
|
||||
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
|
||||
</ComboboxItemText>
|
||||
</BaseItem>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={isChecked}
|
||||
variant="ghost-accent"
|
||||
className="mr-1 flex shrink-0 items-center justify-between px-1.5 py-1"
|
||||
onPointerDown={event => event.preventDefault()}
|
||||
onClick={handleExpandClick}
|
||||
>
|
||||
<span className="px-[3px]">{t('accessControlDialog.operateGroupAndMember.expand', { ns: 'app' })}</span>
|
||||
<RiArrowRightSLine className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type MemberItemProps = {
|
||||
member: AccessControlAccount
|
||||
subject: Subject
|
||||
}
|
||||
function MemberItem({ member, subject }: MemberItemProps) {
|
||||
const currentUser = useSelector(s => s.userProfile)
|
||||
const { t } = useTranslation()
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const isChecked = specificMembers.some(m => m.id === member.id)
|
||||
return (
|
||||
<BaseItem subject={subject} className="pr-3">
|
||||
<SelectionBox checked={isChecked} />
|
||||
<ComboboxItemText className="flex grow items-center px-0">
|
||||
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
|
||||
<Avatar size="xxs" avatar={null} name={member.name} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
|
||||
{currentUser.email === member.email && (
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
(
|
||||
{t('you', { ns: 'common' })}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</ComboboxItemText>
|
||||
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
|
||||
</BaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type BaseItemProps = {
|
||||
className?: string
|
||||
subject: Subject
|
||||
children: React.ReactNode
|
||||
}
|
||||
function BaseItem({ children, className, subject }: BaseItemProps) {
|
||||
return (
|
||||
<ComboboxItem
|
||||
value={subject}
|
||||
className={cn(
|
||||
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ComboboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectionBox({ checked }: { checked: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3',
|
||||
checked
|
||||
? 'bg-components-checkbox-bg text-components-checkbox-icon'
|
||||
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
|
||||
)}
|
||||
>
|
||||
{checked && <span className="i-ri-check-line size-3" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
'use client'
|
||||
import type { Subject } from '@/models/access-control'
|
||||
import type { App } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -10,7 +13,8 @@ import { useUpdateAccessMode } from '@/service/access-control'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import AccessControlDialog from './access-control-dialog'
|
||||
import { AccessControlDialogContent } from './access-control-dialog-content'
|
||||
import AccessControlItem from './access-control-item'
|
||||
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
|
||||
|
||||
type AccessControlProps = {
|
||||
app: App
|
||||
@ -27,7 +31,7 @@ export default function AccessControl(props: AccessControlProps) {
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
|
||||
const hideExternalTip = systemFeatures.webapp_auth.enabled
|
||||
const hideTip = systemFeatures.webapp_auth.enabled
|
||||
&& (systemFeatures.webapp_auth.allow_sso
|
||||
|| systemFeatures.webapp_auth.allow_email_password_login
|
||||
|| systemFeatures.webapp_auth.allow_email_code_login)
|
||||
@ -63,12 +67,47 @@ export default function AccessControl(props: AccessControlProps) {
|
||||
}, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu])
|
||||
return (
|
||||
<AccessControlDialog show onClose={onClose}>
|
||||
<AccessControlDialogContent
|
||||
hideExternalTip={hideExternalTip}
|
||||
saving={isPending}
|
||||
onClose={onClose}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
<div className="flex flex-col gap-y-3">
|
||||
<div className="pt-6 pr-14 pb-3 pl-6">
|
||||
<DialogTitle className="title-2xl-semi-bold text-text-primary">{t('accessControlDialog.title', { ns: 'app' })}</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">{t('accessControlDialog.description', { ns: 'app' })}</DialogDescription>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-1 px-6 pb-3">
|
||||
<div className="leading-6">
|
||||
<p className="system-sm-medium text-text-tertiary">{t('accessControlDialog.accessLabel', { ns: 'app' })}</p>
|
||||
</div>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<RiBuildingLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.organization', { ns: 'app' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
|
||||
<SpecificGroupsOrMembers />
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<RiVerifiedBadgeLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.external', { ns: 'app' })}</p>
|
||||
</div>
|
||||
{!hideTip && <WebAppSSONotEnabledTip />}
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.PUBLIC}>
|
||||
<div className="flex items-center gap-x-2 p-3">
|
||||
<RiGlobalLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}</p>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
|
||||
<Button onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
<Button disabled={isPending} loading={isPending} variant="primary" onClick={handleConfirm}>{t('operation.confirm', { ns: 'common' })}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccessControlDialog>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,46 +1,34 @@
|
||||
'use client'
|
||||
import { useEffect } from 'react'
|
||||
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AccessSubjectSelectionList } from '@/app/components/base/access-subject-selector'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects } from '@/service/access-control'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import { Infotip } from '../../base/infotip'
|
||||
import Loading from '../../base/loading'
|
||||
import AddMemberOrGroupDialog from './add-member-or-group-pop'
|
||||
|
||||
export type SpecificGroupsOrMembersProps = {
|
||||
loadSubjects?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function SpecificGroupsOrMembers({
|
||||
loadSubjects = true,
|
||||
loading = false,
|
||||
}: SpecificGroupsOrMembersProps) {
|
||||
export default function SpecificGroupsOrMembers() {
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const appId = useAccessControlStore(s => s.appId)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { isPending, data } = useAppWhiteListSubjects(
|
||||
appId,
|
||||
loadSubjects && Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS,
|
||||
)
|
||||
const { isPending, data } = useAppWhiteListSubjects(appId, Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
useEffect(() => {
|
||||
if (!loadSubjects)
|
||||
return
|
||||
setSpecificGroups(data?.groups ?? [])
|
||||
setSpecificMembers(data?.members ?? [])
|
||||
}, [data, loadSubjects, setSpecificGroups, setSpecificMembers])
|
||||
}, [data, setSpecificGroups, setSpecificMembers])
|
||||
|
||||
if (currentMenu !== AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||
return (
|
||||
<div className="flex items-center p-3">
|
||||
<div className="flex grow items-center gap-x-2">
|
||||
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<RiLockLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -51,7 +39,7 @@ export default function SpecificGroupsOrMembers({
|
||||
<div>
|
||||
<div className="flex items-center gap-x-1 p-3">
|
||||
<div className="flex grow items-center gap-x-1">
|
||||
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
|
||||
<RiLockLine className="size-4 text-text-primary" />
|
||||
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1">
|
||||
@ -59,20 +47,101 @@ export default function SpecificGroupsOrMembers({
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-1 pb-1">
|
||||
<AccessSubjectSelectionList
|
||||
selectedGroups={specificGroups}
|
||||
selectedMembers={specificMembers}
|
||||
loading={loadSubjects ? isPending : loading}
|
||||
onChange={({ groups, members }) => {
|
||||
setSpecificGroups(groups)
|
||||
setSpecificMembers(members)
|
||||
}}
|
||||
/>
|
||||
<div className="flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2">
|
||||
{isPending ? <Loading /> : <RenderGroupsAndMembers />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RenderGroupsAndMembers() {
|
||||
const { t } = useTranslation()
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
if (specificGroups.length <= 0 && specificMembers.length <= 0)
|
||||
return <div className="px-2 pt-5 pb-1.5"><p className="text-center system-xs-regular text-text-tertiary">{t('accessControlDialog.noGroupsOrMembers', { ns: 'app' })}</p></div>
|
||||
return (
|
||||
<>
|
||||
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">{t('accessControlDialog.groups', { ns: 'app', count: specificGroups.length ?? 0 })}</p>
|
||||
<div className="flex flex-row flex-wrap gap-1">
|
||||
{specificGroups.map((group, index) => <GroupItem key={index} group={group} />)}
|
||||
</div>
|
||||
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">{t('accessControlDialog.members', { ns: 'app', count: specificMembers.length ?? 0 })}</p>
|
||||
<div className="flex flex-row flex-wrap gap-1">
|
||||
{specificMembers.map((member, index) => <MemberItem key={index} member={member} />)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type GroupItemProps = {
|
||||
group: AccessControlGroup
|
||||
}
|
||||
function GroupItem({ group }: GroupItemProps) {
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const handleRemoveGroup = useCallback(() => {
|
||||
setSpecificGroups(specificGroups.filter(g => g.id !== group.id))
|
||||
}, [group, setSpecificGroups, specificGroups])
|
||||
return (
|
||||
<BaseItem
|
||||
icon={<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" />}
|
||||
onRemove={handleRemoveGroup}
|
||||
>
|
||||
<p className="system-xs-regular text-text-primary">{group.name}</p>
|
||||
<p className="system-xs-regular text-text-tertiary">{group.groupSize}</p>
|
||||
</BaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type MemberItemProps = {
|
||||
member: AccessControlAccount
|
||||
}
|
||||
function MemberItem({ member }: MemberItemProps) {
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const handleRemoveMember = useCallback(() => {
|
||||
setSpecificMembers(specificMembers.filter(m => m.id !== member.id))
|
||||
}, [member, setSpecificMembers, specificMembers])
|
||||
return (
|
||||
<BaseItem
|
||||
icon={<Avatar size="xxs" avatar={null} name={member.name} />}
|
||||
onRemove={handleRemoveMember}
|
||||
>
|
||||
<p className="system-xs-regular text-text-primary">{member.name}</p>
|
||||
</BaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type BaseItemProps = {
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
onRemove?: () => void
|
||||
}
|
||||
function BaseItem({ icon, onRemove, children }: BaseItemProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs">
|
||||
<div className="size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
aria-label={t('operation.remove', { ns: 'common' })}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<RiCloseCircleFill className="h-[14px] w-[14px] text-text-quaternary" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WebAppSSONotEnabledTip() {
|
||||
const { t } = useTranslation()
|
||||
const tip = t('accessControlDialog.webAppSSONotEnabledTip', { ns: 'app' })
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AccessControlOptionCard } from '../index'
|
||||
|
||||
describe('AccessControlOptionCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render selected content with selected styles', () => {
|
||||
render(
|
||||
<AccessControlOptionCard selected>
|
||||
<span>Selected access</span>
|
||||
</AccessControlOptionCard>,
|
||||
)
|
||||
|
||||
const card = screen.getByText('Selected access').parentElement
|
||||
|
||||
expect(card).toHaveClass('border-components-option-card-option-selected-border')
|
||||
expect(card).toHaveClass('bg-components-option-card-option-selected-bg')
|
||||
})
|
||||
|
||||
it('should call onSelect when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<AccessControlOptionCard onSelect={onSelect}>
|
||||
<span>Selectable access</span>
|
||||
</AccessControlOptionCard>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Selectable access' }))
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should call onSelect from keyboard activation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<AccessControlOptionCard onSelect={onSelect}>
|
||||
<span>Keyboard access</span>
|
||||
</AccessControlOptionCard>,
|
||||
)
|
||||
|
||||
const card = screen.getByRole('button', { name: 'Keyboard access' })
|
||||
card.focus()
|
||||
await user.keyboard('{Enter}')
|
||||
await user.keyboard(' ')
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should not call onSelect when disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
|
||||
render(
|
||||
<AccessControlOptionCard disabled onSelect={onSelect}>
|
||||
<span>Disabled access</span>
|
||||
</AccessControlOptionCard>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('Disabled access'))
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Disabled access').parentElement).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
@ -1,59 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentPropsWithoutRef, KeyboardEvent } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
type AccessControlOptionCardProps = Omit<ComponentPropsWithoutRef<'div'>, 'onSelect'> & {
|
||||
selected?: boolean
|
||||
disabled?: boolean
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
export function AccessControlOptionCard({
|
||||
selected = false,
|
||||
disabled = false,
|
||||
className,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
onSelect,
|
||||
...props
|
||||
}: AccessControlOptionCardProps) {
|
||||
const interactive = Boolean(onSelect) && !disabled
|
||||
|
||||
const handleClick: ComponentPropsWithoutRef<'div'>['onClick'] = (event) => {
|
||||
onClick?.(event)
|
||||
if (!event.defaultPrevented && interactive)
|
||||
onSelect?.()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
onKeyDown?.(event)
|
||||
if (event.defaultPrevented || !interactive)
|
||||
return
|
||||
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
onSelect?.()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
aria-disabled={disabled || undefined}
|
||||
aria-pressed={interactive ? selected : undefined}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
selected
|
||||
? 'rounded-[10px] border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm'
|
||||
: 'rounded-[10px] border border-components-option-card-option-border bg-components-option-card-option-bg',
|
||||
interactive && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,607 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
AccessControlAccount,
|
||||
AccessControlGroup,
|
||||
Subject,
|
||||
SubjectAccount,
|
||||
SubjectGroup,
|
||||
} from '@/models/access-control'
|
||||
import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
ComboboxItem,
|
||||
ComboboxItemText,
|
||||
ComboboxList,
|
||||
ComboboxStatus,
|
||||
ComboboxTrigger,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
import { SubjectType } from '@/models/access-control'
|
||||
import { useSearchForWhiteListCandidates } from '@/service/access-control'
|
||||
import Loading from '../loading'
|
||||
|
||||
export type AccessSubjectSelectionValue = {
|
||||
groups: AccessControlGroup[]
|
||||
members: AccessControlAccount[]
|
||||
}
|
||||
|
||||
type AccessSubjectSelectionProps = {
|
||||
selectedGroups: AccessControlGroup[]
|
||||
selectedMembers: AccessControlAccount[]
|
||||
onChange: (value: AccessSubjectSelectionValue) => void
|
||||
}
|
||||
|
||||
type AccessSubjectAddButtonProps = AccessSubjectSelectionProps & {
|
||||
disabled?: boolean
|
||||
breadcrumbGroups?: AccessControlGroup[]
|
||||
onBreadcrumbGroupsChange?: (groups: AccessControlGroup[]) => void
|
||||
}
|
||||
|
||||
export function AccessSubjectAddButton({
|
||||
selectedGroups,
|
||||
selectedMembers,
|
||||
onChange,
|
||||
disabled,
|
||||
breadcrumbGroups,
|
||||
onBreadcrumbGroupsChange,
|
||||
}: AccessSubjectAddButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [internalBreadcrumbGroups, setInternalBreadcrumbGroups] = useState<AccessControlGroup[]>([])
|
||||
const scrollRootRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const selectedGroupsForBreadcrumb = breadcrumbGroups ?? internalBreadcrumbGroups
|
||||
const setSelectedGroupsForBreadcrumb = onBreadcrumbGroupsChange ?? setInternalBreadcrumbGroups
|
||||
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
|
||||
|
||||
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
|
||||
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({
|
||||
keyword: debouncedKeyword,
|
||||
groupId: lastAvailableGroup?.id,
|
||||
resultsPerPage: 10,
|
||||
}, open && !disabled)
|
||||
const pages = data?.pages ?? []
|
||||
const subjects = pages.flatMap(page => page.subjects ?? [])
|
||||
const selectedSubjects = [
|
||||
...selectedGroups.map(groupToSubject),
|
||||
...selectedMembers.map(memberToSubject),
|
||||
]
|
||||
const hasResults = pages.length > 0 && subjects.length > 0
|
||||
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
|
||||
const hasMore = pages[pages.length - 1]?.hasMore ?? false
|
||||
|
||||
useEffect(() => {
|
||||
let observer: IntersectionObserver | undefined
|
||||
if (anchorRef.current) {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && hasMore)
|
||||
fetchNextPage()
|
||||
}, { root: scrollRootRef.current, rootMargin: '20px' })
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
return () => observer?.disconnect()
|
||||
}, [fetchNextPage, hasMore, isFetchingNextPage, isLoading])
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen && disabled)
|
||||
return
|
||||
if (!nextOpen)
|
||||
setKeyword('')
|
||||
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
|
||||
if (!disabled && details.reason !== 'item-press')
|
||||
setKeyword(inputValue)
|
||||
}
|
||||
|
||||
const handleValueChange = (nextSubjects: Subject[]) => {
|
||||
const nextGroups: AccessControlGroup[] = []
|
||||
const nextMembers: AccessControlAccount[] = []
|
||||
|
||||
for (const subject of nextSubjects) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
nextGroups.push((subject as SubjectGroup).groupData)
|
||||
else
|
||||
nextMembers.push((subject as SubjectAccount).accountData)
|
||||
}
|
||||
|
||||
onChange({
|
||||
groups: nextGroups,
|
||||
members: nextMembers,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox<Subject, true>
|
||||
multiple
|
||||
open={open}
|
||||
value={selectedSubjects}
|
||||
inputValue={keyword}
|
||||
items={subjects}
|
||||
disabled={disabled}
|
||||
itemToStringLabel={getSubjectLabel}
|
||||
itemToStringValue={getSubjectValue}
|
||||
isItemEqualToValue={isSameSubject}
|
||||
filter={null}
|
||||
onOpenChange={handleOpenChange}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<ComboboxTrigger
|
||||
aria-label={t('operation.add', { ns: 'common' })}
|
||||
icon={false}
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
className="h-6 w-auto min-w-[52px] shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover"
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center justify-center gap-x-0.5 whitespace-nowrap">
|
||||
<span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" />
|
||||
<span className="shrink-0">{t('operation.add', { ns: 'common' })}</span>
|
||||
</span>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
placement="bottom-end"
|
||||
alignOffset={300}
|
||||
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
<div ref={scrollRootRef} className="min-h-0 overflow-y-auto">
|
||||
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<ComboboxInput
|
||||
aria-label={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
|
||||
placeholder={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
|
||||
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
{isLoading
|
||||
? (
|
||||
<ComboboxStatus className="p-1">
|
||||
<Loading />
|
||||
</ComboboxStatus>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{shouldShowBreadcrumb && (
|
||||
<div className="flex h-7 items-center px-2 py-0.5">
|
||||
<SelectedGroupsBreadCrumb
|
||||
selectedGroupsForBreadcrumb={selectedGroupsForBreadcrumb}
|
||||
onChange={setSelectedGroupsForBreadcrumb}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasResults
|
||||
? (
|
||||
<>
|
||||
<ComboboxList className="max-h-none p-1">
|
||||
{(subject: Subject) => (
|
||||
<SubjectItem
|
||||
key={getSubjectValue(subject)}
|
||||
subject={subject}
|
||||
selectedGroups={selectedGroups}
|
||||
selectedMembers={selectedMembers}
|
||||
onExpandGroup={group => setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])}
|
||||
/>
|
||||
)}
|
||||
</ComboboxList>
|
||||
{isFetchingNextPage && <Loading />}
|
||||
<div ref={anchorRef} className="h-0" />
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5">
|
||||
{t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })}
|
||||
</ComboboxEmpty>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
type AccessSubjectSelectionListProps = AccessSubjectSelectionProps & {
|
||||
loading?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AccessSubjectSelectionList({
|
||||
selectedGroups,
|
||||
selectedMembers,
|
||||
onChange,
|
||||
loading,
|
||||
className,
|
||||
}: AccessSubjectSelectionListProps) {
|
||||
return (
|
||||
<div className={cn('flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2', className)}>
|
||||
{loading
|
||||
? <Loading />
|
||||
: (
|
||||
<RenderGroupsAndMembers
|
||||
selectedGroups={selectedGroups}
|
||||
selectedMembers={selectedMembers}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RenderGroupsAndMembers({
|
||||
selectedGroups,
|
||||
selectedMembers,
|
||||
onChange,
|
||||
}: AccessSubjectSelectionProps) {
|
||||
const { t } = useTranslation()
|
||||
if (selectedGroups.length <= 0 && selectedMembers.length <= 0) {
|
||||
return (
|
||||
<div className="px-2 pt-5 pb-1.5">
|
||||
<p className="text-center system-xs-regular text-text-tertiary">
|
||||
{t('accessControlDialog.noGroupsOrMembers', { ns: 'app' })}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('accessControlDialog.groups', { ns: 'app', count: selectedGroups.length ?? 0 })}
|
||||
</p>
|
||||
<div className="flex flex-row flex-wrap gap-1">
|
||||
{selectedGroups.map(group => (
|
||||
<SelectedGroupItem
|
||||
key={group.id}
|
||||
group={group}
|
||||
selectedGroups={selectedGroups}
|
||||
selectedMembers={selectedMembers}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('accessControlDialog.members', { ns: 'app', count: selectedMembers.length ?? 0 })}
|
||||
</p>
|
||||
<div className="flex flex-row flex-wrap gap-1">
|
||||
{selectedMembers.map(member => (
|
||||
<SelectedMemberItem
|
||||
key={member.id}
|
||||
member={member}
|
||||
selectedGroups={selectedGroups}
|
||||
selectedMembers={selectedMembers}
|
||||
onChange={onChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function groupToSubject(group: AccessControlGroup): SubjectGroup {
|
||||
return {
|
||||
subjectId: group.id,
|
||||
subjectType: SubjectType.GROUP,
|
||||
groupData: group,
|
||||
}
|
||||
}
|
||||
|
||||
function memberToSubject(member: AccessControlAccount): SubjectAccount {
|
||||
return {
|
||||
subjectId: member.id,
|
||||
subjectType: SubjectType.ACCOUNT,
|
||||
accountData: member,
|
||||
}
|
||||
}
|
||||
|
||||
function getSubjectLabel(subject: Subject) {
|
||||
if (subject.subjectType === SubjectType.GROUP)
|
||||
return (subject as SubjectGroup).groupData.name
|
||||
|
||||
return (subject as SubjectAccount).accountData.name
|
||||
}
|
||||
|
||||
function getSubjectValue(subject: Subject) {
|
||||
return `${subject.subjectType}:${subject.subjectId}`
|
||||
}
|
||||
|
||||
function isSameSubject(item: Subject, value: Subject) {
|
||||
return item.subjectId === value.subjectId && item.subjectType === value.subjectType
|
||||
}
|
||||
|
||||
function SubjectItem({
|
||||
subject,
|
||||
selectedGroups,
|
||||
selectedMembers,
|
||||
onExpandGroup,
|
||||
}: {
|
||||
subject: Subject
|
||||
selectedGroups: AccessControlGroup[]
|
||||
selectedMembers: AccessControlAccount[]
|
||||
onExpandGroup: (group: AccessControlGroup) => void
|
||||
}) {
|
||||
if (subject.subjectType === SubjectType.GROUP) {
|
||||
return (
|
||||
<GroupItem
|
||||
group={(subject as SubjectGroup).groupData}
|
||||
subject={subject}
|
||||
selectedGroups={selectedGroups}
|
||||
onExpandGroup={onExpandGroup}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MemberItem
|
||||
member={(subject as SubjectAccount).accountData}
|
||||
subject={subject}
|
||||
selectedMembers={selectedMembers}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectedGroupsBreadCrumb({
|
||||
selectedGroupsForBreadcrumb,
|
||||
onChange,
|
||||
}: {
|
||||
selectedGroupsForBreadcrumb: AccessControlGroup[]
|
||||
onChange: (groups: AccessControlGroup[]) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleBreadCrumbClick = (index: number) => {
|
||||
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
|
||||
onChange(newGroups)
|
||||
}
|
||||
const handleReset = () => {
|
||||
onChange([])
|
||||
}
|
||||
const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex h-7 items-center gap-x-0.5 px-2 py-0.5">
|
||||
{hasBreadcrumb
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={handleReset}
|
||||
>
|
||||
{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<span className="system-xs-regular text-text-tertiary">{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}</span>
|
||||
)}
|
||||
{selectedGroupsForBreadcrumb.map((group, index) => {
|
||||
const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1
|
||||
|
||||
return (
|
||||
<div key={group.id} className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary">
|
||||
<span>/</span>
|
||||
{isLastGroup
|
||||
? <span>{group.name}</span>
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
onClick={() => handleBreadCrumbClick(index)}
|
||||
>
|
||||
{group.name}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type GroupItemProps = {
|
||||
group: AccessControlGroup
|
||||
subject: Subject
|
||||
selectedGroups: AccessControlGroup[]
|
||||
onExpandGroup: (group: AccessControlGroup) => void
|
||||
}
|
||||
|
||||
function GroupItem({ group, subject, selectedGroups, onExpandGroup }: GroupItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const isChecked = selectedGroups.some(selectedGroup => selectedGroup.id === group.id)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
|
||||
<ComboboxBaseItem subject={subject}>
|
||||
<SelectionBox checked={isChecked} />
|
||||
<ComboboxItemText className="flex grow items-center px-0">
|
||||
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="flex size-full items-center justify-center bg-[image:var(--color-access-app-icon-mask-bg)]">
|
||||
<span className="i-ri-organization-chart h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
|
||||
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
|
||||
</ComboboxItemText>
|
||||
</ComboboxBaseItem>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={isChecked}
|
||||
variant="ghost-accent"
|
||||
className="mr-1 flex shrink-0 items-center justify-between px-1.5 py-1"
|
||||
onPointerDown={event => event.preventDefault()}
|
||||
onClick={() => onExpandGroup(group)}
|
||||
>
|
||||
<span className="px-[3px]">{t('accessControlDialog.operateGroupAndMember.expand', { ns: 'app' })}</span>
|
||||
<span className="i-ri-arrow-right-s-line size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type MemberItemProps = {
|
||||
member: AccessControlAccount
|
||||
subject: Subject
|
||||
selectedMembers: AccessControlAccount[]
|
||||
}
|
||||
|
||||
function MemberItem({ member, subject, selectedMembers }: MemberItemProps) {
|
||||
const currentUser = useSelector(s => s.userProfile)
|
||||
const { t } = useTranslation()
|
||||
const isChecked = selectedMembers.some(selectedMember => selectedMember.id === member.id)
|
||||
return (
|
||||
<ComboboxBaseItem subject={subject} className="pr-3">
|
||||
<SelectionBox checked={isChecked} />
|
||||
<ComboboxItemText className="flex grow items-center px-0">
|
||||
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="flex size-full items-center justify-center bg-[image:var(--color-access-app-icon-mask-bg)]">
|
||||
<Avatar size="xxs" avatar={null} name={member.name} />
|
||||
</div>
|
||||
</div>
|
||||
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
|
||||
{currentUser.email === member.email && (
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
(
|
||||
{t('you', { ns: 'common' })}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</ComboboxItemText>
|
||||
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
|
||||
</ComboboxBaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type ComboboxBaseItemProps = {
|
||||
className?: string
|
||||
subject: Subject
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
function ComboboxBaseItem({ children, className, subject }: ComboboxBaseItemProps) {
|
||||
return (
|
||||
<ComboboxItem
|
||||
value={subject}
|
||||
className={cn(
|
||||
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</ComboboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectionBox({ checked }: { checked: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3',
|
||||
checked
|
||||
? 'bg-components-checkbox-bg text-components-checkbox-icon'
|
||||
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
|
||||
)}
|
||||
>
|
||||
{checked && <span className="i-ri-check-line size-3" />}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectedGroupItemProps = AccessSubjectSelectionProps & {
|
||||
group: AccessControlGroup
|
||||
}
|
||||
|
||||
function SelectedGroupItem({
|
||||
group,
|
||||
selectedGroups,
|
||||
selectedMembers,
|
||||
onChange,
|
||||
}: SelectedGroupItemProps) {
|
||||
const handleRemoveGroup = () => {
|
||||
onChange({
|
||||
groups: selectedGroups.filter(selectedGroup => selectedGroup.id !== group.id),
|
||||
members: selectedMembers,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectedBaseItem
|
||||
icon={<span className="i-ri-organization-chart h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />}
|
||||
onRemove={handleRemoveGroup}
|
||||
>
|
||||
<p className="system-xs-regular text-text-primary">{group.name}</p>
|
||||
<p className="system-xs-regular text-text-tertiary">{group.groupSize}</p>
|
||||
</SelectedBaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectedMemberItemProps = AccessSubjectSelectionProps & {
|
||||
member: AccessControlAccount
|
||||
}
|
||||
|
||||
function SelectedMemberItem({
|
||||
member,
|
||||
selectedGroups,
|
||||
selectedMembers,
|
||||
onChange,
|
||||
}: SelectedMemberItemProps) {
|
||||
const handleRemoveMember = () => {
|
||||
onChange({
|
||||
groups: selectedGroups,
|
||||
members: selectedMembers.filter(selectedMember => selectedMember.id !== member.id),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectedBaseItem
|
||||
icon={<Avatar size="xxs" avatar={null} name={member.name} />}
|
||||
onRemove={handleRemoveMember}
|
||||
>
|
||||
<p className="system-xs-regular text-text-primary">{member.name}</p>
|
||||
</SelectedBaseItem>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectedBaseItemProps = {
|
||||
icon: ReactNode
|
||||
children: ReactNode
|
||||
onRemove?: () => void
|
||||
}
|
||||
|
||||
function SelectedBaseItem({ icon, onRemove, children }: SelectedBaseItemProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs">
|
||||
<div className="size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
|
||||
<div className="flex size-full items-center justify-center bg-[image:var(--color-access-app-icon-mask-bg)]">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
|
||||
aria-label={t('operation.remove', { ns: 'common' })}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<span className="i-ri-close-circle-fill h-[14px] w-[14px] text-text-quaternary" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -1,59 +1,19 @@
|
||||
'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'
|
||||
import { useParams, useRouter } from '@/next/navigation'
|
||||
import { useDatasetDetail, useDatasetList } from '@/service/knowledge/use-dataset'
|
||||
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
|
||||
|
||||
function datasetLink(dataset: DataSet) {
|
||||
const isPipelineUnpublished = dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
|
||||
const link = isPipelineUnpublished
|
||||
? `/datasets/${dataset.id}/pipeline`
|
||||
: `/datasets/${dataset.id}/documents`
|
||||
|
||||
return dataset.provider === 'external'
|
||||
? `/datasets/${dataset.id}/hitTesting`
|
||||
: link
|
||||
}
|
||||
|
||||
function currentDatasetNavItem(dataset: DataSet) {
|
||||
const iconInfo = dataset.icon_info ?? DEFAULT_DATASET_ICON_INFO
|
||||
|
||||
return {
|
||||
id: dataset.id,
|
||||
name: dataset.name,
|
||||
icon: iconInfo.icon,
|
||||
icon_type: iconInfo.icon_type,
|
||||
icon_background: iconInfo.icon_background ?? null,
|
||||
icon_url: iconInfo.icon_url ?? null,
|
||||
} satisfies Omit<NavItem, 'link'>
|
||||
}
|
||||
|
||||
function datasetNavItem(dataset: DataSet) {
|
||||
const iconInfo = dataset.icon_info ?? DEFAULT_DATASET_ICON_INFO
|
||||
|
||||
return {
|
||||
id: dataset.id,
|
||||
name: dataset.name,
|
||||
link: datasetLink(dataset),
|
||||
icon: iconInfo.icon,
|
||||
icon_type: iconInfo.icon_type,
|
||||
icon_background: iconInfo.icon_background ?? null,
|
||||
icon_url: iconInfo.icon_url ?? null,
|
||||
} satisfies NavItem
|
||||
}
|
||||
|
||||
const DatasetNav = () => {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
@ -70,23 +30,62 @@ const DatasetNav = () => {
|
||||
})
|
||||
const datasetItems = flatten(datasetList?.pages.map(datasetData => datasetData.data))
|
||||
|
||||
const curNav = currentDataset ? currentDatasetNavItem(currentDataset) : undefined
|
||||
const navigationItems = datasetItems.map(datasetNavItem)
|
||||
const runtimeMode = currentDataset?.runtime_mode
|
||||
const createRoute = runtimeMode === 'rag_pipeline'
|
||||
? `${basePath}/datasets/create-from-pipeline`
|
||||
: `${basePath}/datasets/create`
|
||||
const curNav = useMemo(() => {
|
||||
if (!currentDataset)
|
||||
return
|
||||
return {
|
||||
id: currentDataset.id,
|
||||
name: currentDataset.name,
|
||||
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?.id, currentDataset?.name, currentDataset?.icon_info])
|
||||
|
||||
function handleLoadMore() {
|
||||
if (hasNextPage && !isFetchingNextPage)
|
||||
void fetchNextPage()
|
||||
}
|
||||
const getDatasetLink = useCallback((dataset: DataSet) => {
|
||||
const isPipelineUnpublished = dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
|
||||
const link = isPipelineUnpublished
|
||||
? `/datasets/${dataset.id}/pipeline`
|
||||
: `/datasets/${dataset.id}/documents`
|
||||
return dataset.provider === 'external'
|
||||
? `/datasets/${dataset.id}/hitTesting`
|
||||
: link
|
||||
}, [])
|
||||
|
||||
const navigationItems = useMemo(() => {
|
||||
return datasetItems.map((dataset) => {
|
||||
const link = getDatasetLink(dataset)
|
||||
return {
|
||||
id: dataset.id,
|
||||
name: dataset.name,
|
||||
link,
|
||||
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])
|
||||
|
||||
const createRoute = useMemo(() => {
|
||||
const runtimeMode = currentDataset?.runtime_mode
|
||||
if (runtimeMode === 'rag_pipeline')
|
||||
return `${basePath}/datasets/create-from-pipeline`
|
||||
else
|
||||
return `${basePath}/datasets/create`
|
||||
}, [currentDataset?.runtime_mode])
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (hasNextPage)
|
||||
fetchNextPage()
|
||||
}, [hasNextPage, fetchNextPage])
|
||||
|
||||
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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -35,7 +35,5 @@ export const userProfileQueryOptions = () =>
|
||||
},
|
||||
}
|
||||
},
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
retry: (failureCount, error) => !isLegacyBase401(error) && failureCount < 3,
|
||||
})
|
||||
|
||||
@ -75,7 +75,5 @@ export const serverUserProfileQueryOptions = () =>
|
||||
},
|
||||
}
|
||||
},
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -1,233 +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'
|
||||
import { TitleTooltip } from './title-tooltip'
|
||||
|
||||
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 h-10 cursor-pointer items-center gap-2 rounded-lg border border-transparent bg-components-input-bg-normal px-3 text-left hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
|
||||
open && 'border-components-input-border-active bg-components-input-bg-active shadow-xs',
|
||||
app && 'pl-2',
|
||||
)}
|
||||
>
|
||||
{app && (
|
||||
<AppIcon
|
||||
className="shrink-0"
|
||||
size="xs"
|
||||
iconType={app.icon_type}
|
||||
icon={app.icon}
|
||||
background={app.icon_background}
|
||||
imageUrl={app.icon_url}
|
||||
/>
|
||||
)}
|
||||
<TitleTooltip content={app?.name}>
|
||||
<span
|
||||
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>
|
||||
</TitleTooltip>
|
||||
<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}
|
||||
/>
|
||||
<TitleTooltip content={`${app.name} (${app.id})`}>
|
||||
<span 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>
|
||||
</TitleTooltip>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBackdrop,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerPopup,
|
||||
DrawerPortal,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
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 (
|
||||
<Drawer
|
||||
open={open}
|
||||
modal
|
||||
swipeDirection="right"
|
||||
onOpenChange={next => !next && closeDeployDrawer()}
|
||||
>
|
||||
<DrawerPortal>
|
||||
<DrawerBackdrop />
|
||||
<DrawerViewport>
|
||||
<DrawerPopup className="data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-[640px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-[0.5px]">
|
||||
<DrawerCloseButton
|
||||
aria-label={t('deployDrawer.close')}
|
||||
className="absolute top-4 right-5 size-6 rounded-md"
|
||||
/>
|
||||
<DrawerContent className="flex min-h-0 flex-1 flex-col bg-components-panel-bg p-0 pb-0">
|
||||
{!drawerAppInstanceId
|
||||
? <div className="p-6 text-text-tertiary">{t('deployDrawer.notFound')}</div>
|
||||
: (
|
||||
<DeployForm
|
||||
key={formKey}
|
||||
appInstanceId={drawerAppInstanceId}
|
||||
lockedEnvId={drawerEnvironmentId}
|
||||
presetReleaseId={drawerReleaseId}
|
||||
/>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</DrawerPopup>
|
||||
</DrawerViewport>
|
||||
</DrawerPortal>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@ -1,290 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
CredentialSlot,
|
||||
Environment,
|
||||
EnvVarSlot,
|
||||
Release,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import type { EnvVarValues } from '../env-var-bindings-utils'
|
||||
import type { RuntimeCredentialBindingSelections } from '../runtime-credential-bindings-utils'
|
||||
import { DrawerDescription, DrawerTitle } from '@langgenius/dify-ui/drawer'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { environmentBackend, environmentMode, environmentName } from '../../environment'
|
||||
import { formatDate, releaseCommit, releaseLabel } from '../../release'
|
||||
import { DeploymentStateMessage } from '../empty-state'
|
||||
import { EnvVarBindingsPanel } from '../env-var-bindings'
|
||||
import { RuntimeCredentialBindingsPanel } from '../runtime-credential-bindings'
|
||||
import {
|
||||
DeploymentSelect,
|
||||
EnvironmentRow,
|
||||
Field,
|
||||
} from './select'
|
||||
|
||||
export type EnvironmentOption = Environment & { id: string }
|
||||
|
||||
const DEPLOY_FORM_FIELD_SKELETON_KEYS = ['environment', 'release']
|
||||
const DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME = 'max-h-none overflow-visible'
|
||||
|
||||
function environmentOptionLabel(env: EnvironmentOption, t: ReturnType<typeof useTranslation<'deployments'>>['t']) {
|
||||
const description = env.description?.trim()
|
||||
if (description)
|
||||
return `${environmentName(env)} · ${description}`
|
||||
|
||||
return `${environmentName(env)} · ${t(environmentMode(env) === 'isolated' ? 'mode.isolated' : 'mode.shared')} · ${environmentBackend(env).toUpperCase()}`
|
||||
}
|
||||
|
||||
function BindingOptionsPanel({
|
||||
slots,
|
||||
selections,
|
||||
isLoading,
|
||||
hasError,
|
||||
bindingCountLabel,
|
||||
onChange,
|
||||
}: {
|
||||
slots: CredentialSlot[]
|
||||
selections: RuntimeCredentialBindingSelections
|
||||
isLoading: boolean
|
||||
hasError: boolean
|
||||
bindingCountLabel: string
|
||||
onChange: (slot: string, value: string) => void
|
||||
}) {
|
||||
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}
|
||||
listClassName={DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeployFormSkeleton() {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="shrink-0 border-b border-divider-subtle px-6 py-5 pr-14">
|
||||
<SkeletonContainer className="gap-2">
|
||||
<SkeletonRectangle className="h-5 w-44 animate-pulse" />
|
||||
<SkeletonRectangle className="h-3 w-72 animate-pulse" />
|
||||
</SkeletonContainer>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
<div className="flex flex-col gap-5">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkeletonRow className="shrink-0 justify-end border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeployFormHeader() {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-divider-subtle px-6 py-5 pr-14">
|
||||
<DrawerTitle className="title-xl-semi-bold text-text-primary">
|
||||
{t('deployDrawer.title')}
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{t('deployDrawer.description')}
|
||||
</DrawerDescription>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReleaseField({
|
||||
displayedRelease,
|
||||
isExistingRelease,
|
||||
releases,
|
||||
selectedReleaseId,
|
||||
onSelectRelease,
|
||||
}: {
|
||||
displayedRelease?: Release
|
||||
isExistingRelease: boolean
|
||||
releases: Release[]
|
||||
selectedReleaseId: string
|
||||
onSelectRelease: (releaseId: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<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
|
||||
? (
|
||||
<DeploymentStateMessage variant="compact">
|
||||
{t('deployDrawer.noReleaseAvailable')}
|
||||
</DeploymentStateMessage>
|
||||
)
|
||||
: (
|
||||
<DeploymentSelect
|
||||
value={selectedReleaseId}
|
||||
onChange={onSelectRelease}
|
||||
options={releases.filter(release => release.id).map(release => ({
|
||||
value: release.id!,
|
||||
label: `${releaseLabel(release)} · ${releaseCommit(release)}`,
|
||||
}))}
|
||||
placeholder={t('deployDrawer.selectRelease')}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnvironmentField({
|
||||
environments,
|
||||
lockedEnv,
|
||||
lockedEnvId,
|
||||
selectedEnvironmentId,
|
||||
onSelectEnvironment,
|
||||
}: {
|
||||
environments: EnvironmentOption[]
|
||||
lockedEnv?: EnvironmentOption
|
||||
lockedEnvId?: string
|
||||
selectedEnvironmentId: string
|
||||
onSelectEnvironment: (environmentId: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<Field
|
||||
label={t('deployDrawer.targetEnv')}
|
||||
hint={lockedEnvId ? t('deployDrawer.lockedHint') : undefined}
|
||||
>
|
||||
{lockedEnv
|
||||
? <EnvironmentRow env={lockedEnv} />
|
||||
: environments.length === 0
|
||||
? (
|
||||
<DeploymentStateMessage variant="compact">
|
||||
{t('deployDrawer.noNewEnvironmentAvailable')}
|
||||
</DeploymentStateMessage>
|
||||
)
|
||||
: (
|
||||
<DeploymentSelect
|
||||
value={selectedEnvironmentId}
|
||||
onChange={onSelectEnvironment}
|
||||
options={environments.filter(env => env.id).map(env => ({
|
||||
value: env.id!,
|
||||
label: environmentOptionLabel(env, t),
|
||||
}))}
|
||||
placeholder={t('deployDrawer.selectEnv')}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeploymentBindingsSection({
|
||||
bindingSlots,
|
||||
bindingSelections,
|
||||
bindingOptionsLoading,
|
||||
bindingOptionsError,
|
||||
envVarSlots,
|
||||
envVarValues,
|
||||
onBindingChange,
|
||||
onEnvVarChange,
|
||||
}: {
|
||||
bindingSlots: CredentialSlot[]
|
||||
bindingSelections: RuntimeCredentialBindingSelections
|
||||
bindingOptionsLoading: boolean
|
||||
bindingOptionsError: boolean
|
||||
envVarSlots: EnvVarSlot[]
|
||||
envVarValues: EnvVarValues
|
||||
onBindingChange: (slot: string, value: string) => void
|
||||
onEnvVarChange: (key: string, value: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<>
|
||||
<BindingOptionsPanel
|
||||
slots={bindingSlots}
|
||||
selections={bindingSelections}
|
||||
isLoading={bindingOptionsLoading}
|
||||
hasError={bindingOptionsError}
|
||||
bindingCountLabel={t('deployDrawer.bindingCount', { count: bindingSlots.length })}
|
||||
onChange={onBindingChange}
|
||||
/>
|
||||
{!bindingOptionsLoading && !bindingOptionsError && (
|
||||
<EnvVarBindingsPanel
|
||||
slots={envVarSlots}
|
||||
values={envVarValues}
|
||||
title={t('deployDrawer.envVars')}
|
||||
hint={t('deployDrawer.envVarHint')}
|
||||
requiredLabel={t('deployDrawer.requiredBinding')}
|
||||
envVarPlaceholder={t('deployDrawer.envVarPlaceholder')}
|
||||
envVarCountLabel={t('deployDrawer.envVarCount', { count: envVarSlots.length })}
|
||||
missingRequiredLabel={t('deployDrawer.missingRequiredEnvVar')}
|
||||
listClassName={DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME}
|
||||
showMissingRequired
|
||||
onChange={onEnvVarChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,261 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
EnvVarSlot,
|
||||
Release,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import type { EnvVarValues } from '../env-var-bindings-utils'
|
||||
import type { RuntimeCredentialBindingSelections } from '../runtime-credential-bindings-utils'
|
||||
import type { EnvironmentOption } from './form-sections'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { DEPLOYMENT_PAGE_SIZE } from '../../data'
|
||||
import { createDeploymentIdempotencyKey } from '../../idempotency'
|
||||
import { isAvailableDeploymentTarget } from '../../runtime-status'
|
||||
import { closeDeployDrawerAtom } from '../../store'
|
||||
import {
|
||||
hasEnvVarSlotKey,
|
||||
hasMissingRequiredEnvVarValue,
|
||||
selectedDeploymentEnvVars,
|
||||
} from '../env-var-bindings-utils'
|
||||
import {
|
||||
hasMissingRequiredRuntimeCredentialBinding,
|
||||
runtimeCredentialSlotKey,
|
||||
selectedDeploymentRuntimeCredentials,
|
||||
selectedRuntimeCredentialSelections,
|
||||
} from '../runtime-credential-bindings-utils'
|
||||
import {
|
||||
DeployFormHeader,
|
||||
DeployFormSkeleton,
|
||||
DeploymentBindingsSection,
|
||||
EnvironmentField,
|
||||
|
||||
ReleaseField,
|
||||
} from './form-sections'
|
||||
|
||||
type DeployFormProps = {
|
||||
appInstanceId: string
|
||||
lockedEnvId?: string
|
||||
presetReleaseId?: string
|
||||
}
|
||||
|
||||
type DeployReadyFormProps = DeployFormProps & {
|
||||
environments: EnvironmentOption[]
|
||||
releases: Release[]
|
||||
defaultReleaseId?: string
|
||||
}
|
||||
|
||||
type BindingSelections = RuntimeCredentialBindingSelections
|
||||
|
||||
function requiredSlotEnvVarSlot(slot: NonNullable<Release['requiredSlots']>[number]): EnvVarSlot | undefined {
|
||||
if (slot.type !== 'SLOT_TYPE_ENV_VAR')
|
||||
return undefined
|
||||
|
||||
const key = slot.name?.trim()
|
||||
return key ? { key } : undefined
|
||||
}
|
||||
|
||||
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 targetRelease = displayedRelease ?? selectedRelease
|
||||
const targetReleaseId = targetRelease?.id ?? selectedReleaseId
|
||||
const hasSelectedEnvironment = Boolean(selectedEnvironmentId && selectedEnvironment)
|
||||
const shouldLoadBindingOptions = Boolean(appInstanceId && targetReleaseId && hasSelectedEnvironment)
|
||||
const bindingOptions = useQuery(consoleQuery.enterprise.releaseService.listReleaseCredentialCandidates.queryOptions({
|
||||
input: shouldLoadBindingOptions
|
||||
? {
|
||||
params: {
|
||||
releaseId: targetReleaseId,
|
||||
},
|
||||
}
|
||||
: skipToken,
|
||||
}))
|
||||
const bindingSlots = bindingOptions.data?.slots?.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
|
||||
const envVarSlots = targetRelease?.requiredSlots
|
||||
?.map(requiredSlotEnvVarSlot)
|
||||
.filter((slot): slot is EnvVarSlot => hasEnvVarSlotKey(slot)) ?? []
|
||||
const [manualBindings, setManualBindings] = useState<BindingSelections>({})
|
||||
const [envVarValues, setEnvVarValues] = useState<EnvVarValues>({})
|
||||
const selectedBindings = selectedRuntimeCredentialSelections(bindingSlots, manualBindings)
|
||||
const deploymentCredentials = selectedDeploymentRuntimeCredentials(bindingSlots, selectedBindings)
|
||||
const deploymentEnvVars = selectedDeploymentEnvVars(envVarSlots, envVarValues)
|
||||
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 requiredEnvVarsReady = envVarSlots.every(slot => !hasMissingRequiredEnvVarValue(slot, envVarValues))
|
||||
const isSubmitting = startDeploy.isPending
|
||||
const canDeploy = Boolean(
|
||||
selectedEnvironmentId
|
||||
&& selectedEnvironment
|
||||
&& targetReleaseId
|
||||
&& bindingOptionsReady
|
||||
&& requiredBindingsReady
|
||||
&& requiredEnvVarsReady
|
||||
&& !isSubmitting,
|
||||
)
|
||||
|
||||
const lockedEnv = lockedEnvId ? environments.find(e => e.id === lockedEnvId) : undefined
|
||||
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,
|
||||
envVars: deploymentEnvVars.length > 0 ? deploymentEnvVars : undefined,
|
||||
idempotencyKey,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
closeDeployDrawer()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('deployDrawer.deployFailed'))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<DeployFormHeader />
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||
<div className="flex flex-col gap-5">
|
||||
<ReleaseField
|
||||
displayedRelease={displayedRelease}
|
||||
isExistingRelease={isExistingRelease}
|
||||
releases={releases}
|
||||
selectedReleaseId={selectedReleaseId}
|
||||
onSelectRelease={setSelectedReleaseId}
|
||||
/>
|
||||
|
||||
<EnvironmentField
|
||||
environments={environments}
|
||||
lockedEnv={lockedEnv}
|
||||
lockedEnvId={lockedEnvId}
|
||||
selectedEnvironmentId={selectedEnvironmentId}
|
||||
onSelectEnvironment={setSelectedEnvId}
|
||||
/>
|
||||
|
||||
{targetReleaseId && hasSelectedEnvironment && (
|
||||
<DeploymentBindingsSection
|
||||
bindingSlots={bindingSlots}
|
||||
bindingSelections={selectedBindings}
|
||||
bindingOptionsLoading={bindingOptionsLoading}
|
||||
bindingOptionsError={bindingOptions.isError}
|
||||
envVarSlots={envVarSlots}
|
||||
envVarValues={envVarValues}
|
||||
onBindingChange={(slot, value) => setManualBindings(prev => ({ ...prev, [slot]: value }))}
|
||||
onEnvVarChange={(key, value) => setEnvVarValues(prev => ({ ...prev, [key]: value }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 justify-end gap-2 border-t border-divider-subtle bg-background-default-subtle px-6 py-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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,134 +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 { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { environmentBackend, environmentHealth, environmentMode, environmentName } from '../../environment'
|
||||
import { ModeBadge } from '../status-badge'
|
||||
import { TitleTooltip } from '../title-tooltip'
|
||||
|
||||
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}
|
||||
>
|
||||
<TitleTooltip content={opt.disabled ? opt.disabledReason : undefined}>
|
||||
<SelectItemText>{opt.label}</SelectItemText>
|
||||
</TitleTooltip>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvironmentHealthDot({ health }: {
|
||||
health: ReturnType<typeof environmentHealth>
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const label = t(health === 'ready' ? 'health.ready' : 'health.degraded')
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-full',
|
||||
health === 'ready' ? 'bg-util-colors-green-green-50' : 'bg-util-colors-warning-warning-50',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-1.5 rounded-full',
|
||||
health === 'ready' ? 'bg-util-colors-green-green-500' : 'bg-util-colors-warning-warning-500',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnvironmentRow({ env }: { env: EnvironmentOption }) {
|
||||
const summary = env.description?.trim() || environmentBackend(env).toUpperCase()
|
||||
const health = environmentHealth(env)
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<EnvironmentHealthDot health={health} />
|
||||
<span className="truncate system-sm-semibold text-text-primary">{environmentName(env)}</span>
|
||||
<ModeBadge mode={environmentMode(env)} />
|
||||
</div>
|
||||
<span className="line-clamp-1 system-xs-regular text-text-tertiary">{summary}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,143 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
type DeploymentEmptyStateVariant = 'page' | 'list' | 'section' | 'compact'
|
||||
type DeploymentStateMessageVariant = 'page' | 'list' | 'section' | 'compact' | 'embedded'
|
||||
type DeploymentEmptyStateAlign = 'center' | 'start'
|
||||
|
||||
type DeploymentEmptyStateProps = {
|
||||
icon?: string
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
action?: ReactNode
|
||||
variant?: DeploymentEmptyStateVariant
|
||||
align?: DeploymentEmptyStateAlign
|
||||
className?: string
|
||||
}
|
||||
|
||||
type DeploymentStateMessageProps = {
|
||||
children: ReactNode
|
||||
variant?: DeploymentStateMessageVariant
|
||||
className?: string
|
||||
}
|
||||
|
||||
type DeploymentNoticeStateProps = {
|
||||
children: ReactNode
|
||||
icon?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const emptyStateContainerClassNames: Record<DeploymentEmptyStateVariant, string> = {
|
||||
page: 'col-span-full min-h-80 rounded-xl border border-divider-subtle bg-background-default-subtle px-6 py-12',
|
||||
list: 'min-h-60 rounded-lg border border-divider-subtle bg-background-default-subtle px-6 py-12',
|
||||
section: 'min-h-36 rounded-lg border border-divider-subtle bg-background-default-subtle px-6 py-8',
|
||||
compact: 'min-h-14 rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3',
|
||||
}
|
||||
|
||||
const stateMessageClassNames: Record<DeploymentStateMessageVariant, string> = {
|
||||
page: 'col-span-full flex min-h-80 items-center justify-center rounded-xl border border-dashed border-divider-subtle bg-background-default-subtle px-6 py-12 text-center system-sm-regular text-text-tertiary',
|
||||
list: 'flex min-h-36 items-center justify-center rounded-lg border border-dashed border-divider-subtle bg-background-default-subtle px-6 py-12 text-center system-sm-regular text-text-tertiary',
|
||||
section: 'flex min-h-24 items-center justify-center rounded-lg border border-dashed border-divider-subtle bg-background-default-subtle px-4 py-6 text-center system-sm-regular text-text-tertiary',
|
||||
compact: 'rounded-lg border border-dashed border-divider-subtle bg-background-default-subtle px-3 py-3 system-sm-regular text-text-tertiary',
|
||||
embedded: 'px-4 py-10 text-center system-sm-regular text-text-tertiary',
|
||||
}
|
||||
|
||||
export function DeploymentEmptyState({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
variant = 'list',
|
||||
align,
|
||||
className,
|
||||
}: DeploymentEmptyStateProps) {
|
||||
const effectiveAlign = align ?? (variant === 'compact' ? 'start' : 'center')
|
||||
const isLarge = variant === 'page' || variant === 'list'
|
||||
const hasDescription = Boolean(description)
|
||||
const hasAction = Boolean(action)
|
||||
const hasIcon = Boolean(icon)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="deployment-empty-state"
|
||||
className={cn(
|
||||
'flex flex-col justify-center border-dashed',
|
||||
effectiveAlign === 'center' ? 'items-center text-center' : 'items-start text-left',
|
||||
emptyStateContainerClassNames[variant],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasIcon && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center justify-center border border-components-panel-border bg-background-default-subtle text-text-tertiary',
|
||||
variant === 'compact' ? 'mb-2 size-8 rounded-lg' : 'mb-4',
|
||||
isLarge && 'size-11 rounded-xl',
|
||||
variant === 'section' && 'size-10 rounded-lg bg-background-section-burn',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
icon,
|
||||
isLarge ? 'size-5' : variant === 'section' ? 'size-4.5' : 'size-4',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
isLarge
|
||||
? 'system-md-semibold text-text-primary'
|
||||
: variant === 'compact' && !hasIcon && !hasDescription
|
||||
? 'system-sm-regular text-text-tertiary'
|
||||
: 'system-sm-medium text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{hasDescription && (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-1 max-w-120 text-text-tertiary',
|
||||
isLarge ? 'system-sm-regular' : 'system-xs-regular',
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{hasAction && (
|
||||
<div className={isLarge ? 'mt-5' : variant === 'compact' ? 'mt-3' : 'mt-4'}>
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeploymentStateMessage({
|
||||
children,
|
||||
variant = 'list',
|
||||
className,
|
||||
}: DeploymentStateMessageProps) {
|
||||
return (
|
||||
<div className={cn(stateMessageClassNames[variant], className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeploymentNoticeState({
|
||||
children,
|
||||
icon = 'i-ri-information-line',
|
||||
className,
|
||||
}: DeploymentNoticeStateProps) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
import type {
|
||||
EnvVarInput,
|
||||
EnvVarSlot,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
|
||||
export type EnvVarValues = Record<string, string>
|
||||
|
||||
export function envVarSlotKey(slot: EnvVarSlot) {
|
||||
return slot.key?.trim() ?? ''
|
||||
}
|
||||
|
||||
export function hasEnvVarSlotKey(slot?: EnvVarSlot) {
|
||||
return Boolean(slot && envVarSlotKey(slot))
|
||||
}
|
||||
|
||||
export function hasMissingRequiredEnvVarValue(slot: EnvVarSlot, values: EnvVarValues) {
|
||||
const key = envVarSlotKey(slot)
|
||||
|
||||
return !key || !values[key]?.trim()
|
||||
}
|
||||
|
||||
export function selectedDeploymentEnvVars(
|
||||
slots: EnvVarSlot[],
|
||||
values: EnvVarValues,
|
||||
): EnvVarInput[] {
|
||||
return slots
|
||||
.map((slot): EnvVarInput | undefined => {
|
||||
const key = envVarSlotKey(slot)
|
||||
if (!key)
|
||||
return undefined
|
||||
|
||||
const value = values[key]
|
||||
if (!value?.trim())
|
||||
return undefined
|
||||
|
||||
return {
|
||||
key,
|
||||
value,
|
||||
}
|
||||
})
|
||||
.filter((envVar): envVar is EnvVarInput => Boolean(envVar))
|
||||
}
|
||||
@ -1,102 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { EnvVarSlot } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { EnvVarValues } from './env-var-bindings-utils'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Input } from '@langgenius/dify-ui/input'
|
||||
import {
|
||||
envVarSlotKey,
|
||||
hasMissingRequiredEnvVarValue,
|
||||
} from './env-var-bindings-utils'
|
||||
import { TitleTooltip } from './title-tooltip'
|
||||
|
||||
type EnvVarBindingsPanelProps = {
|
||||
slots: EnvVarSlot[]
|
||||
values: EnvVarValues
|
||||
title: string
|
||||
hint: string
|
||||
requiredLabel: string
|
||||
envVarPlaceholder: string
|
||||
envVarCountLabel?: string
|
||||
missingRequiredLabel?: string
|
||||
showMissingRequired?: boolean
|
||||
onChange: (key: string, value: string) => void
|
||||
className?: string
|
||||
listClassName?: string
|
||||
}
|
||||
|
||||
function envVarInputId(index: number, key: string) {
|
||||
const safeKey = key.replace(/[^\w-]/g, '-')
|
||||
|
||||
return `env-var-binding-${index}-${safeKey}`
|
||||
}
|
||||
|
||||
export function EnvVarBindingsPanel({
|
||||
slots,
|
||||
values,
|
||||
title,
|
||||
hint,
|
||||
requiredLabel,
|
||||
envVarPlaceholder,
|
||||
envVarCountLabel,
|
||||
missingRequiredLabel,
|
||||
showMissingRequired = false,
|
||||
onChange,
|
||||
className,
|
||||
listClassName,
|
||||
}: EnvVarBindingsPanelProps) {
|
||||
if (slots.length === 0)
|
||||
return null
|
||||
|
||||
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>
|
||||
<span className="shrink-0 rounded-md bg-background-default px-1.5 py-0.5 system-2xs-medium text-text-quaternary">
|
||||
{envVarCountLabel ?? slots.length}
|
||||
</span>
|
||||
</div>
|
||||
<span className="system-xs-regular text-text-quaternary">{hint}</span>
|
||||
</div>
|
||||
<div className={cn('max-h-[min(360px,34dvh)] overflow-y-auto border-t border-divider-subtle', listClassName)}>
|
||||
{slots.map((slot, index) => {
|
||||
const key = envVarSlotKey(slot)
|
||||
const inputId = envVarInputId(index, key)
|
||||
const missing = showMissingRequired && hasMissingRequiredEnvVarValue(slot, values)
|
||||
|
||||
return (
|
||||
<div key={key} className="flex min-w-0 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 items-center gap-1.5">
|
||||
<TitleTooltip content={key}>
|
||||
<label className="truncate font-mono system-sm-semibold text-text-primary" htmlFor={inputId}>
|
||||
{key}
|
||||
</label>
|
||||
</TitleTooltip>
|
||||
<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>
|
||||
<Input
|
||||
id={inputId}
|
||||
value={values[key] ?? ''}
|
||||
onChange={event => onChange(key, event.target.value)}
|
||||
placeholder={envVarPlaceholder}
|
||||
autoComplete="off"
|
||||
required
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
{missing && missingRequiredLabel && (
|
||||
<div className="system-xs-regular text-text-destructive">
|
||||
{missingRequiredLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,118 +0,0 @@
|
||||
import type {
|
||||
CredentialCandidate,
|
||||
CredentialSelectionInput,
|
||||
CredentialSlot,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
|
||||
export type RuntimeCredentialBindingSelections = Record<string, string>
|
||||
|
||||
export type RuntimeCredentialSelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
azure_openai: 'Azure OpenAI',
|
||||
bedrock: 'Amazon Bedrock',
|
||||
gemini: 'Gemini',
|
||||
google: 'Google',
|
||||
openai: 'OpenAI',
|
||||
vertex_ai: 'Vertex AI',
|
||||
volcengine_maas: 'Volcengine',
|
||||
}
|
||||
|
||||
function providerSlug(providerId?: string) {
|
||||
const parts = providerId?.split('/').filter(Boolean) ?? []
|
||||
return parts[parts.length - 1] ?? ''
|
||||
}
|
||||
|
||||
function titleCaseProviderName(value: string) {
|
||||
return value
|
||||
.split(/[-_]/)
|
||||
.filter(Boolean)
|
||||
.map(part => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function runtimeCredentialSlotKey(slot: CredentialSlot) {
|
||||
return [slot.providerId ?? '', slot.category ?? ''].join(':')
|
||||
}
|
||||
|
||||
export function runtimeCredentialProviderName(providerId?: string) {
|
||||
const slug = providerSlug(providerId)
|
||||
if (!slug)
|
||||
return ''
|
||||
|
||||
return PROVIDER_DISPLAY_NAMES[slug.toLowerCase()] ?? titleCaseProviderName(slug)
|
||||
}
|
||||
|
||||
function runtimeCredentialCandidateLabel(candidate: CredentialCandidate) {
|
||||
const fallback = candidate.credentialId ?? ''
|
||||
const rawLabel = candidate.displayName?.trim() || fallback
|
||||
const providerId = candidate.providerId?.trim()
|
||||
if (!providerId)
|
||||
return rawLabel
|
||||
|
||||
const providerSuffixes = [
|
||||
` · ${providerId}`,
|
||||
` - ${providerId}`,
|
||||
` (${providerId})`,
|
||||
]
|
||||
const label = providerSuffixes.reduce((nextLabel, suffix) => {
|
||||
return nextLabel.endsWith(suffix)
|
||||
? nextLabel.slice(0, -suffix.length).trim()
|
||||
: nextLabel
|
||||
}, rawLabel)
|
||||
|
||||
return label || fallback
|
||||
}
|
||||
|
||||
export function runtimeCredentialCandidateOptions(slot: CredentialSlot): RuntimeCredentialSelectOption[] {
|
||||
return (slot.candidates ?? [])
|
||||
.filter(candidate => candidate.credentialId)
|
||||
.map(candidate => ({
|
||||
value: candidate.credentialId!,
|
||||
label: runtimeCredentialCandidateLabel(candidate),
|
||||
}))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@ -1,194 +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,
|
||||
runtimeCredentialProviderName,
|
||||
runtimeCredentialSlotKey,
|
||||
} from './runtime-credential-bindings-utils'
|
||||
import { TitleTooltip } from './title-tooltip'
|
||||
|
||||
type RuntimeCredentialBindingsPanelProps = {
|
||||
slots: CredentialSlot[]
|
||||
selections: RuntimeCredentialBindingSelections
|
||||
title: string
|
||||
hint: string
|
||||
requiredLabel: string
|
||||
noBindingRequiredLabel: string
|
||||
noCredentialCandidatesLabel: string
|
||||
selectCredentialLabel: string
|
||||
missingRequiredLabel: string
|
||||
bindingCountLabel?: string
|
||||
showMissingRequired?: boolean
|
||||
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}>
|
||||
<TitleTooltip content={option.label}>
|
||||
<SelectItemText>{option.label}</SelectItemText>
|
||||
</TitleTooltip>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export function RuntimeCredentialBindingsPanel({
|
||||
slots,
|
||||
selections,
|
||||
title,
|
||||
hint,
|
||||
requiredLabel,
|
||||
noBindingRequiredLabel,
|
||||
noCredentialCandidatesLabel,
|
||||
selectCredentialLabel,
|
||||
missingRequiredLabel,
|
||||
bindingCountLabel,
|
||||
showMissingRequired = false,
|
||||
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 = showMissingRequired && hasMissingRequiredRuntimeCredentialBinding(slot, selectedValue)
|
||||
const slotName = runtimeCredentialProviderName(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">
|
||||
<TitleTooltip content={slotName}>
|
||||
<span className="truncate system-sm-semibold text-text-primary">
|
||||
{slotName}
|
||||
</span>
|
||||
</TitleTooltip>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
|
||||
export function TitleTooltip({
|
||||
children,
|
||||
content,
|
||||
}: {
|
||||
children: ReactElement
|
||||
content?: ReactNode
|
||||
}) {
|
||||
if (!content)
|
||||
return children
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={children} />
|
||||
<TooltipContent>{content}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -1,73 +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,
|
||||
isSkippingDeployment,
|
||||
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}
|
||||
isSkippingDeployment={isSkippingDeployment}
|
||||
step={step}
|
||||
onBack={handleBack}
|
||||
onPrimaryAction={handlePrimaryAction}
|
||||
onSkipDeployment={handleSkipDeployment}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{guideContent}
|
||||
</GuideCard>
|
||||
</GuideFrame>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,254 +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 { ScrollArea } from '@langgenius/dify-ui/scroll-area'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TitleTooltip } from '../components/title-tooltip'
|
||||
|
||||
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 (
|
||||
<TitleTooltip key={step} content={label}>
|
||||
<li
|
||||
aria-current={isActive ? 'step' : undefined}
|
||||
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>
|
||||
</TitleTooltip>
|
||||
)
|
||||
})}
|
||||
</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">
|
||||
<ScrollArea
|
||||
className="min-h-0 flex-1"
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'min-h-full pt-0.5 pb-6',
|
||||
scrollbar: 'data-[orientation=vertical]:-me-5 data-[orientation=vertical]:my-1',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ScrollArea>
|
||||
{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-hidden">
|
||||
<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-[184px] 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,
|
||||
isSkippingDeployment,
|
||||
step,
|
||||
onBack,
|
||||
onPrimaryAction,
|
||||
onSkipDeployment,
|
||||
}: {
|
||||
canContinue: boolean
|
||||
canSkipDeployment: boolean
|
||||
isDeploying: boolean
|
||||
isSkippingDeployment: boolean
|
||||
step: GuideStep
|
||||
onBack: () => void
|
||||
onPrimaryAction: () => void
|
||||
onSkipDeployment: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const primaryLabel = step === 'target'
|
||||
? isDeploying && !isSkippingDeployment ? t('createGuide.actions.deploying') : t('createGuide.actions.createAndDeploy')
|
||||
: step === 'release' && isDeploying
|
||||
? t('createGuide.actions.creating')
|
||||
: t('createGuide.actions.next')
|
||||
const skipLabel = isSkippingDeployment
|
||||
? t('createGuide.actions.creating')
|
||||
: t('createGuide.actions.skipDeploy')
|
||||
|
||||
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}>
|
||||
{skipLabel}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="primary" disabled={!canContinue || isDeploying} onClick={onPrimaryAction}>
|
||||
{primaryLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { GuideMethod } from './types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TitleTooltip } from '../components/title-tooltip'
|
||||
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 outline-hidden hover:shadow-md focus-visible:ring-2
|
||||
focus-visible:ring-state-accent-solid sm:w-[240px]`,
|
||||
selected && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-md ring-[0.5px] ring-components-option-card-option-selected-border ring-inset',
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<TitleTooltip content={description}>
|
||||
<span className="line-clamp-2 min-w-0 grow system-xs-regular text-text-tertiary">
|
||||
{description}
|
||||
</span>
|
||||
</TitleTooltip>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@ -1,118 +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,
|
||||
instanceNameError,
|
||||
releaseNamePlaceholder,
|
||||
onInstanceNameChange,
|
||||
onInstanceDescriptionChange,
|
||||
onReleaseNameChange,
|
||||
onReleaseDescriptionChange,
|
||||
}: {
|
||||
instanceName: string
|
||||
instanceDescription: string
|
||||
releaseName: string
|
||||
releaseDescription: string
|
||||
instanceNamePlaceholder: string
|
||||
instanceNameError?: string
|
||||
releaseNamePlaceholder: string
|
||||
onInstanceNameChange: (value: string) => void
|
||||
onInstanceDescriptionChange: (value: string) => void
|
||||
onReleaseNameChange: (value: string) => void
|
||||
onReleaseDescriptionChange: (value: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const instanceNameErrorId = 'create-guide-instance-name-error'
|
||||
|
||||
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
|
||||
aria-invalid={instanceNameError ? true : undefined}
|
||||
aria-describedby={instanceNameError ? instanceNameErrorId : undefined}
|
||||
className="h-9"
|
||||
/>
|
||||
{instanceNameError && (
|
||||
<div id={instanceNameErrorId} role="alert" className="system-xs-regular text-text-destructive">
|
||||
{instanceNameError}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@ -1,107 +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,
|
||||
instanceNameError,
|
||||
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
|
||||
instanceNameError?: 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' && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{stage === 'release' && method && (
|
||||
<ReleaseStep
|
||||
instanceName={instanceName}
|
||||
instanceDescription={instanceDescription}
|
||||
releaseName={releaseName}
|
||||
releaseDescription={releaseDescription}
|
||||
instanceNamePlaceholder={sourceName}
|
||||
instanceNameError={instanceNameError}
|
||||
releaseNamePlaceholder={defaultedReleaseName}
|
||||
onInstanceNameChange={onInstanceNameChange}
|
||||
onInstanceDescriptionChange={onInstanceDescriptionChange}
|
||||
onReleaseNameChange={onReleaseNameChange}
|
||||
onReleaseDescriptionChange={onReleaseDescriptionChange}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,159 +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 { DeploymentStateMessage } from '../components/empty-state'
|
||||
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-b-divider-subtle px-3 py-2 transition-colors first:rounded-t-lg last:rounded-b-lg last:border-b-0',
|
||||
selected
|
||||
? 'bg-state-accent-hover hover:bg-state-accent-hover'
|
||||
: '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={cn('truncate system-sm-medium', selected ? 'text-text-accent' : '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-primary-600 text-text-primary-on-surface' : '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-[336px] overflow-y-auto rounded-lg border border-divider-subtle bg-background-default">
|
||||
{isLoading
|
||||
? <SourceAppSkeleton />
|
||||
: filteredApps.length === 0
|
||||
? (
|
||||
<DeploymentStateMessage variant="embedded">
|
||||
{t('createGuide.source.empty')}
|
||||
</DeploymentStateMessage>
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
{filteredApps.map(app => (
|
||||
<SourceAppOption
|
||||
key={app.id}
|
||||
app={app}
|
||||
selected={effectiveSelectedAppId === app.id}
|
||||
onSelect={() => onSelectApp(app)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
@ -1,260 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
CredentialSlot,
|
||||
EnvVarSlot,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import type { EnvVarValues } from '../components/env-var-bindings-utils'
|
||||
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 {
|
||||
EnvVarBindingsPanel,
|
||||
} from '../components/env-var-bindings'
|
||||
import {
|
||||
RuntimeCredentialBindingsPanel,
|
||||
} from '../components/runtime-credential-bindings'
|
||||
import { TitleTooltip } from '../components/title-tooltip'
|
||||
|
||||
import {
|
||||
environmentBackend,
|
||||
environmentMatchesIdentifier,
|
||||
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)
|
||||
const summary = environment.description?.trim() || `${t(mode === 'isolated' ? 'mode.isolated' : 'mode.shared')} · ${environmentBackend(environment).toUpperCase()}`
|
||||
|
||||
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>
|
||||
<TitleTooltip content={summary}>
|
||||
<span className={cn('line-clamp-1 system-xs-regular', selected ? 'text-text-secondary' : 'text-text-tertiary')}>
|
||||
{summary}
|
||||
</span>
|
||||
</TitleTooltip>
|
||||
</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,
|
||||
envVarSlots,
|
||||
selectedEnvironmentId,
|
||||
bindingSelections,
|
||||
envVarValues,
|
||||
isEnvironmentLoading,
|
||||
isEnvironmentError,
|
||||
isBindingLoading,
|
||||
isBindingError,
|
||||
onSelectEnvironment,
|
||||
onSelectBinding,
|
||||
onSetEnvVar,
|
||||
}: {
|
||||
environments: EnvironmentOption[]
|
||||
bindingSlots: CredentialSlot[]
|
||||
envVarSlots: EnvVarSlot[]
|
||||
selectedEnvironmentId: string
|
||||
bindingSelections: BindingSelections
|
||||
envVarValues: EnvVarValues
|
||||
isEnvironmentLoading: boolean
|
||||
isEnvironmentError: boolean
|
||||
isBindingLoading: boolean
|
||||
isBindingError: boolean
|
||||
onSelectEnvironment: (environmentId: string) => void
|
||||
onSelectBinding: (slot: string, value: string) => void
|
||||
onSetEnvVar: (key: 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={environmentMatchesIdentifier(environment, selectedEnvironmentId)}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
{!isBindingLoading && !isBindingError && (
|
||||
<EnvVarBindingsPanel
|
||||
slots={envVarSlots}
|
||||
values={envVarValues}
|
||||
title={t('createGuide.target.envVars')}
|
||||
hint={t('createGuide.target.envVarHint')}
|
||||
requiredLabel={t('createGuide.target.required')}
|
||||
envVarPlaceholder={t('createGuide.target.envVarPlaceholder')}
|
||||
envVarCountLabel={t('createGuide.target.envVarCount', { count: envVarSlots.length })}
|
||||
onChange={onSetEnvVar}
|
||||
className="border-components-option-card-option-border bg-components-option-card-option-bg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</StepShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function TargetReviewSections({
|
||||
bindingSelections,
|
||||
bindingSlots,
|
||||
envVarSlots,
|
||||
envVarValues,
|
||||
environments,
|
||||
isBindingError,
|
||||
isBindingLoading,
|
||||
isEnvironmentError,
|
||||
isEnvironmentLoading,
|
||||
onSelectBinding,
|
||||
onSelectEnvironment,
|
||||
onSetEnvVar,
|
||||
selectedEnvironmentId,
|
||||
}: {
|
||||
bindingSelections: BindingSelections
|
||||
bindingSlots: CredentialSlot[]
|
||||
envVarSlots: EnvVarSlot[]
|
||||
envVarValues: EnvVarValues
|
||||
environments: EnvironmentOption[]
|
||||
isBindingError: boolean
|
||||
isBindingLoading: boolean
|
||||
isEnvironmentError: boolean
|
||||
isEnvironmentLoading: boolean
|
||||
onSelectBinding: (slot: string, value: string) => void
|
||||
onSelectEnvironment: (environmentId: string) => void
|
||||
onSetEnvVar: (key: string, value: string) => void
|
||||
selectedEnvironmentId: string
|
||||
}) {
|
||||
return (
|
||||
<TargetStep
|
||||
environments={environments}
|
||||
bindingSlots={bindingSlots}
|
||||
envVarSlots={envVarSlots}
|
||||
selectedEnvironmentId={selectedEnvironmentId}
|
||||
bindingSelections={bindingSelections}
|
||||
envVarValues={envVarValues}
|
||||
isEnvironmentLoading={isEnvironmentLoading}
|
||||
isEnvironmentError={isEnvironmentError}
|
||||
isBindingLoading={isBindingLoading}
|
||||
isBindingError={isBindingError}
|
||||
onSelectEnvironment={onSelectEnvironment}
|
||||
onSelectBinding={onSelectBinding}
|
||||
onSetEnvVar={onSetEnvVar}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
@ -1,567 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
Environment,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import type { EnvVarValues } from '../components/env-var-bindings-utils'
|
||||
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 { consoleClient, consoleQuery } from '@/service/client'
|
||||
import {
|
||||
hasEnvVarSlotKey,
|
||||
hasMissingRequiredEnvVarValue,
|
||||
selectedDeploymentEnvVars,
|
||||
} from '../components/env-var-bindings-utils'
|
||||
import {
|
||||
hasMissingRequiredRuntimeCredentialBinding,
|
||||
runtimeCredentialSlotKey,
|
||||
selectedDeploymentRuntimeCredentials,
|
||||
selectedRuntimeCredentialSelections,
|
||||
} from '../components/runtime-credential-bindings-utils'
|
||||
import { DEPLOYMENT_PAGE_SIZE, SOURCE_APPS_PAGE_SIZE } from '../data'
|
||||
import {
|
||||
environmentDeploymentId,
|
||||
environmentMatchesIdentifier,
|
||||
} from '../environment'
|
||||
import { deploymentErrorMessage } from '../error'
|
||||
import { createDeploymentIdempotencyKey } from '../idempotency'
|
||||
|
||||
type DslMetadata = {
|
||||
app?: {
|
||||
name?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
const RANDOM_SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz'
|
||||
const RANDOM_SUFFIX_LENGTH = 4
|
||||
const RANDOM_SUFFIX_FALLBACK_LENGTH = 6
|
||||
const RANDOM_SUFFIX_MAX_ATTEMPTS = 16
|
||||
|
||||
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 ''
|
||||
}
|
||||
}
|
||||
|
||||
function randomLetterCombination(length: number) {
|
||||
const randomValues = new Uint8Array(length)
|
||||
|
||||
if (globalThis.crypto) {
|
||||
globalThis.crypto.getRandomValues(randomValues)
|
||||
}
|
||||
else {
|
||||
randomValues.forEach((_, index) => {
|
||||
randomValues[index] = Math.floor(Math.random() * 256)
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(randomValues, value => RANDOM_SUFFIX_ALPHABET[value % RANDOM_SUFFIX_ALPHABET.length]).join('')
|
||||
}
|
||||
|
||||
function availableInstanceName(baseName: string, existingNames: readonly string[]) {
|
||||
const existingNameSet = new Set(existingNames)
|
||||
if (!existingNameSet.has(baseName))
|
||||
return baseName
|
||||
|
||||
for (let attempt = 0; attempt < RANDOM_SUFFIX_MAX_ATTEMPTS; attempt++) {
|
||||
const candidate = `${baseName}-${randomLetterCombination(RANDOM_SUFFIX_LENGTH)}`
|
||||
if (!existingNameSet.has(candidate))
|
||||
return candidate
|
||||
}
|
||||
|
||||
return `${baseName}-${randomLetterCombination(RANDOM_SUFFIX_FALLBACK_LENGTH)}`
|
||||
}
|
||||
|
||||
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 [envVarValues, setEnvVarValues] = useState<EnvVarValues>({})
|
||||
const [isSkippingReleaseOnly, setIsSkippingReleaseOnly] = useState(false)
|
||||
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 appInstancesQuery = useQuery({
|
||||
...consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
pageNumber: 1,
|
||||
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
|
||||
},
|
||||
},
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
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 envVarSlots = shouldLoadDeploymentTarget
|
||||
? deploymentOptions?.envVarSlots?.filter(hasEnvVarSlotKey) ?? []
|
||||
: []
|
||||
const effectiveSelectedEnvironmentId = selectedEnvironmentId || environments[0]?.id || ''
|
||||
const selectedEnvironment = environments.find(env => environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId)) ?? environments[0]
|
||||
const bindingSelections = selectedRuntimeCredentialSelections(bindingSlots, manualBindingSelections)
|
||||
const requiredBindingsReady = bindingSlots.every(slot => !hasMissingRequiredRuntimeCredentialBinding(slot, bindingSelections[runtimeCredentialSlotKey(slot)]))
|
||||
const requiredEnvVarsReady = envVarSlots.every(slot => !hasMissingRequiredEnvVarValue(slot, envVarValues))
|
||||
const isEnvironmentLoading = shouldLoadDeploymentTarget && (deployableEnvironmentsQuery.isLoading || (deployableEnvironmentsQuery.isFetching && !deployableEnvironmentsQuery.data))
|
||||
const isBindingLoading = shouldLoadDeploymentTarget && (deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data))
|
||||
const isDeploying = isSkippingReleaseOnly
|
||||
|| 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 existingInstanceNames = appInstancesQuery.data?.data?.map(appInstance => appInstance.name?.trim()).filter((name): name is string => Boolean(name)) ?? []
|
||||
const hasInstanceNameConflict = Boolean(submittedInstanceName && existingInstanceNames.includes(submittedInstanceName))
|
||||
const instanceNameError = hasInstanceNameConflict ? t('createGuide.release.instanceNameConflict') : undefined
|
||||
const isSourceReady = Boolean(method && (method === 'importDsl' ? hasDslContent && !isReadingDsl && !dslReadError : effectiveSelectedApp?.id))
|
||||
const isInitialReleaseReady = Boolean(isSourceReady && submittedInstanceName && submittedReleaseName && !hasInstanceNameConflict)
|
||||
const showTargetConfiguration = Boolean(method && step === 'target')
|
||||
|
||||
function selectMethod(nextMethod: GuideMethod) {
|
||||
setMethod(nextMethod)
|
||||
setSelectedEnvironmentId('')
|
||||
setManualBindingSelections({})
|
||||
setEnvVarValues({})
|
||||
}
|
||||
|
||||
function handleDslFileChange(file?: File) {
|
||||
const readToken = dslReadTokenRef.current + 1
|
||||
dslReadTokenRef.current = readToken
|
||||
setDslFile(file)
|
||||
setDslContent('')
|
||||
setDslDefaultAppName('')
|
||||
setDslReadError(false)
|
||||
setSelectedEnvironmentId('')
|
||||
setManualBindingSelections({})
|
||||
setEnvVarValues({})
|
||||
|
||||
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
|
||||
&& requiredEnvVarsReady
|
||||
&& 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({})
|
||||
setEnvVarValues({})
|
||||
setStep('target')
|
||||
}
|
||||
|
||||
function applyReleaseDefaults() {
|
||||
const nextInstanceName = sourceName.trim()
|
||||
|
||||
if (!instanceName.trim() && nextInstanceName)
|
||||
setInstanceName(availableInstanceName(nextInstanceName, existingInstanceNames))
|
||||
if (!releaseName.trim())
|
||||
setReleaseName(defaultedReleaseName)
|
||||
}
|
||||
|
||||
async function createInitialReleaseOnly() {
|
||||
setIsSkippingReleaseOnly(true)
|
||||
|
||||
try {
|
||||
const createdAppInstance = await consoleClient.enterprise.appInstanceService.createAppInstance({
|
||||
body: {
|
||||
name: submittedInstanceName,
|
||||
description: instanceDescription.trim() || undefined,
|
||||
},
|
||||
})
|
||||
const appInstanceId = createdAppInstance.appInstance?.id
|
||||
if (!appInstanceId)
|
||||
throw new Error('Create app instance did not return an app instance.')
|
||||
|
||||
const createdRelease = method === 'importDsl'
|
||||
? await consoleClient.enterprise.releaseService.createReleaseFromDsl({
|
||||
body: {
|
||||
appInstanceId,
|
||||
dsl: encodedDslContent,
|
||||
name: submittedReleaseName,
|
||||
description: submittedReleaseDescription || undefined,
|
||||
createAppInstance: false,
|
||||
},
|
||||
})
|
||||
: effectiveSelectedApp?.id
|
||||
? await consoleClient.enterprise.releaseService.createReleaseFromSourceApp({
|
||||
body: {
|
||||
appInstanceId,
|
||||
sourceAppId: effectiveSelectedApp.id,
|
||||
name: submittedReleaseName,
|
||||
description: submittedReleaseDescription || undefined,
|
||||
createAppInstance: false,
|
||||
},
|
||||
})
|
||||
: undefined
|
||||
|
||||
if (!createdRelease?.release?.id)
|
||||
throw new Error('Create release did not return a release.')
|
||||
|
||||
router.push(`/deployments/${appInstanceId}/overview`)
|
||||
}
|
||||
finally {
|
||||
setIsSkippingReleaseOnly(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function createDeploymentAndRelease({ deployToEnvironment }: {
|
||||
deployToEnvironment: boolean
|
||||
}) {
|
||||
if (isDeploying || !isInitialReleaseReady)
|
||||
return
|
||||
if (hasInstanceNameConflict)
|
||||
return
|
||||
if (deployToEnvironment && !selectedEnvironment?.id)
|
||||
return
|
||||
if (method === 'bindApp' && !effectiveSelectedApp?.id)
|
||||
return
|
||||
if (method === 'importDsl' && !hasDslContent)
|
||||
return
|
||||
|
||||
try {
|
||||
if (!deployToEnvironment) {
|
||||
await createInitialReleaseOnly()
|
||||
return
|
||||
}
|
||||
|
||||
const targetEnvironmentId = await resolveSelectedDeploymentEnvironmentId()
|
||||
if (!targetEnvironmentId) {
|
||||
toast.error(t('createGuide.errors.deployFailed'))
|
||||
return
|
||||
}
|
||||
|
||||
const missingRequiredBinding = bindingSlots.some(slot => hasMissingRequiredRuntimeCredentialBinding(slot, bindingSelections[runtimeCredentialSlotKey(slot)]))
|
||||
if (missingRequiredBinding)
|
||||
throw new Error('Missing required deployment binding.')
|
||||
const missingRequiredEnvVar = envVarSlots.some(slot => hasMissingRequiredEnvVarValue(slot, envVarValues))
|
||||
if (missingRequiredEnvVar)
|
||||
throw new Error('Missing required deployment environment variable.')
|
||||
|
||||
const idempotencyKey = createDeploymentIdempotencyKey()
|
||||
const response = method === 'importDsl'
|
||||
? await createInitialDeploymentFromDsl.mutateAsync({
|
||||
body: {
|
||||
dsl: encodedDslContent,
|
||||
environmentId: targetEnvironmentId,
|
||||
appInstanceName: submittedInstanceName,
|
||||
appInstanceDescription: instanceDescription.trim() || undefined,
|
||||
releaseName: submittedReleaseName,
|
||||
releaseDescription: submittedReleaseDescription || undefined,
|
||||
credentials: selectedDeploymentRuntimeCredentials(bindingSlots, bindingSelections),
|
||||
envVars: selectedDeploymentEnvVars(envVarSlots, envVarValues),
|
||||
idempotencyKey,
|
||||
expectedDslDigest: deploymentOptions?.dslDigest,
|
||||
},
|
||||
})
|
||||
: effectiveSelectedApp?.id
|
||||
? await createInitialDeploymentFromSourceApp.mutateAsync({
|
||||
body: {
|
||||
sourceAppId: effectiveSelectedApp.id,
|
||||
environmentId: targetEnvironmentId,
|
||||
appInstanceName: submittedInstanceName,
|
||||
appInstanceDescription: instanceDescription.trim() || undefined,
|
||||
releaseName: submittedReleaseName,
|
||||
releaseDescription: submittedReleaseDescription || undefined,
|
||||
credentials: selectedDeploymentRuntimeCredentials(bindingSlots, bindingSelections),
|
||||
envVars: selectedDeploymentEnvVars(envVarSlots, envVarValues),
|
||||
idempotencyKey,
|
||||
expectedDslDigest: deploymentOptions?.dslDigest,
|
||||
},
|
||||
})
|
||||
: 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 (error) {
|
||||
const fallbackMessage = t(deployToEnvironment ? 'createGuide.errors.deployFailed' : 'createGuide.errors.createReleaseFailed')
|
||||
toast.error(await deploymentErrorMessage(error) || fallbackMessage)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeploy() {
|
||||
await createDeploymentAndRelease({ deployToEnvironment: true })
|
||||
}
|
||||
|
||||
async function handleSkipDeployment() {
|
||||
await createDeploymentAndRelease({ deployToEnvironment: false })
|
||||
}
|
||||
|
||||
async function resolveSelectedDeploymentEnvironmentId() {
|
||||
const currentEnvironmentId = environmentDeploymentId(selectedEnvironment)
|
||||
if (currentEnvironmentId)
|
||||
return currentEnvironmentId
|
||||
|
||||
const selectedEnvironmentIdentifier = selectedEnvironmentId || selectedEnvironment?.id || selectedEnvironment?.name || ''
|
||||
const selectedEnvironmentName = selectedEnvironment?.name || ''
|
||||
const freshResult = await deployableEnvironmentsQuery.refetch()
|
||||
const freshEnvironments = freshResult.data?.data?.filter(hasEnvironmentId) ?? []
|
||||
const freshSelectedEnvironment = freshEnvironments.find(environment => (
|
||||
environmentMatchesIdentifier(environment, selectedEnvironmentIdentifier)
|
||||
|| (selectedEnvironmentName && environment.name === selectedEnvironmentName)
|
||||
)) ?? freshEnvironments[0]
|
||||
|
||||
return environmentDeploymentId(freshSelectedEnvironment)
|
||||
}
|
||||
|
||||
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,
|
||||
instanceNameError,
|
||||
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,
|
||||
isSkippingDeployment: isSkippingReleaseOnly,
|
||||
showTargetConfiguration,
|
||||
step,
|
||||
targetReviewSectionsProps: {
|
||||
bindingSelections,
|
||||
bindingSlots,
|
||||
environments,
|
||||
envVarSlots,
|
||||
envVarValues,
|
||||
isBindingError: deploymentOptionsQuery.isError,
|
||||
isBindingLoading,
|
||||
isEnvironmentError: deployableEnvironmentsQuery.isError,
|
||||
isEnvironmentLoading,
|
||||
onSelectBinding: (slot: string, value: string) => {
|
||||
setManualBindingSelections(prev => ({ ...prev, [slot]: value }))
|
||||
},
|
||||
onSelectEnvironment: setSelectedEnvironmentId,
|
||||
onSetEnvVar: (key: string, value: string) => {
|
||||
setEnvVarValues(prev => ({ ...prev, [key]: value }))
|
||||
},
|
||||
selectedEnvironmentId: effectiveSelectedEnvironmentId,
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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]
|
||||
}
|
||||
@ -1,83 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
type EnvironmentDeploymentBadgeProps = Omit<ComponentPropsWithRef<'span'>, 'children' | 'title'> & {
|
||||
row: EnvironmentDeployment
|
||||
showStatus?: boolean
|
||||
summaryLabel?: string
|
||||
}
|
||||
|
||||
export function EnvironmentDeploymentBadge({
|
||||
row,
|
||||
className,
|
||||
showStatus = true,
|
||||
summaryLabel,
|
||||
'aria-label': ariaLabel,
|
||||
ref,
|
||||
...props
|
||||
}: EnvironmentDeploymentBadgeProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const name = environmentName(row.environment)
|
||||
const status = deploymentStatus(row)
|
||||
const toneClassNames = deploymentStatusToneClassNames(status)
|
||||
const statusLabel = t(deploymentStatusLabelKey(status))
|
||||
const label = summaryLabel ?? `${name} · ${statusLabel}`
|
||||
const visibleLabel = showStatus ? `${name} · ${statusLabel}` : name
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
aria-label={ariaLabel ?? 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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', toneClassNames.dot, status === 'deploying' && 'animate-pulse')} />
|
||||
<span className="truncate">{visibleLabel}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -1,121 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DeploymentEmptyState,
|
||||
DeploymentNoticeState,
|
||||
DeploymentStateMessage,
|
||||
} from '../components/empty-state'
|
||||
|
||||
type SectionProps = {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
children: ReactNode
|
||||
layout?: 'block' | 'row'
|
||||
tone?: 'default' | 'destructive'
|
||||
showDivider?: boolean
|
||||
}
|
||||
|
||||
export function DetailEmptyState(props: Parameters<typeof DeploymentEmptyState>[0]) {
|
||||
return <DeploymentEmptyState {...props} />
|
||||
}
|
||||
|
||||
export function DetailNoticeState(props: Parameters<typeof DeploymentNoticeState>[0]) {
|
||||
return <DeploymentNoticeState {...props} />
|
||||
}
|
||||
|
||||
export function SectionState({ children }: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
return <DeploymentStateMessage variant="section">{children}</DeploymentStateMessage>
|
||||
}
|
||||
|
||||
export function DetailListState({ children }: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
return <DeploymentStateMessage variant="list">{children}</DeploymentStateMessage>
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -1,128 +0,0 @@
|
||||
'use client'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { deploymentStatusPollingInterval, hasRuntimeInstanceDeployment } from '../runtime-status'
|
||||
import {
|
||||
DetailEmptyState,
|
||||
DetailListState,
|
||||
} from './common'
|
||||
import { DeploymentEnvironmentList } from './deploy-tab/deployment-environment-list'
|
||||
import { NewDeploymentButton } from './deploy-tab/new-deployment-button'
|
||||
import {
|
||||
DetailTable,
|
||||
DetailTableBody,
|
||||
DetailTableCard,
|
||||
DetailTableCardList,
|
||||
DetailTableCell,
|
||||
DetailTableHead,
|
||||
DetailTableHeader,
|
||||
DetailTableRow,
|
||||
} from './table'
|
||||
import {
|
||||
DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES,
|
||||
} from './table-styles'
|
||||
|
||||
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
|
||||
? <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>
|
||||
)
|
||||
}
|
||||
@ -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 system-sm-regular break-words whitespace-pre-wrap 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,108 +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,
|
||||
deploymentStatusToneClassNames,
|
||||
} from '../../deployment-ui-utils'
|
||||
import { releaseLabel } from '../../release'
|
||||
import {
|
||||
deploymentStatus,
|
||||
isUndeployedDeploymentRow,
|
||||
} from '../../runtime-status'
|
||||
|
||||
function DeploymentStatusPill({ status, label }: {
|
||||
status: DeploymentUiStatus
|
||||
label: string
|
||||
}) {
|
||||
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,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('size-3.5 shrink-0', deploymentStatusIconClassName(status), toneClassNames.icon)}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeploymentStatusSummary({ row }: {
|
||||
row: EnvironmentDeployment
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
if (isUndeployedDeploymentRow(row)) {
|
||||
return (
|
||||
<DeploymentStatusPill
|
||||
status="not_deployed"
|
||||
label={t('status.notDeployed')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const status = deploymentStatus(row)
|
||||
|
||||
if (status === 'deploying') {
|
||||
const targetRelease = row.desiredRelease ?? row.currentRelease
|
||||
const hasTargetRelease = !!(targetRelease?.name || targetRelease?.id)
|
||||
const statusLabel = hasTargetRelease
|
||||
? t('deployTab.status.deployingRelease', { release: releaseLabel(targetRelease) })
|
||||
: t('status.undeploying')
|
||||
|
||||
return <DeploymentStatusPill status="deploying" label={statusLabel} />
|
||||
}
|
||||
|
||||
if (status === 'deploy_failed') {
|
||||
const hasRunningRelease = !!row.currentRelease?.id
|
||||
return (
|
||||
<DeploymentStatusPill
|
||||
status="deploy_failed"
|
||||
label={t(hasRunningRelease ? 'deployTab.status.runningWithFailed' : 'deployTab.status.deployFailed')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'drifted') {
|
||||
const hasRunningRelease = !!row.currentRelease?.id
|
||||
return (
|
||||
<DeploymentStatusPill
|
||||
status="drifted"
|
||||
label={t(hasRunningRelease ? 'deployTab.status.runningOutOfSync' : 'status.drifted')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'invalid') {
|
||||
return (
|
||||
<DeploymentStatusPill
|
||||
status="invalid"
|
||||
label={t('status.invalid')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'unknown') {
|
||||
return (
|
||||
<DeploymentStatusPill
|
||||
status="unknown"
|
||||
label={t('status.unknown')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DeploymentStatusPill
|
||||
status="ready"
|
||||
label={t('status.ready')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,45 +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 { consoleQuery } from '@/service/client'
|
||||
import { deploymentStatusPollingInterval, hasRuntimeInstanceDeployment } from '../../runtime-status'
|
||||
import { openDeployDrawerAtom } from '../../store'
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export function NewDeploymentHeaderAction({ appInstanceId }: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
},
|
||||
refetchInterval: query => deploymentStatusPollingInterval(query.state.data),
|
||||
}))
|
||||
const rows = environmentDeploymentsQuery.data?.data?.filter(hasRuntimeInstanceDeployment) ?? []
|
||||
|
||||
if (environmentDeploymentsQuery.isLoading || environmentDeploymentsQuery.isError || rows.length === 0)
|
||||
return null
|
||||
|
||||
return <NewDeploymentButton appInstanceId={appInstanceId} />
|
||||
}
|
||||
@ -1,269 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps, PropsWithoutRef } from 'react'
|
||||
import type { InstanceDetailTabKey } from './tabs'
|
||||
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useHover, useKeyPress, useLocalStorageState } from 'ahooks'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getAppModeLabel } from '@/app/components/app-sidebar/app-info/app-mode-labels'
|
||||
import NavLink from '@/app/components/app-sidebar/nav-link'
|
||||
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { toAppMode } from '../app-mode'
|
||||
import { TitleTooltip } from '../components/title-tooltip'
|
||||
|
||||
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>
|
||||
<TitleTooltip content={appInstanceId}>
|
||||
<div className="max-w-full truncate font-mono system-2xs-regular text-text-tertiary">
|
||||
{appInstanceId}
|
||||
</div>
|
||||
</TitleTooltip>
|
||||
</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">
|
||||
<TitleTooltip content={instanceName}>
|
||||
<div className="truncate system-md-semibold whitespace-nowrap text-text-secondary">
|
||||
{instanceName}
|
||||
</div>
|
||||
</TitleTooltip>
|
||||
</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 && (
|
||||
<TitleTooltip content={app.description}>
|
||||
<div className="line-clamp-2 system-xs-regular text-text-tertiary">
|
||||
{app.description}
|
||||
</div>
|
||||
</TitleTooltip>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -1,98 +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 { NewDeploymentHeaderAction } from './deploy-tab/new-deployment-button'
|
||||
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 === 'instances' || 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} />
|
||||
: activeTab === 'instances'
|
||||
? <NewDeploymentHeaderAction appInstanceId={appInstanceId} />
|
||||
: <CreateReleaseControl appInstanceId={appInstanceId} size="medium" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MobileDetailTabs appInstanceId={appInstanceId} activeTab={activeTab} />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DeployDrawer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,146 +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, ApiTokenSummarySection, ApiTokenSummarySectionSkeleton } from './overview-tab/access-status-section'
|
||||
import { EnvironmentStrip, EnvironmentStripSkeleton } from './overview-tab/environment-strip'
|
||||
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 LatestReleaseSection({ 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.latestReleaseTitle')}
|
||||
</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">
|
||||
<EnvironmentStripSkeleton />
|
||||
<LatestReleaseSection appInstanceId={appInstanceId}>
|
||||
<ReleaseHeroSkeleton />
|
||||
</LatestReleaseSection>
|
||||
<AccessStatusSectionSkeleton />
|
||||
<ApiTokenSummarySectionSkeleton />
|
||||
</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 releaseCount = releasesQuery.data?.pagination?.totalCount ?? releaseRows.length
|
||||
const runtimeRows = runtimeInstancesQuery.data?.data?.filter(row => row.environment?.id) ?? []
|
||||
const latestRelease = releaseRows[0]
|
||||
const accessChannels = accessChannelsQuery.data?.accessChannels
|
||||
|
||||
return (
|
||||
<OverviewLayout>
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<EnvironmentStrip
|
||||
appInstanceId={appInstanceId}
|
||||
rows={runtimeRows}
|
||||
releaseRows={releaseRows}
|
||||
isLoading={runtimeInstancesQuery.isLoading}
|
||||
isError={runtimeInstancesQuery.isError}
|
||||
/>
|
||||
<LatestReleaseSection appInstanceId={appInstanceId}>
|
||||
<ReleaseHero
|
||||
appInstanceId={appInstanceId}
|
||||
latestRelease={latestRelease}
|
||||
releaseCount={releaseCount}
|
||||
/>
|
||||
</LatestReleaseSection>
|
||||
<AccessStatusSection appInstanceId={appInstanceId} accessChannels={accessChannels} />
|
||||
<ApiTokenSummarySection
|
||||
appInstanceId={appInstanceId}
|
||||
rows={runtimeRows}
|
||||
accessChannels={accessChannels}
|
||||
isEnvironmentLoading={runtimeInstancesQuery.isLoading}
|
||||
isEnvironmentError={runtimeInstancesQuery.isError}
|
||||
/>
|
||||
</div>
|
||||
</OverviewLayout>
|
||||
)
|
||||
}
|
||||
@ -1,295 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AccessChannels, EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQueries } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { environmentId } from '../../environment'
|
||||
import { hasRuntimeInstanceDeployment } from '../../runtime-status'
|
||||
import { SectionState } from '../common'
|
||||
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
|
||||
}
|
||||
|
||||
type ApiTokenSummarySectionProps = {
|
||||
appInstanceId: string
|
||||
rows: EnvironmentDeployment[]
|
||||
accessChannels?: AccessChannels
|
||||
isEnvironmentLoading: boolean
|
||||
isEnvironmentError: boolean
|
||||
}
|
||||
|
||||
type AccessStatusItem = {
|
||||
key: 'webapp' | 'cli'
|
||||
href: string
|
||||
icon: string
|
||||
label: string
|
||||
enabled: boolean
|
||||
meta: string
|
||||
}
|
||||
|
||||
const ACCESS_STATUS_SKELETON_KEYS = ['webapp', 'cli']
|
||||
|
||||
export function AccessStatusSection({ appInstanceId, accessChannels }: 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'),
|
||||
},
|
||||
]
|
||||
|
||||
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 ApiTokenSummarySection({
|
||||
appInstanceId,
|
||||
rows,
|
||||
accessChannels,
|
||||
isEnvironmentLoading,
|
||||
isEnvironmentError,
|
||||
}: ApiTokenSummarySectionProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const runtimeRows = rows.filter(hasRuntimeInstanceDeployment)
|
||||
const apiEnabled = Boolean(accessChannels?.developerApiEnabled)
|
||||
const apiKeyQueries = useQueries({
|
||||
queries: runtimeRows.map(row => consoleQuery.enterprise.accessService.listApiKeys.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
appInstanceId,
|
||||
environmentId: environmentId(row.environment),
|
||||
},
|
||||
},
|
||||
enabled: apiEnabled,
|
||||
})),
|
||||
})
|
||||
const apiKeyCount = apiKeyQueries.reduce((count, query) => count + (query.data?.data?.length ?? 0), 0)
|
||||
const apiKeysLoading = apiKeyQueries.some(query => query.isLoading)
|
||||
const apiKeysError = apiKeyQueries.some(query => query.isError)
|
||||
const isLoading = isEnvironmentLoading || (apiEnabled && apiKeysLoading)
|
||||
const isError = isEnvironmentError || (apiEnabled && apiKeysError)
|
||||
|
||||
return (
|
||||
<section className="flex min-w-0 flex-col gap-3">
|
||||
<h3 className="system-sm-semibold text-text-primary">
|
||||
{t('overview.api')}
|
||||
</h3>
|
||||
|
||||
{isLoading
|
||||
? <ApiTokenSummaryCardSkeleton />
|
||||
: isError
|
||||
? <SectionState>{t('common.loadFailed')}</SectionState>
|
||||
: (
|
||||
<Link
|
||||
href={`/deployments/${appInstanceId}/api-tokens`}
|
||||
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="i-ri-code-s-slash-line size-4" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<span className="flex min-w-0 items-center justify-between gap-3">
|
||||
<span className="truncate system-sm-medium text-text-primary">
|
||||
{t('card.access.api')}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<StatusBadge enabled={apiEnabled} />
|
||||
<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>
|
||||
{apiEnabled
|
||||
? (
|
||||
<span className="flex min-w-0 flex-wrap gap-2">
|
||||
<span className="inline-flex h-6 min-w-0 items-center rounded-md bg-background-section-burn px-2 system-xs-medium text-text-secondary">
|
||||
{t('overview.apiKeysCount', { count: apiKeyCount })}
|
||||
</span>
|
||||
<span className="inline-flex h-6 min-w-0 items-center rounded-md bg-background-section-burn px-2 system-xs-medium text-text-secondary">
|
||||
{t('overview.apiTokenSummary.environments', { count: runtimeRows.length })}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span className="truncate text-xs text-text-tertiary">
|
||||
{t('overview.accessMeta.apiTokens')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ enabled }: {
|
||||
enabled: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
OVERVIEW_STATUS_BADGE_CLASS_NAME,
|
||||
enabled
|
||||
? 'text-util-colors-green-green-700'
|
||||
: 'text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-1.5 shrink-0 rounded-full',
|
||||
enabled ? 'bg-util-colors-green-green-500' : 'bg-text-quaternary',
|
||||
)}
|
||||
/>
|
||||
{enabled ? t('overview.enabled') : t('overview.disabled')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApiTokenSummarySectionSkeleton() {
|
||||
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.api')}
|
||||
</h3>
|
||||
<ApiTokenSummaryCardSkeleton />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ApiTokenSummaryCardSkeleton() {
|
||||
return (
|
||||
<div
|
||||
data-slot="deployment-overview-api-token-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>
|
||||
<span className="flex gap-2">
|
||||
<SkeletonRectangle className="my-0 h-6 w-24 animate-pulse rounded-md" />
|
||||
<SkeletonRectangle className="my-0 h-6 w-32 animate-pulse rounded-md" />
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -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'
|
||||
@ -1,155 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import Link from '@/next/link'
|
||||
import { environmentId } from '../../environment'
|
||||
import { hasRuntimeInstanceDeployment } from '../../runtime-status'
|
||||
import { openDeployDrawerAtom } from '../../store'
|
||||
import { DetailEmptyState, SectionState } from '../common'
|
||||
import { OVERVIEW_CARD_CLASS_NAME } from './card-styles'
|
||||
import { EnvironmentTile } from './environment-tile'
|
||||
|
||||
const OVERVIEW_RUNTIME_INSTANCE_LIMIT = 4
|
||||
|
||||
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)
|
||||
const hasRuntimeRows = runtimeRows.length > 0
|
||||
const hasRelease = releaseRows.length > 0
|
||||
|
||||
return (
|
||||
<section className="flex 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.strip.title')}</h3>
|
||||
{hasRuntimeRows && (
|
||||
<Link
|
||||
href={`/deployments/${appInstanceId}/instances`}
|
||||
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>
|
||||
|
||||
{isLoading
|
||||
? <CardSkeletons />
|
||||
: isError
|
||||
? <SectionState>{t('common.loadFailed')}</SectionState>
|
||||
: !hasRuntimeRows
|
||||
? <EnvironmentEmptyState appInstanceId={appInstanceId} canDeploy={hasRelease} />
|
||||
: (
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,360px),1fr))] gap-3">
|
||||
{previewRows.map(row => (
|
||||
<EnvironmentTile
|
||||
key={environmentId(row.environment)}
|
||||
appInstanceId={appInstanceId}
|
||||
row={row}
|
||||
releaseRows={releaseRows}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EnvironmentEmptyState({ appInstanceId, canDeploy }: {
|
||||
appInstanceId: string
|
||||
canDeploy: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
|
||||
|
||||
return (
|
||||
<DetailEmptyState
|
||||
variant="section"
|
||||
icon="i-ri-server-line"
|
||||
title={t('overview.strip.emptyTitle')}
|
||||
description={canDeploy ? t('overview.strip.emptyDeployableDescription') : t('overview.strip.emptyDescription')}
|
||||
className="min-h-44"
|
||||
action={canDeploy
|
||||
? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="medium"
|
||||
className="gap-1.5"
|
||||
onClick={() => openDeployDrawer({ appInstanceId })}
|
||||
>
|
||||
<span className="i-ri-rocket-line size-4 shrink-0" aria-hidden="true" />
|
||||
{t('overview.strip.deployToNewEnvironment')}
|
||||
</Button>
|
||||
)
|
||||
: undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const SKELETON_KEYS = ['a', 'b', 'c']
|
||||
|
||||
function CardSkeletons() {
|
||||
return (
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,360px),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>
|
||||
)
|
||||
}
|
||||
@ -1,374 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { DeploymentUiStatus } from '../../runtime-status'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { TitleTooltip } from '../../components/title-tooltip'
|
||||
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 showStatusSignal = config.kind !== 'deploying'
|
||||
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 })
|
||||
}
|
||||
|
||||
const actionButton = (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
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>
|
||||
)
|
||||
|
||||
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>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<RuntimeStatusSignal status={status} t={t} />
|
||||
{showStatusSignal && <StatusSignal config={config} drift={drift} t={t} />}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{tooltip
|
||||
? (
|
||||
<TitleTooltip content={tooltip}>
|
||||
<span className="inline-flex max-w-full min-w-0 shrink-0">
|
||||
{actionButton}
|
||||
</span>
|
||||
</TitleTooltip>
|
||||
)
|
||||
: actionButton}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function RuntimeStatusSignal({ status, t }: {
|
||||
status: DeploymentUiStatus
|
||||
t: ReturnType<typeof useTranslation<'deployments'>>['t']
|
||||
}) {
|
||||
const config = runtimeStatusConfig(status)
|
||||
const label = runtimeStatusLabel(status, t)
|
||||
|
||||
return (
|
||||
<TitleTooltip content={label}>
|
||||
<span className={cn(OVERVIEW_STATUS_BADGE_CLASS_NAME, config.statusClass)}>
|
||||
<span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', config.dotClass)} />
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
</TitleTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusSignal({ className, config, drift, t }: {
|
||||
className?: string
|
||||
config: TileConfig
|
||||
drift: ReturnType<typeof computeDrift>
|
||||
t: ReturnType<typeof useTranslation<'deployments'>>['t']
|
||||
}) {
|
||||
const title = renderDriftTitle(config.kind, drift, t)
|
||||
|
||||
return (
|
||||
<TitleTooltip content={title}>
|
||||
<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>
|
||||
</TitleTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function runtimeStatusConfig(status: DeploymentUiStatus): {
|
||||
dotClass: string
|
||||
statusClass: string
|
||||
} {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return {
|
||||
dotClass: 'bg-util-colors-green-green-500',
|
||||
statusClass: 'text-util-colors-green-green-700',
|
||||
}
|
||||
case 'deploying':
|
||||
return {
|
||||
dotClass: 'bg-util-colors-blue-blue-500 animate-pulse',
|
||||
statusClass: 'text-util-colors-blue-blue-700',
|
||||
}
|
||||
case 'deploy_failed':
|
||||
return {
|
||||
dotClass: 'bg-util-colors-red-red-500',
|
||||
statusClass: 'text-util-colors-red-red-700',
|
||||
}
|
||||
case 'drifted':
|
||||
return {
|
||||
dotClass: 'bg-util-colors-warning-warning-500',
|
||||
statusClass: 'text-util-colors-warning-warning-700',
|
||||
}
|
||||
case 'invalid':
|
||||
return {
|
||||
dotClass: 'bg-util-colors-red-red-500',
|
||||
statusClass: 'text-util-colors-red-red-700',
|
||||
}
|
||||
case 'not_deployed':
|
||||
return {
|
||||
dotClass: 'bg-text-quaternary',
|
||||
statusClass: 'text-text-tertiary',
|
||||
}
|
||||
case 'unknown':
|
||||
return {
|
||||
dotClass: 'bg-text-quaternary',
|
||||
statusClass: 'text-text-tertiary',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeStatusLabel(
|
||||
status: DeploymentUiStatus,
|
||||
t: ReturnType<typeof useTranslation<'deployments'>>['t'],
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'ready':
|
||||
return t('status.ready')
|
||||
case 'deploying':
|
||||
return t('status.deploying')
|
||||
case 'deploy_failed':
|
||||
return t('status.deployFailed')
|
||||
case 'drifted':
|
||||
return t('status.drifted')
|
||||
case 'invalid':
|
||||
return t('status.invalid')
|
||||
case 'not_deployed':
|
||||
return t('status.notDeployed')
|
||||
case 'unknown':
|
||||
return t('status.unknown')
|
||||
}
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
function renderDriftTitle(
|
||||
kind: TileKind,
|
||||
drift: ReturnType<typeof computeDrift>,
|
||||
t: ReturnType<typeof useTranslation<'deployments'>>['t'],
|
||||
): string {
|
||||
switch (kind) {
|
||||
case 'latest':
|
||||
return t('overview.chip.latestTooltip')
|
||||
case 'behind':
|
||||
return t('overview.chip.behindTooltip', { count: drift.kind === 'behind' ? drift.steps : 0 })
|
||||
case 'older':
|
||||
return t('overview.chip.olderReleaseTooltip')
|
||||
case 'empty':
|
||||
return t('overview.chip.emptyTooltip')
|
||||
case 'deploying':
|
||||
return t('overview.chip.deployingTooltip')
|
||||
case 'failed':
|
||||
return t('overview.chip.failedTooltip')
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -1,168 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Release } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle } 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 { TitleTooltip } from '../../components/title-tooltip'
|
||||
import { formatDate, releaseCommit, releaseLabel } from '../../release'
|
||||
import { DetailEmptyState } from '../common'
|
||||
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
|
||||
releaseCount: number
|
||||
}
|
||||
|
||||
type ReleaseMetaItemProps = {
|
||||
label?: string
|
||||
showSeparator?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ReleaseHero({ appInstanceId, latestRelease, releaseCount }: ReleaseHeroProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
|
||||
const author = latestRelease?.createdBy?.name ?? ''
|
||||
const ago = latestRelease?.createdAt ? formatTimeFromNow(new Date(latestRelease.createdAt).getTime()) : ''
|
||||
const createdAtTitle = latestRelease?.createdAt ? formatDate(latestRelease.createdAt) : undefined
|
||||
const commit = releaseCommit(latestRelease)
|
||||
|
||||
if (!latestRelease?.id) {
|
||||
return (
|
||||
<DetailEmptyState
|
||||
variant="section"
|
||||
icon="i-ri-stack-line"
|
||||
title={t('overview.hero.empty')}
|
||||
description={t('overview.hero.emptyDescription')}
|
||||
action={<CreateReleaseControl appInstanceId={appInstanceId} size="medium" />}
|
||||
className="min-h-44"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(OVERVIEW_CARD_CLASS_NAME, 'flex min-w-0 flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6')}>
|
||||
<div className="flex min-w-0 items-center 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-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h4 className="truncate system-sm-semibold text-text-primary">
|
||||
{releaseLabel(latestRelease)}
|
||||
</h4>
|
||||
{commit !== '—' && (
|
||||
<TitleTooltip content={t('versions.commitTooltip', { 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>
|
||||
</TitleTooltip>
|
||||
)}
|
||||
</div>
|
||||
<p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 system-xs-regular text-text-tertiary">
|
||||
<ReleaseMetaItem label={t('versions.col.sourceApp')} showSeparator={false}>
|
||||
<LatestReleaseSource release={latestRelease} />
|
||||
</ReleaseMetaItem>
|
||||
{author && (
|
||||
<ReleaseMetaItem>
|
||||
{t('overview.hero.byName', { name: author })}
|
||||
</ReleaseMetaItem>
|
||||
)}
|
||||
{ago && (
|
||||
<ReleaseMetaItem>
|
||||
<TitleTooltip content={createdAtTitle}>
|
||||
<span>
|
||||
{ago}
|
||||
</span>
|
||||
</TitleTooltip>
|
||||
</ReleaseMetaItem>
|
||||
)}
|
||||
<ReleaseMetaItem>
|
||||
{t('overview.latestRelease.releaseCount', { count: releaseCount })}
|
||||
</ReleaseMetaItem>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseMetaItem({ label, showSeparator = true, children }: ReleaseMetaItemProps) {
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
{showSeparator && (
|
||||
<span aria-hidden className="text-text-quaternary">·</span>
|
||||
)}
|
||||
{label && (
|
||||
<span className="shrink-0 text-text-quaternary">{label}</span>
|
||||
)}
|
||||
<span className="min-w-0 truncate">{children}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function LatestReleaseSource({ release }: {
|
||||
release: Release
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const sourceAppId = release.sourceAppId
|
||||
const sourceAppQuery = useQuery(consoleQuery.apps.byAppId.get.queryOptions({
|
||||
input: sourceAppId
|
||||
? { params: { app_id: sourceAppId } }
|
||||
: skipToken,
|
||||
}))
|
||||
|
||||
if (!sourceAppId) {
|
||||
return (
|
||||
<span>
|
||||
{release.source === 'RELEASE_SOURCE_UPLOAD' ? t('versions.manualDslOption') : '—'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const sourceAppName = sourceAppQuery.data?.name
|
||||
const label = sourceAppName || sourceAppId
|
||||
const title = sourceAppName ? `${sourceAppName} (${sourceAppId})` : sourceAppId
|
||||
|
||||
return (
|
||||
<TitleTooltip content={title}>
|
||||
<Link
|
||||
href={`/app/${encodeURIComponent(sourceAppId)}/workflow`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex max-w-full min-w-0 items-center gap-1 text-text-secondary transition-colors hover:text-text-accent"
|
||||
>
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
<span className="i-ri-arrow-right-up-line size-3.5 shrink-0" aria-hidden="true" />
|
||||
</Link>
|
||||
</TitleTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReleaseHeroSkeleton() {
|
||||
return (
|
||||
<div
|
||||
data-slot="deployment-overview-release-hero-skeleton"
|
||||
className={cn(OVERVIEW_CARD_CLASS_NAME, '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-4 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -1,129 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { App } from '@/types/app'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBackdrop,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerPopup,
|
||||
DrawerPortal,
|
||||
DrawerTitle,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import TemplateWorkflowEn from '@/app/components/develop/template/template_workflow.en.mdx'
|
||||
import TemplateWorkflowJa from '@/app/components/develop/template/template_workflow.ja.mdx'
|
||||
import TemplateWorkflowZh from '@/app/components/develop/template/template_workflow.zh.mdx'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { getDocLanguage } from '@/i18n-config/language'
|
||||
import { AppModeEnum, Theme } from '@/types/app'
|
||||
|
||||
type PromptVariable = { key: string, name: string }
|
||||
|
||||
type WorkflowDocTemplateProps = {
|
||||
appDetail: App
|
||||
variables: PromptVariable[]
|
||||
inputs: Record<string, string>
|
||||
}
|
||||
|
||||
const EMPTY_VARIABLES: PromptVariable[] = []
|
||||
const EMPTY_INPUTS: Record<string, string> = {}
|
||||
|
||||
function WorkflowDocTemplate({ docLanguage, appDetail, variables, inputs }: WorkflowDocTemplateProps & {
|
||||
docLanguage: string
|
||||
}) {
|
||||
if (docLanguage === 'zh') {
|
||||
return (
|
||||
<TemplateWorkflowZh
|
||||
appDetail={appDetail}
|
||||
variables={variables}
|
||||
inputs={inputs}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (docLanguage === 'ja') {
|
||||
return (
|
||||
<TemplateWorkflowJa
|
||||
appDetail={appDetail}
|
||||
variables={variables}
|
||||
inputs={inputs}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TemplateWorkflowEn
|
||||
appDetail={appDetail}
|
||||
variables={variables}
|
||||
inputs={inputs}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeveloperApiDocsDrawer({
|
||||
open,
|
||||
appInstanceId,
|
||||
apiBaseUrl,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
appInstanceId: string
|
||||
apiBaseUrl: string
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const locale = useLocale()
|
||||
const { theme } = useTheme()
|
||||
const docLanguage = getDocLanguage(locale)
|
||||
const appDetail = {
|
||||
id: appInstanceId,
|
||||
mode: AppModeEnum.WORKFLOW,
|
||||
api_base_url: apiBaseUrl,
|
||||
} as App
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
modal
|
||||
swipeDirection="right"
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DrawerPortal>
|
||||
<DrawerBackdrop />
|
||||
<DrawerViewport>
|
||||
<DrawerPopup className="data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-[840px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-[0.5px]">
|
||||
<DrawerCloseButton
|
||||
aria-label={t('access.api.docsClose')}
|
||||
className="absolute top-4 right-5 size-6 rounded-md"
|
||||
/>
|
||||
<DrawerContent className="flex min-h-0 flex-1 flex-col bg-components-panel-bg p-0 pb-0">
|
||||
<div className="shrink-0 border-b border-divider-subtle px-6 py-5 pr-14">
|
||||
<DrawerTitle className="title-xl-semi-bold text-text-primary">
|
||||
{t('access.api.docsTitle')}
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{t('access.api.docsDescription')}
|
||||
</DrawerDescription>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-6 py-5">
|
||||
<article className={cn('prose max-w-none', theme === Theme.dark && 'prose-invert')}>
|
||||
<WorkflowDocTemplate
|
||||
docLanguage={docLanguage}
|
||||
appDetail={appDetail}
|
||||
variables={EMPTY_VARIABLES}
|
||||
inputs={EMPTY_INPUTS}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</DrawerPopup>
|
||||
</DrawerViewport>
|
||||
</DrawerPortal>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
@ -1,516 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
ApiKey,
|
||||
Environment,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import type { ButtonProps } from '@langgenius/dify-ui/button'
|
||||
import type { FormEvent } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation } 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 [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 handleRevoke() {
|
||||
if (!apiKey.id || isRevoking)
|
||||
return
|
||||
|
||||
revokeApiKey.mutate(
|
||||
{
|
||||
params: {
|
||||
apiKeyId: apiKey.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
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()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('access.api.createFailed'))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user