mirror of
https://github.com/langgenius/dify.git
synced 2026-05-19 08:17:14 +08:00
Compare commits
2 Commits
4-27-app-d
...
copilot/qu
| Author | SHA1 | Date | |
|---|---|---|---|
| 59e96fbb2a | |||
| 06ea0f7ac2 |
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: how-to-write-component
|
||||
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around abstraction choices, props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
|
||||
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
|
||||
---
|
||||
|
||||
# How To Write A Component
|
||||
@ -12,7 +12,6 @@ Use this as the decision guide for React/TypeScript component structure. Existin
|
||||
- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
|
||||
- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
|
||||
- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature.
|
||||
- Prefer local code and purpose-named helpers over catch-all utility modules; inline cheap derived values when that is clearer.
|
||||
- Use Tailwind CSS v4.1+ rules via the `tailwind-css-rules` skill. Prefer v4 utilities, `gap`, `text-size/line-height`, `min-h-dvh`, and avoid deprecated utilities and `@apply`.
|
||||
|
||||
## Ownership
|
||||
@ -20,8 +19,6 @@ Use this as the decision guide for React/TypeScript component structure. Existin
|
||||
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
|
||||
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing.
|
||||
- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children.
|
||||
- Pass stable domain identity across boundaries; avoid forwarding derived presentation state when the receiver can derive it from its own data source. A component that owns a visual surface should also own the data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
|
||||
- Loading states for visual surfaces should use skeleton placeholders scoped to the content that is actually loading, with shape, density, and dimensions close to the final UI. Avoid generic loading text or centered spinners for page sections, cards, lists, tables, forms, and drawers; reserve spinners for small inline busy indicators such as an in-progress status icon.
|
||||
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow.
|
||||
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
|
||||
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
|
||||
@ -32,9 +29,9 @@ Use this as the decision guide for React/TypeScript component structure. Existin
|
||||
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
|
||||
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
|
||||
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
|
||||
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
|
||||
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially persistent IDs and route params. Normalize framework or route params at the boundary.
|
||||
- Keep fallback and invariant checks at the lowest component that already handles that state; avoid defensive fallbacks that mask impossible states.
|
||||
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them.
|
||||
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially IDs like `appInstanceId`. Normalize framework or route params at the boundary.
|
||||
- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks.
|
||||
|
||||
## Queries And Mutations
|
||||
|
||||
@ -51,13 +48,12 @@ Use this as the decision guide for React/TypeScript component structure. Existin
|
||||
## Component Boundaries
|
||||
|
||||
- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner.
|
||||
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Do not expand a child component's responsibility just because its data is useful nearby; keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
|
||||
- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer.
|
||||
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
|
||||
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
|
||||
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
|
||||
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
|
||||
- Avoid shallow wrappers, layout-only render-prop wrappers, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
|
||||
- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
|
||||
|
||||
## You Might Not Need An Effect
|
||||
|
||||
|
||||
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:
|
||||
- "*"
|
||||
|
||||
|
||||
@ -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,129 +0,0 @@
|
||||
"""Inner API endpoints for runtime credential resolution.
|
||||
|
||||
Called by Enterprise while resolving AppRunner runtime artifacts. The endpoint
|
||||
returns decrypted model credentials for in-memory runtime use only.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from json import JSONDecodeError
|
||||
from typing import Any
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import register_schema_model
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import enterprise_inner_api_only
|
||||
from core.helper import encrypter
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
|
||||
from extensions.ext_database import db
|
||||
from models.provider import ProviderCredential
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InnerRuntimeModelCredentialResolveItem(BaseModel):
|
||||
credential_id: str = Field(description="Provider credential id")
|
||||
provider: str = Field(description="Runtime provider identifier, for example langgenius/openai/openai")
|
||||
vendor: str | None = Field(default=None, description="Model vendor, for example openai")
|
||||
plugin_unique_identifier: str | None = Field(default=None, description="Runtime plugin identifier")
|
||||
|
||||
|
||||
class InnerRuntimeModelCredentialsResolvePayload(BaseModel):
|
||||
tenant_id: str = Field(description="Workspace id")
|
||||
credentials: list[InnerRuntimeModelCredentialResolveItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
register_schema_model(inner_api_ns, InnerRuntimeModelCredentialsResolvePayload)
|
||||
|
||||
|
||||
@inner_api_ns.route("/enterprise/runtime/model-credentials:resolve")
|
||||
class EnterpriseRuntimeModelCredentialsResolve(Resource):
|
||||
@setup_required
|
||||
@enterprise_inner_api_only
|
||||
@inner_api_ns.doc(
|
||||
"enterprise_runtime_model_credentials_resolve",
|
||||
responses={
|
||||
200: "Credentials resolved",
|
||||
400: "Invalid request or credential config",
|
||||
404: "Provider or credential not found",
|
||||
},
|
||||
)
|
||||
@inner_api_ns.expect(inner_api_ns.models[InnerRuntimeModelCredentialsResolvePayload.__name__])
|
||||
def post(self):
|
||||
args = InnerRuntimeModelCredentialsResolvePayload.model_validate(inner_api_ns.payload or {})
|
||||
if not args.credentials:
|
||||
return {"model_credentials": []}, 200
|
||||
|
||||
provider_manager = create_plugin_provider_manager(tenant_id=args.tenant_id)
|
||||
provider_configurations = provider_manager.get_configurations(args.tenant_id)
|
||||
|
||||
resolved: list[dict[str, Any]] = []
|
||||
for item in args.credentials:
|
||||
provider_configuration = provider_configurations.get(item.provider)
|
||||
if provider_configuration is None:
|
||||
return {"message": f"provider '{item.provider}' not found"}, 404
|
||||
|
||||
provider_schema = provider_configuration.provider.provider_credential_schema
|
||||
secret_variables = provider_configuration.extract_secret_variables(
|
||||
provider_schema.credential_form_schemas if provider_schema else []
|
||||
)
|
||||
|
||||
with Session(db.engine) as session:
|
||||
stmt = select(ProviderCredential).where(
|
||||
ProviderCredential.id == item.credential_id,
|
||||
ProviderCredential.tenant_id == args.tenant_id,
|
||||
ProviderCredential.provider_name.in_(provider_configuration._get_provider_names()),
|
||||
)
|
||||
credential = session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
if credential is None or not credential.encrypted_config:
|
||||
return {"message": f"credential '{item.credential_id}' not found"}, 404
|
||||
|
||||
try:
|
||||
values = json.loads(credential.encrypted_config)
|
||||
except JSONDecodeError:
|
||||
return {"message": f"credential '{item.credential_id}' has invalid config"}, 400
|
||||
if not isinstance(values, dict):
|
||||
return {"message": f"credential '{item.credential_id}' has invalid config"}, 400
|
||||
|
||||
for key in secret_variables:
|
||||
value = values.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
try:
|
||||
values[key] = encrypter.decrypt_token(tenant_id=args.tenant_id, token=value)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"failed to resolve runtime model credential",
|
||||
extra={
|
||||
"credential_id": item.credential_id,
|
||||
"provider": item.provider,
|
||||
"tenant_id": args.tenant_id,
|
||||
"error": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
return {"message": f"credential '{item.credential_id}' decrypt failed"}, 400
|
||||
|
||||
resolved.append(
|
||||
{
|
||||
"credential_id": item.credential_id,
|
||||
"provider": item.provider,
|
||||
"vendor": item.vendor or _vendor_from_provider(item.provider),
|
||||
"plugin_unique_identifier": item.plugin_unique_identifier,
|
||||
"values": values,
|
||||
}
|
||||
)
|
||||
|
||||
return {"model_credentials": resolved}, 200
|
||||
|
||||
|
||||
def _vendor_from_provider(provider: str) -> str:
|
||||
provider = provider.strip("/")
|
||||
if not provider:
|
||||
return ""
|
||||
return provider.rsplit("/", 1)[-1]
|
||||
@ -159,7 +159,6 @@ class PluginManagerModel(BaseModel):
|
||||
|
||||
class SystemFeatureModel(BaseModel):
|
||||
app_dsl_version: str = ""
|
||||
enable_app_deploy: bool = False
|
||||
sso_enforced_for_signin: bool = False
|
||||
sso_enforced_for_signin_protocol: str = ""
|
||||
enable_marketplace: bool = False
|
||||
@ -234,7 +233,6 @@ class FeatureService:
|
||||
cls._fulfill_system_params_from_env(system_features)
|
||||
|
||||
if dify_config.ENTERPRISE_ENABLED:
|
||||
system_features.enable_app_deploy = True
|
||||
system_features.branding.enabled = True
|
||||
system_features.webapp_auth.enabled = True
|
||||
system_features.enable_change_email = False
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,105 +0,0 @@
|
||||
"""Unit tests for runtime credential inner API."""
|
||||
|
||||
import inspect
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from controllers.inner_api.runtime_credentials import (
|
||||
EnterpriseRuntimeModelCredentialsResolve,
|
||||
InnerRuntimeModelCredentialsResolvePayload,
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_model_credentials_payload_accepts_items():
|
||||
payload = InnerRuntimeModelCredentialsResolvePayload.model_validate(
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"credentials": [
|
||||
{
|
||||
"credential_id": "credential-1",
|
||||
"provider": "langgenius/openai/openai",
|
||||
"vendor": "openai",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert payload.tenant_id == "tenant-1"
|
||||
assert payload.credentials[0].provider == "langgenius/openai/openai"
|
||||
|
||||
|
||||
@patch("controllers.inner_api.runtime_credentials.encrypter.decrypt_token")
|
||||
@patch("controllers.inner_api.runtime_credentials.db")
|
||||
@patch("controllers.inner_api.runtime_credentials.Session")
|
||||
@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager")
|
||||
def test_runtime_model_credentials_resolve_returns_decrypted_values(
|
||||
mock_provider_manager_factory,
|
||||
mock_session_cls,
|
||||
mock_db,
|
||||
mock_decrypt_token,
|
||||
app: Flask,
|
||||
):
|
||||
provider_configuration = MagicMock()
|
||||
provider_configuration.provider.provider_credential_schema.credential_form_schemas = []
|
||||
provider_configuration.extract_secret_variables.return_value = ["openai_api_key"]
|
||||
provider_configuration._get_provider_names.return_value = ["langgenius/openai/openai", "openai"]
|
||||
|
||||
provider_configurations = MagicMock()
|
||||
provider_configurations.get.return_value = provider_configuration
|
||||
provider_manager = MagicMock()
|
||||
provider_manager.get_configurations.return_value = provider_configurations
|
||||
mock_provider_manager_factory.return_value = provider_manager
|
||||
|
||||
credential = MagicMock()
|
||||
credential.encrypted_config = '{"openai_api_key":"encrypted","api_base":"https://api.openai.com/v1"}'
|
||||
session = MagicMock()
|
||||
session.__enter__.return_value = session
|
||||
session.__exit__.return_value = False
|
||||
session.execute.return_value.scalar_one_or_none.return_value = credential
|
||||
mock_session_cls.return_value = session
|
||||
mock_db.engine = MagicMock()
|
||||
mock_decrypt_token.return_value = "sk-test"
|
||||
|
||||
handler = EnterpriseRuntimeModelCredentialsResolve()
|
||||
unwrapped = inspect.unwrap(handler.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"credentials": [
|
||||
{
|
||||
"credential_id": "credential-1",
|
||||
"provider": "langgenius/openai/openai",
|
||||
"vendor": "openai",
|
||||
}
|
||||
],
|
||||
}
|
||||
body, status_code = unwrapped(handler)
|
||||
|
||||
assert status_code == 200
|
||||
assert body["model_credentials"][0]["values"]["openai_api_key"] == "sk-test"
|
||||
assert body["model_credentials"][0]["values"]["api_base"] == "https://api.openai.com/v1"
|
||||
mock_decrypt_token.assert_called_once_with(tenant_id="tenant-1", token="encrypted")
|
||||
|
||||
|
||||
@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager")
|
||||
def test_runtime_model_credentials_resolve_rejects_unknown_provider(mock_provider_manager_factory, app: Flask):
|
||||
provider_configurations = MagicMock()
|
||||
provider_configurations.get.return_value = None
|
||||
provider_manager = MagicMock()
|
||||
provider_manager.get_configurations.return_value = provider_configurations
|
||||
mock_provider_manager_factory.return_value = provider_manager
|
||||
|
||||
handler = EnterpriseRuntimeModelCredentialsResolve()
|
||||
unwrapped = inspect.unwrap(handler.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = {
|
||||
"tenant_id": "tenant-1",
|
||||
"credentials": [{"credential_id": "credential-1", "provider": "missing"}],
|
||||
}
|
||||
body, status_code = unwrapped(handler)
|
||||
|
||||
assert status_code == 404
|
||||
assert "provider" in body["message"]
|
||||
@ -34,8 +34,16 @@ if [[ -f "$EXCLUDES_FILE" ]]; then
|
||||
fi
|
||||
|
||||
tmp_output="$(mktemp)"
|
||||
pyrefly_command=(
|
||||
uv run --directory api --dev pyrefly check
|
||||
"${pyrefly_args[@]}"
|
||||
)
|
||||
if (( ${#target_paths[@]} > 0 )); then
|
||||
pyrefly_command+=("${target_paths[@]}")
|
||||
fi
|
||||
|
||||
set +e
|
||||
uv run --directory api --dev pyrefly check "${pyrefly_args[@]}" "${target_paths[@]}" >"$tmp_output" 2>&1
|
||||
"${pyrefly_command[@]}" >"$tmp_output" 2>&1
|
||||
pyrefly_status=$?
|
||||
set -e
|
||||
|
||||
|
||||
@ -4758,6 +4758,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/types/feature.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/types/lamejs.d.ts": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
|
||||
@ -8,14 +8,14 @@
|
||||
|
||||
Snapshot generated from `packages/contracts/generated/api/readiness.json` after running `pnpm -C packages/contracts gen-api-contract-from-openapi`.
|
||||
|
||||
Are we OpenAPI ready? **No.** Current generated API contracts are **16.7% ready**.
|
||||
Are we OpenAPI ready? **No.** Current generated API contracts are **16.6% ready**.
|
||||
|
||||
| Surface | Ready | Not ready | Total | Ready % |
|
||||
| --------- | ------: | --------: | ------: | --------: |
|
||||
| console | 96 | 474 | 570 | 16.8% |
|
||||
| console | 95 | 475 | 570 | 16.7% |
|
||||
| service | 16 | 72 | 88 | 18.2% |
|
||||
| web | 5 | 36 | 41 | 12.2% |
|
||||
| **total** | **117** | **582** | **699** | **16.7%** |
|
||||
| **total** | **116** | **583** | **699** | **16.6%** |
|
||||
|
||||
Readiness here means the generated contract operation is not marked with:
|
||||
|
||||
|
||||
@ -426,10 +426,16 @@ export const imports = {
|
||||
|
||||
/**
|
||||
* Get workflow online users
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const post3 = oc
|
||||
.route({
|
||||
description: 'Get workflow online users',
|
||||
deprecated: true,
|
||||
description:
|
||||
'Get workflow online users\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAppsWorkflowsOnlineUsers',
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"surfaces": {
|
||||
"console": {
|
||||
"notReady": 474,
|
||||
"notReady": 475,
|
||||
"total": 570
|
||||
},
|
||||
"service": {
|
||||
|
||||
@ -4,66 +4,6 @@ import { oc } from '@orpc/contract'
|
||||
import * as z from 'zod'
|
||||
|
||||
import {
|
||||
zAccessSubjectServiceListAccessSubjectsQuery,
|
||||
zAccessSubjectServiceListAccessSubjectsResponse,
|
||||
zAppDeployAccessServiceCreateDeveloperApiKeyBody,
|
||||
zAppDeployAccessServiceCreateDeveloperApiKeyPath,
|
||||
zAppDeployAccessServiceCreateDeveloperApiKeyResponse,
|
||||
zAppDeployAccessServiceDeleteDeveloperApiKeyPath,
|
||||
zAppDeployAccessServiceDeleteDeveloperApiKeyResponse,
|
||||
zAppDeployAccessServiceGetAppInstanceAccessPath,
|
||||
zAppDeployAccessServiceGetAppInstanceAccessResponse,
|
||||
zAppDeployAccessServiceRevealDeveloperApiKeyBody,
|
||||
zAppDeployAccessServiceRevealDeveloperApiKeyPath,
|
||||
zAppDeployAccessServiceRevealDeveloperApiKeyResponse,
|
||||
zAppDeployAccessServiceUpdateAccessChannelsBody,
|
||||
zAppDeployAccessServiceUpdateAccessChannelsPath,
|
||||
zAppDeployAccessServiceUpdateAccessChannelsResponse,
|
||||
zAppDeployAccessServiceUpdateDeveloperApiBody,
|
||||
zAppDeployAccessServiceUpdateDeveloperApiPath,
|
||||
zAppDeployAccessServiceUpdateDeveloperApiResponse,
|
||||
zAppDeployAccessServiceUpdateEnvironmentAccessPolicyBody,
|
||||
zAppDeployAccessServiceUpdateEnvironmentAccessPolicyPath,
|
||||
zAppDeployAccessServiceUpdateEnvironmentAccessPolicyResponse,
|
||||
zAppDeploymentServiceCancelDeploymentBody,
|
||||
zAppDeploymentServiceCancelDeploymentPath,
|
||||
zAppDeploymentServiceCancelDeploymentResponse,
|
||||
zAppDeploymentServiceCreateDeploymentBody,
|
||||
zAppDeploymentServiceCreateDeploymentPath,
|
||||
zAppDeploymentServiceCreateDeploymentResponse,
|
||||
zAppDeploymentServiceGetDeploymentPlanPath,
|
||||
zAppDeploymentServiceGetDeploymentPlanResponse,
|
||||
zAppDeploymentServiceListEnvironmentDeploymentsPath,
|
||||
zAppDeploymentServiceListEnvironmentDeploymentsResponse,
|
||||
zAppDeploymentServiceUndeployRuntimeInstanceBody,
|
||||
zAppDeploymentServiceUndeployRuntimeInstancePath,
|
||||
zAppDeploymentServiceUndeployRuntimeInstanceResponse,
|
||||
zAppInstanceServiceCreateAppInstanceBody,
|
||||
zAppInstanceServiceCreateAppInstanceResponse,
|
||||
zAppInstanceServiceDeleteAppInstancePath,
|
||||
zAppInstanceServiceDeleteAppInstanceResponse,
|
||||
zAppInstanceServiceGetAppInstanceOverviewPath,
|
||||
zAppInstanceServiceGetAppInstanceOverviewResponse,
|
||||
zAppInstanceServiceGetAppInstancePath,
|
||||
zAppInstanceServiceGetAppInstanceResponse,
|
||||
zAppInstanceServiceGetAppInstanceSettingsPath,
|
||||
zAppInstanceServiceGetAppInstanceSettingsResponse,
|
||||
zAppInstanceServiceImportAppInstanceBody,
|
||||
zAppInstanceServiceImportAppInstanceResponse,
|
||||
zAppInstanceServiceListAppInstancesQuery,
|
||||
zAppInstanceServiceListAppInstancesResponse,
|
||||
zAppInstanceServiceUpdateAppInstanceBody,
|
||||
zAppInstanceServiceUpdateAppInstancePath,
|
||||
zAppInstanceServiceUpdateAppInstanceResponse,
|
||||
zAppReleaseServiceCreateReleaseBody,
|
||||
zAppReleaseServiceCreateReleasePath,
|
||||
zAppReleaseServiceCreateReleaseResponse,
|
||||
zAppReleaseServiceListReleasesPath,
|
||||
zAppReleaseServiceListReleasesQuery,
|
||||
zAppReleaseServiceListReleasesResponse,
|
||||
zAppReleaseServicePreviewReleaseBody,
|
||||
zAppReleaseServicePreviewReleasePath,
|
||||
zAppReleaseServicePreviewReleaseResponse,
|
||||
zConsoleSsoOAuth2LoginResponse,
|
||||
zConsoleSsoOidcLoginResponse,
|
||||
zConsoleSsoSamlLoginResponse,
|
||||
@ -81,369 +21,6 @@ import {
|
||||
zWebAppAuthUpdateWebAppWhitelistSubjectsResponse,
|
||||
} from './zod.gen'
|
||||
|
||||
export const listAccessSubjects = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AccessSubjectService_ListAccessSubjects',
|
||||
path: '/enterprise/access-subjects',
|
||||
tags: ['AccessSubjectService'],
|
||||
})
|
||||
.input(z.object({ query: zAccessSubjectServiceListAccessSubjectsQuery.optional() }))
|
||||
.output(zAccessSubjectServiceListAccessSubjectsResponse)
|
||||
|
||||
export const accessSubjectService = {
|
||||
listAccessSubjects,
|
||||
}
|
||||
|
||||
export const listAppInstances = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AppInstanceService_ListAppInstances',
|
||||
path: '/enterprise/app-instances',
|
||||
tags: ['AppInstanceService'],
|
||||
})
|
||||
.input(z.object({ query: zAppInstanceServiceListAppInstancesQuery.optional() }))
|
||||
.output(zAppInstanceServiceListAppInstancesResponse)
|
||||
|
||||
export const createAppInstance = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'AppInstanceService_CreateAppInstance',
|
||||
path: '/enterprise/app-instances',
|
||||
tags: ['AppInstanceService'],
|
||||
})
|
||||
.input(z.object({ body: zAppInstanceServiceCreateAppInstanceBody }))
|
||||
.output(zAppInstanceServiceCreateAppInstanceResponse)
|
||||
|
||||
export const importAppInstance = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'AppInstanceService_ImportAppInstance',
|
||||
path: '/enterprise/app-instances/import',
|
||||
tags: ['AppInstanceService'],
|
||||
})
|
||||
.input(z.object({ body: zAppInstanceServiceImportAppInstanceBody }))
|
||||
.output(zAppInstanceServiceImportAppInstanceResponse)
|
||||
|
||||
export const deleteAppInstance = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'DELETE',
|
||||
operationId: 'AppInstanceService_DeleteAppInstance',
|
||||
path: '/enterprise/app-instances/{appInstanceId}',
|
||||
tags: ['AppInstanceService'],
|
||||
})
|
||||
.input(z.object({ params: zAppInstanceServiceDeleteAppInstancePath }))
|
||||
.output(zAppInstanceServiceDeleteAppInstanceResponse)
|
||||
|
||||
export const getAppInstance = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AppInstanceService_GetAppInstance',
|
||||
path: '/enterprise/app-instances/{appInstanceId}',
|
||||
tags: ['AppInstanceService'],
|
||||
})
|
||||
.input(z.object({ params: zAppInstanceServiceGetAppInstancePath }))
|
||||
.output(zAppInstanceServiceGetAppInstanceResponse)
|
||||
|
||||
export const updateAppInstance = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'PATCH',
|
||||
operationId: 'AppInstanceService_UpdateAppInstance',
|
||||
path: '/enterprise/app-instances/{appInstanceId}',
|
||||
tags: ['AppInstanceService'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zAppInstanceServiceUpdateAppInstanceBody,
|
||||
params: zAppInstanceServiceUpdateAppInstancePath,
|
||||
}),
|
||||
)
|
||||
.output(zAppInstanceServiceUpdateAppInstanceResponse)
|
||||
|
||||
export const getAppInstanceOverview = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AppInstanceService_GetAppInstanceOverview',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/overview',
|
||||
tags: ['AppInstanceService'],
|
||||
})
|
||||
.input(z.object({ params: zAppInstanceServiceGetAppInstanceOverviewPath }))
|
||||
.output(zAppInstanceServiceGetAppInstanceOverviewResponse)
|
||||
|
||||
export const getAppInstanceSettings = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AppInstanceService_GetAppInstanceSettings',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/settings',
|
||||
tags: ['AppInstanceService'],
|
||||
})
|
||||
.input(z.object({ params: zAppInstanceServiceGetAppInstanceSettingsPath }))
|
||||
.output(zAppInstanceServiceGetAppInstanceSettingsResponse)
|
||||
|
||||
export const appInstanceService = {
|
||||
listAppInstances,
|
||||
createAppInstance,
|
||||
importAppInstance,
|
||||
deleteAppInstance,
|
||||
getAppInstance,
|
||||
updateAppInstance,
|
||||
getAppInstanceOverview,
|
||||
getAppInstanceSettings,
|
||||
}
|
||||
|
||||
export const getAppInstanceAccess = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AppDeployAccessService_GetAppInstanceAccess',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/access',
|
||||
tags: ['AppDeployAccessService'],
|
||||
})
|
||||
.input(z.object({ params: zAppDeployAccessServiceGetAppInstanceAccessPath }))
|
||||
.output(zAppDeployAccessServiceGetAppInstanceAccessResponse)
|
||||
|
||||
export const updateAccessChannels = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'PATCH',
|
||||
operationId: 'AppDeployAccessService_UpdateAccessChannels',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/access-channels',
|
||||
tags: ['AppDeployAccessService'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zAppDeployAccessServiceUpdateAccessChannelsBody,
|
||||
params: zAppDeployAccessServiceUpdateAccessChannelsPath,
|
||||
}),
|
||||
)
|
||||
.output(zAppDeployAccessServiceUpdateAccessChannelsResponse)
|
||||
|
||||
export const updateDeveloperApi = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'PATCH',
|
||||
operationId: 'AppDeployAccessService_UpdateDeveloperApi',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/developer-api',
|
||||
tags: ['AppDeployAccessService'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zAppDeployAccessServiceUpdateDeveloperApiBody,
|
||||
params: zAppDeployAccessServiceUpdateDeveloperApiPath,
|
||||
}),
|
||||
)
|
||||
.output(zAppDeployAccessServiceUpdateDeveloperApiResponse)
|
||||
|
||||
export const updateEnvironmentAccessPolicy = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'PUT',
|
||||
operationId: 'AppDeployAccessService_UpdateEnvironmentAccessPolicy',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/access-policy',
|
||||
tags: ['AppDeployAccessService'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zAppDeployAccessServiceUpdateEnvironmentAccessPolicyBody,
|
||||
params: zAppDeployAccessServiceUpdateEnvironmentAccessPolicyPath,
|
||||
}),
|
||||
)
|
||||
.output(zAppDeployAccessServiceUpdateEnvironmentAccessPolicyResponse)
|
||||
|
||||
export const createDeveloperApiKey = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'AppDeployAccessService_CreateDeveloperApiKey',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/api-keys',
|
||||
tags: ['AppDeployAccessService'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zAppDeployAccessServiceCreateDeveloperApiKeyBody,
|
||||
params: zAppDeployAccessServiceCreateDeveloperApiKeyPath,
|
||||
}),
|
||||
)
|
||||
.output(zAppDeployAccessServiceCreateDeveloperApiKeyResponse)
|
||||
|
||||
export const deleteDeveloperApiKey = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'DELETE',
|
||||
operationId: 'AppDeployAccessService_DeleteDeveloperApiKey',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/api-keys/{apiKeyId}',
|
||||
tags: ['AppDeployAccessService'],
|
||||
})
|
||||
.input(z.object({ params: zAppDeployAccessServiceDeleteDeveloperApiKeyPath }))
|
||||
.output(zAppDeployAccessServiceDeleteDeveloperApiKeyResponse)
|
||||
|
||||
export const revealDeveloperApiKey = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'AppDeployAccessService_RevealDeveloperApiKey',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/api-keys/{apiKeyId}/reveal',
|
||||
tags: ['AppDeployAccessService'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zAppDeployAccessServiceRevealDeveloperApiKeyBody,
|
||||
params: zAppDeployAccessServiceRevealDeveloperApiKeyPath,
|
||||
}),
|
||||
)
|
||||
.output(zAppDeployAccessServiceRevealDeveloperApiKeyResponse)
|
||||
|
||||
export const appDeployAccessService = {
|
||||
getAppInstanceAccess,
|
||||
updateAccessChannels,
|
||||
updateDeveloperApi,
|
||||
updateEnvironmentAccessPolicy,
|
||||
createDeveloperApiKey,
|
||||
deleteDeveloperApiKey,
|
||||
revealDeveloperApiKey,
|
||||
}
|
||||
|
||||
export const listEnvironmentDeployments = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AppDeploymentService_ListEnvironmentDeployments',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/environment-deployments',
|
||||
tags: ['AppDeploymentService'],
|
||||
})
|
||||
.input(z.object({ params: zAppDeploymentServiceListEnvironmentDeploymentsPath }))
|
||||
.output(zAppDeploymentServiceListEnvironmentDeploymentsResponse)
|
||||
|
||||
export const createDeployment = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'AppDeploymentService_CreateDeployment',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/deployments',
|
||||
tags: ['AppDeploymentService'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zAppDeploymentServiceCreateDeploymentBody,
|
||||
params: zAppDeploymentServiceCreateDeploymentPath,
|
||||
}),
|
||||
)
|
||||
.output(zAppDeploymentServiceCreateDeploymentResponse)
|
||||
|
||||
export const cancelDeployment = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'AppDeploymentService_CancelDeployment',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/deployments/{deploymentId}/cancel',
|
||||
tags: ['AppDeploymentService'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zAppDeploymentServiceCancelDeploymentBody,
|
||||
params: zAppDeploymentServiceCancelDeploymentPath,
|
||||
}),
|
||||
)
|
||||
.output(zAppDeploymentServiceCancelDeploymentResponse)
|
||||
|
||||
export const undeployRuntimeInstance = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'AppDeploymentService_UndeployRuntimeInstance',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/environments/{environmentId}/undeploy',
|
||||
tags: ['AppDeploymentService'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zAppDeploymentServiceUndeployRuntimeInstanceBody,
|
||||
params: zAppDeploymentServiceUndeployRuntimeInstancePath,
|
||||
}),
|
||||
)
|
||||
.output(zAppDeploymentServiceUndeployRuntimeInstanceResponse)
|
||||
|
||||
export const getDeploymentPlan = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AppDeploymentService_GetDeploymentPlan',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/releases/{releaseId}/deployment-plan',
|
||||
tags: ['AppDeploymentService'],
|
||||
})
|
||||
.input(z.object({ params: zAppDeploymentServiceGetDeploymentPlanPath }))
|
||||
.output(zAppDeploymentServiceGetDeploymentPlanResponse)
|
||||
|
||||
export const appDeploymentService = {
|
||||
listEnvironmentDeployments,
|
||||
createDeployment,
|
||||
cancelDeployment,
|
||||
undeployRuntimeInstance,
|
||||
getDeploymentPlan,
|
||||
}
|
||||
|
||||
export const listReleases = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'AppReleaseService_ListReleases',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/releases',
|
||||
tags: ['AppReleaseService'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
params: zAppReleaseServiceListReleasesPath,
|
||||
query: zAppReleaseServiceListReleasesQuery.optional(),
|
||||
}),
|
||||
)
|
||||
.output(zAppReleaseServiceListReleasesResponse)
|
||||
|
||||
export const createRelease = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'AppReleaseService_CreateRelease',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/releases',
|
||||
tags: ['AppReleaseService'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zAppReleaseServiceCreateReleaseBody,
|
||||
params: zAppReleaseServiceCreateReleasePath,
|
||||
}),
|
||||
)
|
||||
.output(zAppReleaseServiceCreateReleaseResponse)
|
||||
|
||||
export const previewRelease = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'AppReleaseService_PreviewRelease',
|
||||
path: '/enterprise/app-instances/{appInstanceId}/releases/preview',
|
||||
tags: ['AppReleaseService'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zAppReleaseServicePreviewReleaseBody,
|
||||
params: zAppReleaseServicePreviewReleasePath,
|
||||
}),
|
||||
)
|
||||
.output(zAppReleaseServicePreviewReleaseResponse)
|
||||
|
||||
export const appReleaseService = {
|
||||
listReleases,
|
||||
createRelease,
|
||||
previewRelease,
|
||||
}
|
||||
|
||||
export const oAuth2Login = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
@ -556,11 +133,6 @@ export const webAppAuth = {
|
||||
}
|
||||
|
||||
export const contract = {
|
||||
accessSubjectService,
|
||||
appInstanceService,
|
||||
appDeployAccessService,
|
||||
appDeploymentService,
|
||||
appReleaseService,
|
||||
consoleSso,
|
||||
webAppAuth,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -37,13 +37,6 @@ const stripSchemaNamePrefix = (schemaName: string) => {
|
||||
.replace(/^pagination\./, '')
|
||||
}
|
||||
|
||||
const contractTagSegment = (tag?: string) => {
|
||||
if (tag === 'EnterpriseAppDeployConsole')
|
||||
return 'AppDeploy'
|
||||
|
||||
return tag || 'default'
|
||||
}
|
||||
|
||||
const contractNameSegments = (operation: ContractOperation) => {
|
||||
const operationId = operation.operationId || operation.id
|
||||
const tag = operation.tags?.[0]
|
||||
@ -55,7 +48,7 @@ const contractNameSegments = (operation: ContractOperation) => {
|
||||
}
|
||||
|
||||
const contractPathSegments = (operation: ContractOperation) => {
|
||||
return [contractTagSegment(operation.tags?.[0]), ...contractNameSegments(operation)]
|
||||
return [operation.tags?.[0] || 'default', ...contractNameSegments(operation)]
|
||||
}
|
||||
|
||||
const normalizeEnterpriseOpenApi = () => {
|
||||
|
||||
@ -1,8 +0,0 @@
|
||||
import { DeployTab } from '@/features/deployments/detail/deploy-tab'
|
||||
|
||||
export default async function InstanceDetailDeployPage({ params }: {
|
||||
params: Promise<{ appInstanceId: string }>
|
||||
}) {
|
||||
const { appInstanceId } = await params
|
||||
return <DeployTab appInstanceId={appInstanceId} />
|
||||
}
|
||||
@ -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 AmplitudeProvider from '@/app/components/base/amplitude'
|
||||
import GA, { GaType } from '@/app/components/base/ga'
|
||||
import Zendesk from '@/app/components/base/zendesk'
|
||||
import { GotoAnything } from '@/app/components/goto-anything'
|
||||
import { Header } from '@/app/components/header'
|
||||
import Header from '@/app/components/header'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import ReadmePanel from '@/app/components/plugins/readme-panel'
|
||||
import { AppContextProvider } from '@/context/app-context-provider'
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -59,7 +59,6 @@ const defaultProviderContext = {
|
||||
|
||||
const defaultModalContext: ModalContextState = {
|
||||
setShowAccountSettingModal: noop,
|
||||
setShowApiBasedExtensionModal: noop,
|
||||
setShowModerationSettingModal: noop,
|
||||
setShowExternalDataToolModal: noop,
|
||||
setShowPricingModal: noop,
|
||||
|
||||
@ -55,7 +55,6 @@ const mockUseProviderContext = vi.fn<() => ProviderContextState>()
|
||||
|
||||
const buildModalContext = (): ModalContextState => ({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
setShowApiBasedExtensionModal: vi.fn(),
|
||||
setShowModerationSettingModal: vi.fn(),
|
||||
setShowExternalDataToolModal: vi.fn(),
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
|
||||
@ -2,7 +2,7 @@ import type { ReactElement } from 'react'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { Header } from '../index'
|
||||
import Header from '../index'
|
||||
|
||||
function createMockComponent(testId: string) {
|
||||
return () => <div data-testid={testId} />
|
||||
@ -44,10 +44,6 @@ vi.mock('@/app/components/header/tools-nav', () => ({
|
||||
default: createMockComponent('tools-nav'),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/deployments/nav', () => ({
|
||||
DeploymentsNav: createMockComponent('deployments-nav'),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/plan-badge', () => ({
|
||||
PlanBadge: ({ onClick, plan }: { onClick?: () => void, plan?: string }) => (
|
||||
<button data-testid="plan-badge" onClick={onClick} data-plan={plan} />
|
||||
@ -70,7 +66,6 @@ let mockPlanType = 'sandbox'
|
||||
let mockBrandingEnabled = false
|
||||
let mockBrandingTitle: string | null = null
|
||||
let mockBrandingLogo: string | null = null
|
||||
let mockEnableAppDeploy = false
|
||||
const mockSetShowPricingModal = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
|
||||
@ -108,7 +103,6 @@ const renderHeader = (ui: ReactElement = <Header />) =>
|
||||
application_title: mockBrandingTitle ?? '',
|
||||
workspace_logo: mockBrandingLogo ?? '',
|
||||
},
|
||||
enable_app_deploy: mockEnableAppDeploy,
|
||||
},
|
||||
})
|
||||
|
||||
@ -123,7 +117,6 @@ describe('Header', () => {
|
||||
mockBrandingEnabled = false
|
||||
mockBrandingTitle = null
|
||||
mockBrandingLogo = null
|
||||
mockEnableAppDeploy = false
|
||||
})
|
||||
|
||||
it('should render header with main nav components', () => {
|
||||
@ -221,24 +214,6 @@ describe('Header', () => {
|
||||
expect(screen.getByTestId('app-nav')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide deployments nav when app deploy is disabled', () => {
|
||||
mockIsWorkspaceEditor = true
|
||||
mockEnableAppDeploy = false
|
||||
|
||||
renderHeader()
|
||||
|
||||
expect(screen.queryByTestId('deployments-nav')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show deployments nav for editors when app deploy is enabled', () => {
|
||||
mockIsWorkspaceEditor = true
|
||||
mockEnableAppDeploy = true
|
||||
|
||||
renderHeader()
|
||||
|
||||
expect(screen.getByTestId('deployments-nav')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide dataset nav when neither editor nor dataset operator', () => {
|
||||
mockIsWorkspaceEditor = false
|
||||
mockIsDatasetOperator = false
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { SetStateAction } from 'react'
|
||||
import type { ModalContextState, ModalState } from '@/context/modal-context'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
|
||||
import { useApiBasedExtensions } from '@/service/use-common'
|
||||
import ApiBasedExtensionPage from '../index'
|
||||
|
||||
@ -10,19 +8,16 @@ vi.mock('@/service/use-common', () => ({
|
||||
useApiBasedExtensions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: vi.fn(),
|
||||
vi.mock('@/service/common', () => ({
|
||||
addApiBasedExtension: vi.fn(),
|
||||
updateApiBasedExtension: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('ApiBasedExtensionPage', () => {
|
||||
const mockRefetch = vi.fn<() => void>()
|
||||
const mockSetShowApiBasedExtensionModal = vi.fn<(value: SetStateAction<ModalState<Partial<ApiBasedExtensionResponse>> | null>) => void>()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useModalContext).mockReturnValue({
|
||||
setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
|
||||
} as unknown as ModalContextState)
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
@ -128,13 +123,17 @@ describe('ApiBasedExtensionPage', () => {
|
||||
fireEvent.click(screen.getByText('common.apiBasedExtension.add'))
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payload: {},
|
||||
}))
|
||||
expect(screen.getByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call refetch when onSaveCallback is executed from the modal', () => {
|
||||
it('should call refetch when add modal saves successfully', async () => {
|
||||
// Arrange
|
||||
vi.mocked(addApiBasedExtension).mockResolvedValue({
|
||||
id: 'new-id',
|
||||
name: 'New Ext',
|
||||
api_endpoint: 'https://api.test',
|
||||
api_key: 'secret-key',
|
||||
})
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
data: [],
|
||||
isPending: false,
|
||||
@ -144,25 +143,23 @@ describe('ApiBasedExtensionPage', () => {
|
||||
// Act
|
||||
render(<ApiBasedExtensionPage />)
|
||||
fireEvent.click(screen.getByText('common.apiBasedExtension.add'))
|
||||
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } })
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
// Trigger callback manually from the mock call
|
||||
const callArgs = mockSetShowApiBasedExtensionModal.mock.calls[0]![0]
|
||||
if (typeof callArgs === 'object' && callArgs !== null && 'onSaveCallback' in callArgs) {
|
||||
if (callArgs.onSaveCallback) {
|
||||
callArgs.onSaveCallback()
|
||||
// Assert
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call refetch when an item is updated', () => {
|
||||
it('should call refetch when an item is updated', async () => {
|
||||
// Arrange
|
||||
const mockData: ApiBasedExtensionResponse[] = [
|
||||
{ id: '1', name: 'Extension 1', api_endpoint: 'url1', api_key: 'key1' },
|
||||
]
|
||||
const extension: ApiBasedExtensionResponse = { id: '1', name: 'Extension 1', api_endpoint: 'url1', api_key: 'long-api-key' }
|
||||
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...extension, name: 'Updated' })
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
data: mockData,
|
||||
data: [extension],
|
||||
isPending: false,
|
||||
refetch: mockRefetch,
|
||||
} as unknown as ReturnType<typeof useApiBasedExtensions>)
|
||||
@ -171,16 +168,12 @@ describe('ApiBasedExtensionPage', () => {
|
||||
|
||||
// Act - Click edit on the rendered item
|
||||
fireEvent.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
// Retrieve the onSaveCallback from the modal call and execute it
|
||||
const callArgs = mockSetShowApiBasedExtensionModal.mock.calls[0]![0]
|
||||
if (typeof callArgs === 'object' && callArgs !== null && 'onSaveCallback' in callArgs) {
|
||||
if (callArgs.onSaveCallback)
|
||||
callArgs.onSaveCallback()
|
||||
}
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
// Assert
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,17 +1,10 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { ModalContextState } from '@/context/modal-context'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import * as reactI18next from 'react-i18next'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { deleteApiBasedExtension } from '@/service/common'
|
||||
import Item from '../item'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
deleteApiBasedExtension: vi.fn(),
|
||||
}))
|
||||
@ -24,19 +17,16 @@ describe('Item Component', () => {
|
||||
api_key: 'test-api-key',
|
||||
}
|
||||
const mockOnUpdate = vi.fn()
|
||||
const mockSetShowApiBasedExtensionModal = vi.fn()
|
||||
const mockOnEdit = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useModalContext).mockReturnValue({
|
||||
setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
|
||||
} as unknown as ModalContextState)
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render extension data correctly', () => {
|
||||
// Act
|
||||
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
@ -54,7 +44,7 @@ describe('Item Component', () => {
|
||||
}
|
||||
|
||||
// Act
|
||||
render(<Item data={minimalData} onUpdate={mockOnUpdate} />)
|
||||
render(<Item data={minimalData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
|
||||
// Assert
|
||||
// Assert
|
||||
@ -64,41 +54,20 @@ describe('Item Component', () => {
|
||||
})
|
||||
|
||||
describe('Modal Interactions', () => {
|
||||
it('should open edit modal with correct payload when clicking edit button', () => {
|
||||
it('should request editing with the current extension when clicking edit button', () => {
|
||||
// Act
|
||||
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
fireEvent.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payload: mockData,
|
||||
}))
|
||||
const lastCall = mockSetShowApiBasedExtensionModal.mock.calls[0]![0]
|
||||
if (typeof lastCall === 'object' && lastCall !== null && 'onSaveCallback' in lastCall)
|
||||
expect(lastCall.onSaveCallback).toBeInstanceOf(Function)
|
||||
})
|
||||
|
||||
it('should execute onUpdate callback when edit modal save callback is invoked', () => {
|
||||
// Act
|
||||
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
|
||||
fireEvent.click(screen.getByText('common.operation.edit'))
|
||||
|
||||
// Assert
|
||||
const modalCallArg = mockSetShowApiBasedExtensionModal.mock.calls[0]![0]
|
||||
if (typeof modalCallArg === 'object' && modalCallArg !== null && 'onSaveCallback' in modalCallArg) {
|
||||
const onSaveCallback = modalCallArg.onSaveCallback
|
||||
if (onSaveCallback) {
|
||||
onSaveCallback()
|
||||
expect(mockOnUpdate).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
}
|
||||
expect(mockOnEdit).toHaveBeenCalledWith(mockData)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Deletion', () => {
|
||||
it('should show delete confirmation dialog when clicking delete button', () => {
|
||||
// Act
|
||||
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
|
||||
// Assert
|
||||
@ -109,7 +78,7 @@ describe('Item Component', () => {
|
||||
it('should call delete API and triggers onUpdate when confirming deletion', async () => {
|
||||
// Arrange
|
||||
vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
|
||||
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
@ -129,7 +98,7 @@ describe('Item Component', () => {
|
||||
it('should hide delete confirmation dialog after successful deletion', async () => {
|
||||
// Arrange
|
||||
vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
|
||||
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
@ -147,7 +116,7 @@ describe('Item Component', () => {
|
||||
|
||||
it('should close delete confirmation when clicking cancel button', async () => {
|
||||
// Act
|
||||
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
@ -159,7 +128,7 @@ describe('Item Component', () => {
|
||||
|
||||
it('should not call delete API when canceling deletion', () => {
|
||||
// Act
|
||||
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
fireEvent.click(screen.getByText('common.operation.delete'))
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
@ -188,7 +157,7 @@ describe('Item Component', () => {
|
||||
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
|
||||
|
||||
// Act
|
||||
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
|
||||
render(<Item data={mockData} onEdit={mockOnEdit} onUpdate={mockOnUpdate} />)
|
||||
const allButtons = screen.getAllByRole('button')
|
||||
const editBtn = screen.getByText('operation.edit')
|
||||
const deleteBtn = allButtons.find(btn => btn !== editBtn)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { ComponentProps, ReactElement } from 'react'
|
||||
import { fireEvent, render as RTLRender, screen, waitFor } from '@testing-library/react'
|
||||
import * as reactI18next from 'react-i18next'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
@ -34,7 +34,7 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
}))
|
||||
|
||||
describe('ApiBasedExtensionModal', () => {
|
||||
const mockOnCancel = vi.fn()
|
||||
const mockOnOpenChange = vi.fn()
|
||||
const mockOnSave = vi.fn()
|
||||
const mockDocLink = vi.fn((path?: string) => `https://docs.dify.ai${path || ''}`)
|
||||
const mockExtension = (overrides: Partial<ApiBasedExtensionResponse> = {}): ApiBasedExtensionResponse => ({
|
||||
@ -46,6 +46,19 @@ describe('ApiBasedExtensionModal', () => {
|
||||
})
|
||||
|
||||
const render = (ui: ReactElement) => RTLRender(ui)
|
||||
const renderModal = (props: Partial<ComponentProps<typeof ApiBasedExtensionModal>> = {}) => render(
|
||||
<ApiBasedExtensionModal
|
||||
open
|
||||
extension={{}}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
{...props}
|
||||
/>,
|
||||
)
|
||||
const expectCloseRequested = () => {
|
||||
const calls = mockOnOpenChange.mock.calls
|
||||
expect(calls[calls.length - 1]?.[0]).toBe(false)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@ -55,9 +68,10 @@ describe('ApiBasedExtensionModal', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render correctly for adding a new extension', () => {
|
||||
// Act
|
||||
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
|
||||
renderModal()
|
||||
|
||||
// Assert
|
||||
expect(screen.getByRole('dialog', { name: 'common.apiBasedExtension.modal.title' })).toBeInTheDocument()
|
||||
expect(screen.getByText('common.apiBasedExtension.modal.title')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder')).toBeInTheDocument()
|
||||
@ -69,7 +83,7 @@ describe('ApiBasedExtensionModal', () => {
|
||||
const data = mockExtension()
|
||||
|
||||
// Act
|
||||
render(<ApiBasedExtensionModal data={data} onCancel={mockOnCancel} onSave={mockOnSave} />)
|
||||
renderModal({ extension: data })
|
||||
|
||||
// Assert
|
||||
expect(screen.getByText('common.apiBasedExtension.modal.editTitle')).toBeInTheDocument()
|
||||
@ -77,6 +91,14 @@ describe('ApiBasedExtensionModal', () => {
|
||||
expect(screen.getByDisplayValue('url')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('key')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render dialog content when closed', () => {
|
||||
// Act
|
||||
renderModal({ open: false })
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Submissions', () => {
|
||||
@ -89,7 +111,7 @@ describe('ApiBasedExtensionModal', () => {
|
||||
api_key: 'secret-key',
|
||||
})
|
||||
vi.mocked(addApiBasedExtension).mockResolvedValue(newExtension)
|
||||
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
|
||||
renderModal()
|
||||
|
||||
// Act
|
||||
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
|
||||
@ -115,7 +137,7 @@ describe('ApiBasedExtensionModal', () => {
|
||||
// Arrange
|
||||
const data = mockExtension({ api_key: 'long-secret-key' })
|
||||
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, name: 'Updated' })
|
||||
render(<ApiBasedExtensionModal data={data} onCancel={mockOnCancel} onSave={mockOnSave} />)
|
||||
renderModal({ extension: data })
|
||||
|
||||
// Act
|
||||
fireEvent.change(screen.getByDisplayValue('Existing'), { target: { value: 'Updated' } })
|
||||
@ -125,12 +147,11 @@ describe('ApiBasedExtensionModal', () => {
|
||||
await waitFor(() => {
|
||||
expect(updateApiBasedExtension).toHaveBeenCalledWith({
|
||||
url: '/api-based-extension/1',
|
||||
body: expect.objectContaining({
|
||||
id: '1',
|
||||
body: {
|
||||
name: 'Updated',
|
||||
api_endpoint: 'url',
|
||||
api_key: '[__HIDDEN__]',
|
||||
}),
|
||||
},
|
||||
})
|
||||
expect(mockToast.success).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully')
|
||||
expect(mockOnSave).toHaveBeenCalled()
|
||||
@ -141,7 +162,7 @@ describe('ApiBasedExtensionModal', () => {
|
||||
// Arrange
|
||||
const data = mockExtension({ api_key: 'old-key' })
|
||||
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, api_key: 'new-longer-key' })
|
||||
render(<ApiBasedExtensionModal data={data} onCancel={mockOnCancel} onSave={mockOnSave} />)
|
||||
renderModal({ extension: data })
|
||||
|
||||
// Act
|
||||
fireEvent.change(screen.getByDisplayValue('old-key'), { target: { value: 'new-longer-key' } })
|
||||
@ -151,9 +172,11 @@ describe('ApiBasedExtensionModal', () => {
|
||||
await waitFor(() => {
|
||||
expect(updateApiBasedExtension).toHaveBeenCalledWith({
|
||||
url: '/api-based-extension/1',
|
||||
body: expect.objectContaining({
|
||||
body: {
|
||||
name: 'Existing',
|
||||
api_endpoint: 'url',
|
||||
api_key: 'new-longer-key',
|
||||
}),
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -162,7 +185,7 @@ describe('ApiBasedExtensionModal', () => {
|
||||
describe('Validation', () => {
|
||||
it('should show error if api key is too short', async () => {
|
||||
// Arrange
|
||||
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
|
||||
renderModal()
|
||||
|
||||
// Act
|
||||
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'Ext' } })
|
||||
@ -180,7 +203,7 @@ describe('ApiBasedExtensionModal', () => {
|
||||
it('should work when onSave is not provided', async () => {
|
||||
// Arrange
|
||||
vi.mocked(addApiBasedExtension).mockResolvedValue(mockExtension({ id: 'new-id' }))
|
||||
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} />)
|
||||
renderModal({ onSave: undefined })
|
||||
|
||||
// Act
|
||||
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
|
||||
@ -194,15 +217,56 @@ describe('ApiBasedExtensionModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should call onCancel when clicking cancel button', () => {
|
||||
it('should request closing when clicking cancel button', () => {
|
||||
// Arrange
|
||||
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
|
||||
renderModal()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
|
||||
// Assert
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
expectCloseRequested()
|
||||
})
|
||||
|
||||
it('should request closing when clicking close button', async () => {
|
||||
// Arrange
|
||||
renderModal()
|
||||
|
||||
// Act
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expectCloseRequested()
|
||||
})
|
||||
})
|
||||
|
||||
it('should request closing when pressing Escape', async () => {
|
||||
// Arrange
|
||||
renderModal()
|
||||
|
||||
// Act
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expectCloseRequested()
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep open when clicking outside the dialog', () => {
|
||||
// Arrange
|
||||
renderModal()
|
||||
|
||||
// Act
|
||||
const backdrop = document.querySelector('.bg-background-overlay')
|
||||
expect(backdrop).toBeInTheDocument()
|
||||
fireEvent.pointerDown(backdrop!)
|
||||
fireEvent.pointerUp(backdrop!)
|
||||
fireEvent.click(backdrop!)
|
||||
|
||||
// Assert
|
||||
expect(mockOnOpenChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -230,7 +294,7 @@ describe('ApiBasedExtensionModal', () => {
|
||||
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
|
||||
|
||||
// Act
|
||||
const { container } = render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} />)
|
||||
const { container } = renderModal({ onSave: undefined })
|
||||
|
||||
// Assert
|
||||
const inputs = container.querySelectorAll('input')
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { UseQueryResult } from '@tanstack/react-query'
|
||||
import type { ModalContextState } from '@/context/modal-context'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { addApiBasedExtension } from '@/service/common'
|
||||
import { useApiBasedExtensions } from '@/service/use-common'
|
||||
import ApiBasedExtensionSelector from '../selector'
|
||||
|
||||
@ -15,12 +16,15 @@ vi.mock('@/service/use-common', () => ({
|
||||
useApiBasedExtensions: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/common', () => ({
|
||||
addApiBasedExtension: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/popover', async () => await import('@/__mocks__/base-ui-popover'))
|
||||
|
||||
describe('ApiBasedExtensionSelector', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
const mockSetShowAccountSettingModal = vi.fn()
|
||||
const mockSetShowApiBasedExtensionModal = vi.fn()
|
||||
const mockRefetch = vi.fn()
|
||||
|
||||
const mockData: ApiBasedExtensionResponse[] = [
|
||||
@ -32,7 +36,6 @@ describe('ApiBasedExtensionSelector', () => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useModalContext).mockReturnValue({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
|
||||
} as unknown as ModalContextState)
|
||||
vi.mocked(useApiBasedExtensions).mockReturnValue({
|
||||
data: mockData,
|
||||
@ -103,26 +106,29 @@ describe('ApiBasedExtensionSelector', () => {
|
||||
})
|
||||
|
||||
it('should open add modal when clicking add button and refetches on save', async () => {
|
||||
// Arrange
|
||||
vi.mocked(addApiBasedExtension).mockResolvedValue({
|
||||
id: 'new-id',
|
||||
name: 'New Ext',
|
||||
api_endpoint: 'https://api.test',
|
||||
api_key: 'secret-key',
|
||||
})
|
||||
|
||||
// Act
|
||||
render(<ApiBasedExtensionSelector value="" onChange={mockOnChange} />)
|
||||
fireEvent.click(screen.getByText('common.apiBasedExtension.selector.placeholder'))
|
||||
|
||||
const addButton = await screen.findByText('common.operation.add')
|
||||
fireEvent.click(addButton)
|
||||
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } })
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
|
||||
// Assert
|
||||
expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payload: {},
|
||||
}))
|
||||
|
||||
// Trigger callback
|
||||
const lastCall = mockSetShowApiBasedExtensionModal.mock.calls[0]![0]
|
||||
if (typeof lastCall === 'object' && lastCall !== null && 'onSaveCallback' in lastCall) {
|
||||
if (lastCall.onSaveCallback) {
|
||||
lastCall.onSaveCallback()
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
}
|
||||
}
|
||||
await waitFor(() => {
|
||||
expect(mockRefetch).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,24 +1,42 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
RiAddLine,
|
||||
} from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useApiBasedExtensions } from '@/service/use-common'
|
||||
import Empty from './empty'
|
||||
import Item from './item'
|
||||
import ApiBasedExtensionModal from './modal'
|
||||
|
||||
type ApiBasedExtensionDialogState = {
|
||||
extension: Partial<ApiBasedExtensionResponse>
|
||||
onSave: () => void
|
||||
} | null
|
||||
|
||||
const ApiBasedExtensionPage = () => {
|
||||
const { t } = useTranslation()
|
||||
const { setShowApiBasedExtensionModal } = useModalContext()
|
||||
const { data, refetch: mutate, isPending: isLoading } = useApiBasedExtensions()
|
||||
const [dialogState, setDialogState] = useState<ApiBasedExtensionDialogState>(null)
|
||||
|
||||
const handleOpenApiBasedExtensionModal = () => {
|
||||
setShowApiBasedExtensionModal({
|
||||
payload: {},
|
||||
onSaveCallback: () => mutate(),
|
||||
setDialogState({
|
||||
extension: {},
|
||||
onSave: () => mutate(),
|
||||
})
|
||||
}
|
||||
const handleEditApiBasedExtension = (extension: ApiBasedExtensionResponse) => {
|
||||
setDialogState({
|
||||
extension,
|
||||
onSave: () => mutate(),
|
||||
})
|
||||
}
|
||||
const handleSaveApiBasedExtension = () => {
|
||||
dialogState?.onSave()
|
||||
setDialogState(null)
|
||||
}
|
||||
const handleApiBasedExtensionModalOpenChange = (open: boolean) => {
|
||||
if (!open)
|
||||
setDialogState(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -33,6 +51,7 @@ const ApiBasedExtensionPage = () => {
|
||||
<Item
|
||||
key={item.id}
|
||||
data={item}
|
||||
onEdit={handleEditApiBasedExtension}
|
||||
onUpdate={() => mutate()}
|
||||
/>
|
||||
))
|
||||
@ -43,9 +62,19 @@ const ApiBasedExtensionPage = () => {
|
||||
className="w-full"
|
||||
onClick={handleOpenApiBasedExtensionModal}
|
||||
>
|
||||
<RiAddLine className="mr-1 h-4 w-4" />
|
||||
<span className="mr-1 i-ri-add-line h-4 w-4" aria-hidden="true" />
|
||||
{t('apiBasedExtension.add', { ns: 'common' })}
|
||||
</Button>
|
||||
{
|
||||
dialogState && (
|
||||
<ApiBasedExtensionModal
|
||||
open
|
||||
extension={dialogState.extension}
|
||||
onOpenChange={handleApiBasedExtensionModalOpenChange}
|
||||
onSave={handleSaveApiBasedExtension}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@ -9,32 +8,25 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEditLine,
|
||||
} from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { deleteApiBasedExtension } from '@/service/common'
|
||||
|
||||
type ItemProps = {
|
||||
data: ApiBasedExtensionResponse
|
||||
onEdit: (extension: ApiBasedExtensionResponse) => void
|
||||
onUpdate: () => void
|
||||
}
|
||||
const Item: FC<ItemProps> = ({
|
||||
const Item = ({
|
||||
data,
|
||||
onEdit,
|
||||
onUpdate,
|
||||
}) => {
|
||||
}: ItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { setShowApiBasedExtensionModal } = useModalContext()
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
const handleOpenApiBasedExtensionModal = () => {
|
||||
setShowApiBasedExtensionModal({
|
||||
payload: data,
|
||||
onSaveCallback: () => onUpdate(),
|
||||
})
|
||||
onEdit(data)
|
||||
}
|
||||
const handleDeleteApiBasedExtension = async () => {
|
||||
await deleteApiBasedExtension(`/api-based-extension/${data.id}`)
|
||||
@ -44,28 +36,27 @@ const Item: FC<ItemProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group mb-2 flex items-center rounded-xl border-[0.5px] border-transparent bg-components-input-bg-normal px-4 py-2 hover:border-components-input-border-active hover:shadow-xs">
|
||||
<div className="grow">
|
||||
<div className="group mb-2 flex items-center rounded-xl border-[0.5px] border-transparent bg-components-input-bg-normal px-4 py-2 focus-within:border-components-input-border-active focus-within:shadow-xs hover:border-components-input-border-active hover:shadow-xs">
|
||||
<div className="min-w-0 grow">
|
||||
<div className="mb-0.5 text-[13px] font-medium text-text-secondary">{data.name}</div>
|
||||
<div className="text-xs text-text-tertiary">{data.api_endpoint}</div>
|
||||
<div className="truncate text-xs text-text-tertiary">{data.api_endpoint}</div>
|
||||
</div>
|
||||
<div className="hidden items-center group-hover:flex">
|
||||
<div className="pointer-events-none flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
<Button
|
||||
className="mr-1"
|
||||
onClick={handleOpenApiBasedExtensionModal}
|
||||
>
|
||||
<RiEditLine className="mr-1 h-4 w-4" />
|
||||
<span className="mr-1 i-ri-edit-line h-4 w-4" aria-hidden="true" />
|
||||
{t('operation.edit', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<RiDeleteBinLine className="mr-1 h-4 w-4" />
|
||||
<span className="mr-1 i-ri-delete-bin-line h-4 w-4" aria-hidden="true" />
|
||||
{t('operation.delete', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={open => !open && setShowDeleteConfirm(false)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogContent backdropProps={{ forceRender: true }}>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
|
||||
{`${t('operation.delete', { ns: 'common' })} \u201C${data.name}\u201D?`}
|
||||
|
||||
@ -2,51 +2,57 @@ import type {
|
||||
ApiBasedExtensionPayload,
|
||||
ApiBasedExtensionResponse,
|
||||
} from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BookOpen01 } from '@/app/components/base/icons/src/vender/line/education'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
|
||||
|
||||
type ApiBasedExtensionField = 'name' | 'api_endpoint' | 'api_key'
|
||||
|
||||
type ApiBasedExtensionModalProps = {
|
||||
data: Partial<ApiBasedExtensionResponse>
|
||||
onCancel: () => void
|
||||
open: boolean
|
||||
extension: Partial<ApiBasedExtensionResponse>
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave?: (newData: ApiBasedExtensionResponse) => void
|
||||
}
|
||||
const ApiBasedExtensionModal: FC<ApiBasedExtensionModalProps> = ({ data, onCancel, onSave }) => {
|
||||
const ApiBasedExtensionModal = ({ open, extension, onOpenChange, onSave }: ApiBasedExtensionModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const [localeData, setLocaleData] = useState(data)
|
||||
const [localData, setLocalData] = useState(extension)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const handleDataChange = (type: string, value: string) => {
|
||||
setLocaleData({ ...localeData, [type]: value })
|
||||
const handleDataChange = (field: ApiBasedExtensionField, value: string) => {
|
||||
setLocalData({ ...localData, [field]: value })
|
||||
}
|
||||
const handleSave = async () => {
|
||||
setLoading(true)
|
||||
if (localeData && localeData.api_key && localeData.api_key?.length < 5) {
|
||||
if (localData.api_key && localData.api_key.length < 5) {
|
||||
toast.error(t('apiBasedExtension.modal.apiKey.lengthError', { ns: 'common' }))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const payload: ApiBasedExtensionPayload = {
|
||||
name: localData.name || '',
|
||||
api_endpoint: localData.api_endpoint || '',
|
||||
api_key: localData.api_key || '',
|
||||
}
|
||||
let res = {} as ApiBasedExtensionResponse
|
||||
if (!data.id) {
|
||||
if (!extension.id) {
|
||||
res = await addApiBasedExtension({
|
||||
url: '/api-based-extension',
|
||||
body: localeData as ApiBasedExtensionPayload,
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
else {
|
||||
res = await updateApiBasedExtension({
|
||||
url: `/api-based-extension/${data.id}`,
|
||||
url: `/api-based-extension/${extension.id}`,
|
||||
body: {
|
||||
...localeData,
|
||||
api_key: data.api_key === localeData.api_key ? '[__HIDDEN__]' : localeData.api_key,
|
||||
} as ApiBasedExtensionPayload,
|
||||
...payload,
|
||||
api_key: extension.api_key === localData.api_key ? '[__HIDDEN__]' : payload.api_key,
|
||||
},
|
||||
})
|
||||
toast.success(t('actionMsg.modifiedSuccessfully', { ns: 'common' }))
|
||||
}
|
||||
@ -57,44 +63,47 @@ const ApiBasedExtensionModal: FC<ApiBasedExtensionModalProps> = ({ data, onCance
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogContent className="w-[640px]! max-w-none! border-none p-8! pb-6! text-left align-middle">
|
||||
|
||||
<div className="mb-2 text-xl font-semibold text-text-primary">
|
||||
{data.name
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange} disablePointerDismissal>
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className="w-160 border-none p-8 pb-6 text-left"
|
||||
>
|
||||
<DialogCloseButton />
|
||||
|
||||
<DialogTitle className="mb-2 pr-8 text-xl font-semibold text-text-primary">
|
||||
{extension.name
|
||||
? t('apiBasedExtension.modal.editTitle', { ns: 'common' })
|
||||
: t('apiBasedExtension.modal.title', { ns: 'common' })}
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<div className="py-2">
|
||||
<div className="text-sm leading-9 font-medium text-text-primary">
|
||||
{t('apiBasedExtension.modal.name.title', { ns: 'common' })}
|
||||
</div>
|
||||
<input value={localeData.name || ''} onChange={e => handleDataChange('name', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.name.placeholder', { ns: 'common' }) || ''} />
|
||||
<input value={localData.name || ''} onChange={e => handleDataChange('name', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.name.placeholder', { ns: 'common' }) || ''} />
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<div className="flex h-9 items-center justify-between text-sm font-medium text-text-primary">
|
||||
{t('apiBasedExtension.modal.apiEndpoint.title', { ns: 'common' })}
|
||||
<a href={docLink('/use-dify/workspace/api-extension/api-extension')} target="_blank" rel="noopener noreferrer" className="group flex items-center text-xs font-normal text-text-accent">
|
||||
<BookOpen01 className="mr-1 h-3 w-3" />
|
||||
<a href={docLink('/use-dify/workspace/api-extension/api-extension')} target="_blank" rel="noopener noreferrer" className="flex items-center text-xs font-normal text-text-accent">
|
||||
<span className="mr-1 i-custom-vender-line-education-book-open-01 h-3 w-3" aria-hidden="true" />
|
||||
{t('apiBasedExtension.link', { ns: 'common' })}
|
||||
</a>
|
||||
</div>
|
||||
<input value={localeData.api_endpoint || ''} onChange={e => handleDataChange('api_endpoint', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiEndpoint.placeholder', { ns: 'common' }) || ''} />
|
||||
<input value={localData.api_endpoint || ''} onChange={e => handleDataChange('api_endpoint', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiEndpoint.placeholder', { ns: 'common' }) || ''} />
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<div className="text-sm leading-9 font-medium text-text-primary">
|
||||
{t('apiBasedExtension.modal.apiKey.title', { ns: 'common' })}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input value={localeData.api_key || ''} onChange={e => handleDataChange('api_key', e.target.value)} className="mr-2 block h-9 grow appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiKey.placeholder', { ns: 'common' }) || ''} />
|
||||
</div>
|
||||
<input value={localData.api_key || ''} onChange={e => handleDataChange('api_key', e.target.value)} className="block h-9 w-full appearance-none rounded-lg bg-components-input-bg-normal px-3 text-sm text-text-primary outline-hidden" placeholder={t('apiBasedExtension.modal.apiKey.placeholder', { ns: 'common' }) || ''} />
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-end">
|
||||
<Button onClick={onCancel} className="mr-2">
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={!localeData.name || !localeData.api_endpoint || !localeData.api_key || loading} onClick={handleSave}>
|
||||
<Button variant="primary" disabled={!localData.name || !localData.api_endpoint || !localData.api_key || loading} onClick={handleSave}>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -1,32 +1,25 @@
|
||||
import type { FC } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import {
|
||||
RiAddLine,
|
||||
RiArrowDownSLine,
|
||||
} from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
ArrowUpRight,
|
||||
} from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useApiBasedExtensions } from '@/service/use-common'
|
||||
import ApiBasedExtensionModal from './modal'
|
||||
|
||||
type ApiBasedExtensionSelectorProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const ApiBasedExtensionSelector: FC<ApiBasedExtensionSelectorProps> = ({
|
||||
const ApiBasedExtensionSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
}: ApiBasedExtensionSelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [addModalOpen, setAddModalOpen] = useState(false)
|
||||
const {
|
||||
setShowAccountSettingModal,
|
||||
setShowApiBasedExtensionModal,
|
||||
} = useModalContext()
|
||||
const { data, refetch: mutate } = useApiBasedExtensions()
|
||||
const handleSelect = (id: string) => {
|
||||
@ -36,91 +29,115 @@ const ApiBasedExtensionSelector: FC<ApiBasedExtensionSelectorProps> = ({
|
||||
|
||||
const currentItem = data?.find(item => item.id === value)
|
||||
|
||||
const handleSaveApiBasedExtension = () => {
|
||||
mutate()
|
||||
setAddModalOpen(false)
|
||||
}
|
||||
const handleAddModalOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen)
|
||||
setAddModalOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button type="button" className="block w-full border-0 bg-transparent p-0 text-left">
|
||||
{
|
||||
currentItem
|
||||
? (
|
||||
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3">
|
||||
<div className="text-sm text-text-primary">{currentItem.name}</div>
|
||||
<div className="flex items-center">
|
||||
<div className="mr-1.5 w-[270px] truncate text-right text-xs text-text-quaternary">
|
||||
{currentItem.api_endpoint}
|
||||
</div>
|
||||
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3 text-sm text-text-quaternary">
|
||||
{t('apiBasedExtension.selector.placeholder', { ns: 'common' })}
|
||||
<RiArrowDownSLine className={`h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
className="w-[calc(100%-32px)] max-w-[576px]"
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
<>
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<div className="z-10 w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
|
||||
<div className="p-1">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-1">
|
||||
<div className="text-xs font-medium text-text-tertiary">
|
||||
{t('apiBasedExtension.selector.title', { ns: 'common' })}
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<button type="button" className="block w-full border-0 bg-transparent p-0 text-left">
|
||||
{
|
||||
currentItem
|
||||
? (
|
||||
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3">
|
||||
<div className="text-sm text-text-primary">{currentItem.name}</div>
|
||||
<div className="flex items-center">
|
||||
<div className="mr-1.5 w-[270px] truncate text-right text-xs text-text-quaternary">
|
||||
{currentItem.api_endpoint}
|
||||
</div>
|
||||
<span className={`i-ri-arrow-down-s-line h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex h-9 cursor-pointer items-center justify-between rounded-lg bg-components-input-bg-normal pr-2.5 pl-3 text-sm text-text-quaternary">
|
||||
{t('apiBasedExtension.selector.placeholder', { ns: 'common' })}
|
||||
<span className={`i-ri-arrow-down-s-line h-4 w-4 text-text-secondary ${!open && 'opacity-60'}`} aria-hidden="true" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
className="w-[calc(100%-32px)] max-w-[576px]"
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<div className="z-10 w-full rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
|
||||
<div className="p-1">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-1">
|
||||
<div className="text-xs font-medium text-text-tertiary">
|
||||
{t('apiBasedExtension.selector.title', { ns: 'common' })}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center border-none bg-transparent p-0 text-xs text-text-accent"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION })
|
||||
}}
|
||||
>
|
||||
{t('apiBasedExtension.selector.manage', { ns: 'common' })}
|
||||
<span className="ml-0.5 i-custom-vender-line-arrows-arrow-up-right h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="flex cursor-pointer items-center text-xs text-text-accent"
|
||||
<div className="max-h-[250px] overflow-y-auto">
|
||||
{
|
||||
data?.map(item => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
className="w-full cursor-pointer rounded-md border-none bg-transparent px-3 py-1.5 text-left hover:bg-state-base-hover"
|
||||
onClick={() => handleSelect(item.id!)}
|
||||
>
|
||||
<div className="text-sm text-text-primary">{item.name}</div>
|
||||
<div className="text-xs text-text-tertiary">{item.api_endpoint}</div>
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular" />
|
||||
<div className="p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 w-full cursor-pointer items-center border-none bg-transparent px-3 text-left text-sm text-text-accent"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION })
|
||||
setAddModalOpen(true)
|
||||
}}
|
||||
>
|
||||
{t('apiBasedExtension.selector.manage', { ns: 'common' })}
|
||||
<ArrowUpRight className="ml-0.5 h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[250px] overflow-y-auto">
|
||||
{
|
||||
data?.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="w-full cursor-pointer rounded-md px-3 py-1.5 text-left hover:stroke-state-base-hover"
|
||||
onClick={() => handleSelect(item.id)}
|
||||
>
|
||||
<div className="text-sm text-text-primary">{item.name}</div>
|
||||
<div className="text-xs text-text-tertiary">{item.api_endpoint}</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
<span className="mr-2 i-ri-add-line h-4 w-4" aria-hidden="true" />
|
||||
{t('operation.add', { ns: 'common' })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center px-3 text-sm text-text-accent"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setShowApiBasedExtensionModal({ payload: {}, onSaveCallback: () => mutate() })
|
||||
}}
|
||||
>
|
||||
<RiAddLine className="mr-2 h-4 w-4" />
|
||||
{t('operation.add', { ns: 'common' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{
|
||||
addModalOpen && (
|
||||
<ApiBasedExtensionModal
|
||||
open
|
||||
extension={{}}
|
||||
onOpenChange={handleAddModalOpenChange}
|
||||
onSave={handleSaveApiBasedExtension}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import WorkplaceSelector from '@/app/components/header/account-dropdown/workplace-selector'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
@ -7,7 +8,6 @@ import { useAppContext } from '@/context/app-context'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { WorkspaceProvider } from '@/context/workspace-context-provider'
|
||||
import { DeploymentsNav } from '@/features/deployments/nav'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import Link from '@/next/link'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
@ -28,7 +28,7 @@ const navClassName = `
|
||||
cursor-pointer
|
||||
`
|
||||
|
||||
export function Header() {
|
||||
const Header = () => {
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
@ -37,33 +37,29 @@ export function Header() {
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const isFreePlan = plan.type === Plan.sandbox
|
||||
const isBrandingEnabled = systemFeatures.branding.enabled
|
||||
const canUseAppDeploy = isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy
|
||||
|
||||
function handlePlanClick() {
|
||||
const handlePlanClick = useCallback(() => {
|
||||
if (isFreePlan)
|
||||
setShowPricingModal()
|
||||
else
|
||||
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
|
||||
}
|
||||
}, [isFreePlan, setShowAccountSettingModal, setShowPricingModal])
|
||||
|
||||
function renderLogo() {
|
||||
return (
|
||||
<h1>
|
||||
<Link href="/apps" className="flex h-8 shrink-0 items-center justify-center overflow-hidden px-0.5 indent-[-9999px] whitespace-nowrap">
|
||||
{isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
|
||||
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? (
|
||||
<img
|
||||
src={systemFeatures.branding.workspace_logo}
|
||||
className="block h-[22px] w-auto object-contain"
|
||||
alt="logo"
|
||||
/>
|
||||
)
|
||||
: <DifyLogo />}
|
||||
</Link>
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
const renderLogo = () => (
|
||||
<h1>
|
||||
<Link href="/apps" className="flex h-8 shrink-0 items-center justify-center overflow-hidden px-0.5 indent-[-9999px] whitespace-nowrap">
|
||||
{isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
|
||||
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? (
|
||||
<img
|
||||
src={systemFeatures.branding.workspace_logo}
|
||||
className="block h-[22px] w-auto object-contain"
|
||||
alt="logo"
|
||||
/>
|
||||
)
|
||||
: <DifyLogo />}
|
||||
</Link>
|
||||
</h1>
|
||||
)
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
@ -77,17 +73,18 @@ export function Header() {
|
||||
</WorkspaceProvider>
|
||||
{enableBilling ? <PlanBadge allowHover sandboxAsUpgrade plan={plan.type} onClick={handlePlanClick} /> : <LicenseNav />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<PluginsNav />
|
||||
<div className="flex items-center">
|
||||
<div className="mr-2">
|
||||
<PluginsNav />
|
||||
</div>
|
||||
<AccountDropdown />
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-1 flex items-center justify-center gap-1">
|
||||
<div className="my-1 flex items-center justify-center space-x-1">
|
||||
{!isCurrentWorkspaceDatasetOperator && <ExploreNav className={navClassName} />}
|
||||
{!isCurrentWorkspaceDatasetOperator && <AppNav />}
|
||||
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
|
||||
{!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
|
||||
{canUseAppDeploy && <DeploymentsNav />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -103,18 +100,20 @@ export function Header() {
|
||||
</WorkspaceProvider>
|
||||
{enableBilling ? <PlanBadge allowHover sandboxAsUpgrade plan={plan.type} onClick={handlePlanClick} /> : <LicenseNav />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
{!isCurrentWorkspaceDatasetOperator && <ExploreNav className={navClassName} />}
|
||||
{!isCurrentWorkspaceDatasetOperator && <AppNav />}
|
||||
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
|
||||
{!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
|
||||
{canUseAppDeploy && <DeploymentsNav />}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 pr-3 pl-2 min-[1280px]:pl-3">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end pr-3 pl-2 min-[1280px]:pl-3">
|
||||
<EnvNav />
|
||||
<PluginsNav />
|
||||
<div className="mr-2">
|
||||
<PluginsNav />
|
||||
</div>
|
||||
<AccountDropdown />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Header
|
||||
|
||||
@ -26,7 +26,6 @@ const mockSetShowAccountSettingModal = vi.fn()
|
||||
vi.mock('@/context/modal-context', () => ({
|
||||
useModalContext: (): ModalContextState => ({
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
setShowApiBasedExtensionModal: vi.fn(),
|
||||
setShowModerationSettingModal: vi.fn(),
|
||||
setShowExternalDataToolModal: vi.fn(),
|
||||
setShowPricingModal: mockSetShowPricingModal,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { ReactNode, SetStateAction } from 'react'
|
||||
import type { ModalState, ModelModalType } from './modal-context'
|
||||
import type { OpeningStatement } from '@/app/components/base/features/types'
|
||||
@ -36,9 +35,6 @@ import {
|
||||
const AccountSetting = dynamic(() => import('@/app/components/header/account-setting'), {
|
||||
ssr: false,
|
||||
})
|
||||
const ApiBasedExtensionModal = dynamic(() => import('@/app/components/header/account-setting/api-based-extension-page/modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const ModerationSettingModal = dynamic(() => import('@/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
@ -90,7 +86,6 @@ export const ModalContextProvider = ({
|
||||
? urlAccountModalState.payload
|
||||
: DEFAULT_ACCOUNT_SETTING_TAB)
|
||||
: null
|
||||
const [showApiBasedExtensionModal, setShowApiBasedExtensionModal] = useState<ModalState<Partial<ApiBasedExtensionResponse>> | null>(null)
|
||||
const [showModerationSettingModal, setShowModerationSettingModal] = useState<ModalState<ModerationConfig> | null>(null)
|
||||
const [showExternalDataToolModal, setShowExternalDataToolModal] = useState<ModalState<ExternalDataTool> | null>(null)
|
||||
const [showModelModal, setShowModelModal] = useState<ModalState<ModelModalType> | null>(null)
|
||||
@ -206,12 +201,6 @@ export const ModalContextProvider = ({
|
||||
showOpeningModal.onCancelCallback()
|
||||
}, [showOpeningModal])
|
||||
|
||||
const handleSaveApiBasedExtension = (newApiBasedExtension: ApiBasedExtensionResponse) => {
|
||||
if (showApiBasedExtensionModal?.onSaveCallback)
|
||||
showApiBasedExtensionModal.onSaveCallback(newApiBasedExtension)
|
||||
setShowApiBasedExtensionModal(null)
|
||||
}
|
||||
|
||||
const handleSaveModeration = (newModerationConfig: ModerationConfig) => {
|
||||
if (showModerationSettingModal?.onSaveCallback)
|
||||
showModerationSettingModal.onSaveCallback(newModerationConfig)
|
||||
@ -247,7 +236,6 @@ export const ModalContextProvider = ({
|
||||
return (
|
||||
<ModalContext.Provider value={{
|
||||
setShowAccountSettingModal,
|
||||
setShowApiBasedExtensionModal,
|
||||
setShowModerationSettingModal,
|
||||
setShowExternalDataToolModal,
|
||||
setShowPricingModal: handleShowPricingModal,
|
||||
@ -273,15 +261,6 @@ export const ModalContextProvider = ({
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
!!showApiBasedExtensionModal && (
|
||||
<ApiBasedExtensionModal
|
||||
data={showApiBasedExtensionModal.payload}
|
||||
onCancel={() => setShowApiBasedExtensionModal(null)}
|
||||
onSave={handleSaveApiBasedExtension}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!showModerationSettingModal && (
|
||||
<ModerationSettingModal
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import type { ApiBasedExtensionResponse } from '@dify/contracts/api/console/api-based-extension/types.gen'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import type { TriggerEventsLimitModalPayload } from './hooks/use-trigger-events-limit-modal'
|
||||
import type { OpeningStatement } from '@/app/components/base/features/types'
|
||||
@ -48,7 +47,6 @@ export type ModelModalType = {
|
||||
|
||||
export type ModalContextState = {
|
||||
setShowAccountSettingModal: Dispatch<SetStateAction<ModalState<AccountSettingTab> | null>>
|
||||
setShowApiBasedExtensionModal: Dispatch<SetStateAction<ModalState<Partial<ApiBasedExtensionResponse>> | null>>
|
||||
setShowModerationSettingModal: Dispatch<SetStateAction<ModalState<ModerationConfig> | null>>
|
||||
setShowExternalDataToolModal: Dispatch<SetStateAction<ModalState<ExternalDataTool> | null>>
|
||||
setShowPricingModal: () => void
|
||||
@ -68,7 +66,6 @@ export type ModalContextState = {
|
||||
|
||||
export const ModalContext = createContext<ModalContextState>({
|
||||
setShowAccountSettingModal: noop,
|
||||
setShowApiBasedExtensionModal: noop,
|
||||
setShowModerationSettingModal: noop,
|
||||
setShowExternalDataToolModal: noop,
|
||||
setShowPricingModal: noop,
|
||||
|
||||
@ -1,113 +0,0 @@
|
||||
import type { ReleaseRow, ReleaseSummary } from '@dify/contracts/enterprise/types.gen'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { releaseDeploymentAction } from '../release-action'
|
||||
|
||||
function release(overrides: ReleaseRow): ReleaseRow {
|
||||
return overrides
|
||||
}
|
||||
|
||||
function currentRelease(overrides: ReleaseSummary): ReleaseSummary {
|
||||
return overrides
|
||||
}
|
||||
|
||||
describe('releaseDeploymentAction', () => {
|
||||
describe('deploy actions', () => {
|
||||
it('should return deploy when the target environment has no current release', () => {
|
||||
// Arrange
|
||||
const releases = [
|
||||
release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
|
||||
]
|
||||
|
||||
// Act
|
||||
const action = releaseDeploymentAction({
|
||||
targetRelease: releases[0],
|
||||
releaseRows: releases,
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(action).toBe('deploy')
|
||||
})
|
||||
|
||||
it('should return deployExistingRelease when a preset release is deployed to a new environment', () => {
|
||||
// Arrange
|
||||
const releases = [
|
||||
release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
|
||||
]
|
||||
|
||||
// Act
|
||||
const action = releaseDeploymentAction({
|
||||
targetRelease: releases[0],
|
||||
releaseRows: releases,
|
||||
isExistingRelease: true,
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(action).toBe('deployExistingRelease')
|
||||
})
|
||||
})
|
||||
|
||||
describe('release direction', () => {
|
||||
it('should return promote when the target release is newer than the current release', () => {
|
||||
// Arrange
|
||||
const releases = [
|
||||
release({ id: 'release-3', createdAt: '2026-01-03T00:00:00Z' }),
|
||||
release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
|
||||
]
|
||||
|
||||
// Act
|
||||
const action = releaseDeploymentAction({
|
||||
targetRelease: releases[0],
|
||||
currentRelease: currentRelease({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
|
||||
releaseRows: releases,
|
||||
isExistingRelease: true,
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(action).toBe('promote')
|
||||
})
|
||||
|
||||
it('should return rollback when the target release is older than the current release', () => {
|
||||
// Arrange
|
||||
const releases = [
|
||||
release({ id: 'release-3', createdAt: '2026-01-03T00:00:00Z' }),
|
||||
release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
|
||||
]
|
||||
|
||||
// Act
|
||||
const action = releaseDeploymentAction({
|
||||
targetRelease: releases[1],
|
||||
currentRelease: currentRelease({ id: 'release-3', createdAt: '2026-01-03T00:00:00Z' }),
|
||||
releaseRows: releases,
|
||||
isExistingRelease: true,
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(action).toBe('rollback')
|
||||
})
|
||||
|
||||
it('should fall back to release list order when release timestamps are unavailable', () => {
|
||||
// Arrange
|
||||
const releases = [
|
||||
release({ id: 'release-3' }),
|
||||
release({ id: 'release-2' }),
|
||||
release({ id: 'release-1' }),
|
||||
]
|
||||
|
||||
// Act
|
||||
const rollbackAction = releaseDeploymentAction({
|
||||
targetRelease: releases[2],
|
||||
currentRelease: currentRelease({ id: 'release-2' }),
|
||||
releaseRows: releases,
|
||||
})
|
||||
const promoteAction = releaseDeploymentAction({
|
||||
targetRelease: releases[0],
|
||||
currentRelease: currentRelease({ id: 'release-2' }),
|
||||
releaseRows: releases,
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(rollbackAction).toBe('rollback')
|
||||
expect(promoteAction).toBe('promote')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,349 +0,0 @@
|
||||
'use client'
|
||||
import type { App } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
ComboboxItem,
|
||||
ComboboxItemText,
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { keepPreviousData, useInfiniteQuery, useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
const SOURCE_APP_PAGE_SIZE = 20
|
||||
const SOURCE_APP_PICKER_SKELETON_KEYS = ['first-source-app', 'second-source-app', 'third-source-app']
|
||||
|
||||
function sourceAppSearchText(app: App) {
|
||||
return `${app.name} ${app.id} ${app.mode}`.toLowerCase()
|
||||
}
|
||||
|
||||
function SourceAppTrigger({ open, app }: {
|
||||
open: boolean
|
||||
app?: App
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'group flex cursor-pointer items-center gap-2 rounded-lg bg-components-input-bg-normal p-2 pl-3 hover:bg-state-base-hover-alt',
|
||||
open && 'bg-state-base-hover-alt',
|
||||
app && 'py-1.5 pl-1.5',
|
||||
)}
|
||||
>
|
||||
{app && (
|
||||
<AppIcon
|
||||
className="shrink-0"
|
||||
size="xs"
|
||||
iconType={app.icon_type}
|
||||
icon={app.icon}
|
||||
background={app.icon_background}
|
||||
imageUrl={app.icon_url}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
title={app?.name}
|
||||
className={cn(
|
||||
'min-w-0 grow truncate',
|
||||
app
|
||||
? 'system-sm-medium text-components-input-text-filled'
|
||||
: 'system-sm-regular text-components-input-text-placeholder',
|
||||
)}
|
||||
>
|
||||
{app?.name ?? t('createModal.appPickerPlaceholder')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
|
||||
open && 'text-text-secondary',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceAppOption({ app }: {
|
||||
app: App
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const modeLabel = t(`appMode.${app.mode}`, { defaultValue: app.mode })
|
||||
|
||||
return (
|
||||
<ComboboxItem
|
||||
value={app}
|
||||
className="mx-0 grid-cols-[minmax(0,1fr)_auto] gap-3 py-1 pr-3 pl-2"
|
||||
>
|
||||
<ComboboxItemText className="flex min-w-0 items-center gap-3 px-0">
|
||||
<AppIcon
|
||||
className="shrink-0"
|
||||
size="xs"
|
||||
iconType={app.icon_type}
|
||||
icon={app.icon}
|
||||
background={app.icon_background}
|
||||
imageUrl={app.icon_url}
|
||||
/>
|
||||
<span title={`${app.name} (${app.id})`} className="flex min-w-0 grow items-center gap-1 truncate system-sm-medium text-components-input-text-filled">
|
||||
<span className="truncate">{app.name}</span>
|
||||
<span className="shrink-0 text-text-tertiary">
|
||||
(
|
||||
{app.id.slice(0, 8)}
|
||||
)
|
||||
</span>
|
||||
</span>
|
||||
</ComboboxItemText>
|
||||
<span className="shrink-0 system-2xs-medium-uppercase text-text-tertiary">{modeLabel}</span>
|
||||
</ComboboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceAppPickerSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-3 py-3">
|
||||
{SOURCE_APP_PICKER_SKELETON_KEYS.map(key => (
|
||||
<SkeletonRow key={key} className="h-7 gap-3">
|
||||
<SkeletonRectangle className="my-0 size-5 animate-pulse rounded-md" />
|
||||
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
|
||||
</SkeletonRow>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceAppPicker({ value, onChange }: {
|
||||
value?: App
|
||||
onChange: (app: App) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [isShow, setIsShow] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
} = useInfiniteQuery({
|
||||
...consoleQuery.apps.list.infiniteOptions({
|
||||
input: pageParam => ({
|
||||
query: {
|
||||
page: Number(pageParam),
|
||||
limit: SOURCE_APP_PAGE_SIZE,
|
||||
name: searchText,
|
||||
},
|
||||
}),
|
||||
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
}),
|
||||
})
|
||||
|
||||
const apps = data?.pages.flatMap(page => page.data) ?? []
|
||||
|
||||
return (
|
||||
<Combobox<App>
|
||||
items={apps}
|
||||
open={isShow}
|
||||
inputValue={searchText}
|
||||
onOpenChange={setIsShow}
|
||||
onInputValueChange={setSearchText}
|
||||
onValueChange={(app) => {
|
||||
if (!app)
|
||||
return
|
||||
onChange(app)
|
||||
setIsShow(false)
|
||||
}}
|
||||
itemToStringLabel={app => app?.name ?? ''}
|
||||
itemToStringValue={app => app?.id ?? ''}
|
||||
filter={(app, query) => sourceAppSearchText(app).includes(query.toLowerCase())}
|
||||
disabled={false}
|
||||
>
|
||||
<ComboboxTrigger
|
||||
aria-label={t('createModal.sourceApp')}
|
||||
icon={false}
|
||||
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-open:bg-transparent"
|
||||
>
|
||||
<SourceAppTrigger open={isShow} app={value} />
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
|
||||
>
|
||||
<div className="relative flex max-h-100 min-h-20 w-89 flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
|
||||
<div className="p-2 pb-1">
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span className="i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
<ComboboxInput
|
||||
aria-label={t('createModal.appSearchPlaceholder')}
|
||||
placeholder={t('createModal.appSearchPlaceholder')}
|
||||
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
|
||||
/>
|
||||
</ComboboxInputGroup>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-1">
|
||||
{(isLoading || isFetchingNextPage) && apps.length === 0 && <SourceAppPickerSkeleton />}
|
||||
<ComboboxList className="max-h-none p-0">
|
||||
{(app: App) => (
|
||||
<SourceAppOption key={app.id} app={app} />
|
||||
)}
|
||||
</ComboboxList>
|
||||
{!(isLoading || isFetchingNextPage) && (
|
||||
<ComboboxEmpty>
|
||||
{t('createModal.appSearchEmpty')}
|
||||
</ComboboxEmpty>
|
||||
)}
|
||||
{hasNextPage && (
|
||||
<div className="flex justify-center px-3 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
disabled={isFetchingNextPage}
|
||||
onClick={() => {
|
||||
void fetchNextPage()
|
||||
}}
|
||||
>
|
||||
{isFetchingNextPage ? t('common.loading') : t('createModal.loadMoreApps')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateInstanceForm({ onClose }: {
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const router = useRouter()
|
||||
const createInstance = useMutation(consoleQuery.enterprise.appInstanceService.createAppInstance.mutationOptions())
|
||||
|
||||
const [sourceApp, setSourceApp] = useState<App>()
|
||||
|
||||
const canCreate = Boolean(sourceApp?.id && !createInstance.isPending)
|
||||
|
||||
const handleCreate = async (form: HTMLFormElement) => {
|
||||
if (!canCreate || !sourceApp?.id)
|
||||
return
|
||||
|
||||
const formData = new FormData(form)
|
||||
const name = String(formData.get('name') ?? '').trim()
|
||||
const description = String(formData.get('description') ?? '').trim()
|
||||
if (!name)
|
||||
return
|
||||
|
||||
try {
|
||||
const result = await createInstance.mutateAsync({
|
||||
body: {
|
||||
sourceAppId: sourceApp.id,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
},
|
||||
})
|
||||
if (!result.appInstanceId)
|
||||
throw new Error('Create app instance did not return an appInstanceId.')
|
||||
onClose()
|
||||
router.push(`/deployments/${result.appInstanceId}/overview`)
|
||||
}
|
||||
catch {
|
||||
toast.error(t('createModal.createFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void handleCreate(event.currentTarget)
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<DialogTitle className="title-xl-semi-bold text-text-primary">
|
||||
{t('createModal.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{t('createModal.description')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="system-xs-medium-uppercase text-text-tertiary">{t('createModal.sourceApp')}</label>
|
||||
<SourceAppPicker
|
||||
value={sourceApp}
|
||||
onChange={setSourceApp}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="instance-name">
|
||||
{t('createModal.nameLabel')}
|
||||
</label>
|
||||
<Input
|
||||
id="instance-name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder={sourceApp?.name ?? t('createModal.namePlaceholder')}
|
||||
required
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="instance-desc">
|
||||
{t('createModal.descriptionLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
id="instance-desc"
|
||||
name="description"
|
||||
placeholder={t('createModal.descriptionPlaceholder')}
|
||||
className="min-h-20 w-full appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
{t('createModal.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={!canCreate}>
|
||||
{t('createModal.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function CreateInstanceModal({ open, onOpenChange }: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DialogContent className="w-130 max-w-[90vw]">
|
||||
<DialogCloseButton />
|
||||
{open && <CreateInstanceForm onClose={() => onOpenChange(false)} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
closeDeployDrawerAtom,
|
||||
deployDrawerAppInstanceIdAtom,
|
||||
deployDrawerEnvironmentIdAtom,
|
||||
deployDrawerOpenAtom,
|
||||
deployDrawerReleaseIdAtom,
|
||||
} from '../store'
|
||||
import { DeployForm } from './deploy-drawer/form'
|
||||
|
||||
export function DeployDrawer() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const open = useAtomValue(deployDrawerOpenAtom)
|
||||
const drawerAppInstanceId = useAtomValue(deployDrawerAppInstanceIdAtom)
|
||||
const drawerEnvironmentId = useAtomValue(deployDrawerEnvironmentIdAtom)
|
||||
const drawerReleaseId = useAtomValue(deployDrawerReleaseIdAtom)
|
||||
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
|
||||
const formKey = `${drawerAppInstanceId ?? 'none'}-${drawerEnvironmentId ?? 'any'}-${drawerReleaseId ?? 'new'}-${open ? '1' : '0'}`
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={next => !next && closeDeployDrawer()}
|
||||
>
|
||||
<DialogContent className="w-140 max-w-[90vw]">
|
||||
<DialogCloseButton />
|
||||
{!drawerAppInstanceId
|
||||
? <div className="p-4 text-text-tertiary">{t('deployDrawer.notFound')}</div>
|
||||
: (
|
||||
<DeployForm
|
||||
key={formKey}
|
||||
appInstanceId={drawerAppInstanceId}
|
||||
lockedEnvId={drawerEnvironmentId}
|
||||
presetReleaseId={drawerReleaseId}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -1,500 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AppDeployEnvironment, DeploymentBindingSlot, DeploymentRuntimeBinding, EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { DEPLOYMENT_PAGE_SIZE } from '../../data'
|
||||
import { environmentId, environmentMode, environmentName } from '../../environment'
|
||||
import { releaseCommit, releaseLabel } from '../../release'
|
||||
import { releaseDeploymentAction } from '../../release-action'
|
||||
import { isUndeployedDeploymentRow } from '../../runtime-status'
|
||||
import { closeDeployDrawerAtom } from '../../store'
|
||||
import {
|
||||
DeploymentSelect,
|
||||
EnvironmentRow,
|
||||
Field,
|
||||
} from './select'
|
||||
|
||||
type DeployFormProps = {
|
||||
appInstanceId: string
|
||||
lockedEnvId?: string
|
||||
presetReleaseId?: string
|
||||
}
|
||||
|
||||
type DeployReadyFormProps = DeployFormProps & {
|
||||
environments: EnvironmentOption[]
|
||||
releases: ReleaseRow[]
|
||||
defaultReleaseId?: string
|
||||
runtimeRows: EnvironmentDeployment[]
|
||||
}
|
||||
|
||||
type EnvironmentOption = AppDeployEnvironment & { id: string }
|
||||
|
||||
const DEPLOY_FORM_FIELD_SKELETON_KEYS = ['environment', 'release']
|
||||
|
||||
type BindingSelections = Record<string, string>
|
||||
|
||||
type BindingSelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type BindingOptionsPanelProps = {
|
||||
slots: DeploymentBindingSlot[]
|
||||
selections: BindingSelections
|
||||
isLoading: boolean
|
||||
hasError: boolean
|
||||
onChange: (slot: string, value: string) => void
|
||||
}
|
||||
|
||||
function isEnvBindingSlot(slot: DeploymentBindingSlot) {
|
||||
return (slot.kind?.toLowerCase() ?? '').includes('env')
|
||||
}
|
||||
|
||||
function bindingSlotKey(slot: DeploymentBindingSlot) {
|
||||
return slot.slot ?? ''
|
||||
}
|
||||
|
||||
function bindingCandidateOptions(slot: DeploymentBindingSlot): BindingSelectOption[] {
|
||||
if (isEnvBindingSlot(slot)) {
|
||||
return (slot.envVarCandidates ?? [])
|
||||
.filter(candidate => candidate.envVarId)
|
||||
.map(candidate => ({
|
||||
value: candidate.envVarId!,
|
||||
label: [
|
||||
candidate.name,
|
||||
candidate.displayValue,
|
||||
].filter(Boolean).join(' · ') || candidate.envVarId!,
|
||||
}))
|
||||
}
|
||||
|
||||
return (slot.credentialCandidates ?? [])
|
||||
.filter(candidate => candidate.credentialId)
|
||||
.map(candidate => ({
|
||||
value: candidate.credentialId!,
|
||||
label: [
|
||||
candidate.displayName,
|
||||
candidate.pluginName || candidate.pluginId,
|
||||
candidate.pluginVersion,
|
||||
].filter(Boolean).join(' · ') || candidate.credentialId!,
|
||||
}))
|
||||
}
|
||||
|
||||
function hasMissingRequiredBinding(slot: DeploymentBindingSlot, selectedValue?: string) {
|
||||
return Boolean(slot.required && !selectedValue)
|
||||
}
|
||||
|
||||
function selectedDeploymentBindings(slots: DeploymentBindingSlot[], selections: BindingSelections): DeploymentRuntimeBinding[] {
|
||||
return slots
|
||||
.map((slot): DeploymentRuntimeBinding | undefined => {
|
||||
const slotKey = bindingSlotKey(slot)
|
||||
const selectedValue = selections[slotKey]
|
||||
if (!slotKey || !selectedValue)
|
||||
return undefined
|
||||
|
||||
return isEnvBindingSlot(slot)
|
||||
? { slot: slotKey, envVarId: selectedValue }
|
||||
: { slot: slotKey, credentialId: selectedValue }
|
||||
})
|
||||
.filter((binding): binding is DeploymentRuntimeBinding => Boolean(binding))
|
||||
}
|
||||
|
||||
function selectedBindingSelections(slots: DeploymentBindingSlot[], manualBindings: BindingSelections): BindingSelections {
|
||||
const next: BindingSelections = {}
|
||||
for (const slot of slots) {
|
||||
const slotKey = bindingSlotKey(slot)
|
||||
const candidates = bindingCandidateOptions(slot)
|
||||
const existing = manualBindings[slotKey]
|
||||
if (existing && candidates.some(candidate => candidate.value === existing))
|
||||
next[slotKey] = existing
|
||||
else if (candidates.length === 1 && candidates[0])
|
||||
next[slotKey] = candidates[0].value
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function BindingOptionsPanel({
|
||||
slots,
|
||||
selections,
|
||||
isLoading,
|
||||
hasError,
|
||||
onChange,
|
||||
}: BindingOptionsPanelProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4">
|
||||
<SkeletonContainer className="gap-2">
|
||||
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
|
||||
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
|
||||
</SkeletonContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4 system-sm-regular text-text-destructive">
|
||||
{t('deployDrawer.bindingOptionsFailed')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-divider-subtle bg-background-default-subtle">
|
||||
<div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">{t('deployDrawer.runtimeCredentials')}</div>
|
||||
<span className="system-xs-regular text-text-quaternary">{t('deployDrawer.bindingSelectionHint')}</span>
|
||||
</div>
|
||||
{slots.length === 0
|
||||
? (
|
||||
<div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary">
|
||||
{t('deployDrawer.noBindingRequired')}
|
||||
</div>
|
||||
)
|
||||
: slots.map((slot) => {
|
||||
const slotKey = bindingSlotKey(slot)
|
||||
const candidates = bindingCandidateOptions(slot)
|
||||
const selectedValue = selections[slotKey] ?? ''
|
||||
const missing = hasMissingRequiredBinding(slot, selectedValue)
|
||||
return (
|
||||
<div key={slotKey} className="flex flex-col gap-2 border-t border-divider-subtle px-3 py-3">
|
||||
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(220px,0.9fr)] sm:items-start">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate system-sm-medium text-text-secondary" title={slot.name || slotKey}>
|
||||
{slot.name || slotKey}
|
||||
</span>
|
||||
{slot.required && (
|
||||
<span className="shrink-0 rounded-md bg-background-default px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('deployDrawer.requiredBinding')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-mono system-xs-regular break-all text-text-quaternary" title={slotKey}>
|
||||
{slotKey}
|
||||
</span>
|
||||
</div>
|
||||
{candidates.length === 0
|
||||
? (
|
||||
<div className="rounded-lg border border-divider-subtle bg-background-default px-2 py-1.5 system-sm-regular text-text-quaternary">
|
||||
{t('deployDrawer.noCredentialCandidates')}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<DeploymentSelect
|
||||
value={selectedValue}
|
||||
onChange={value => onChange(slotKey, value)}
|
||||
options={candidates}
|
||||
placeholder={t('deployDrawer.selectCredential')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{missing && (
|
||||
<div className="system-xs-regular text-text-destructive">
|
||||
{t('deployDrawer.missingRequiredBinding')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeployFormSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<SkeletonContainer className="gap-2">
|
||||
<SkeletonRectangle className="h-5 w-44 animate-pulse" />
|
||||
<SkeletonRectangle className="h-3 w-72 animate-pulse" />
|
||||
</SkeletonContainer>
|
||||
|
||||
{DEPLOY_FORM_FIELD_SKELETON_KEYS.map(key => (
|
||||
<SkeletonContainer key={key} className="gap-2">
|
||||
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-9 w-full animate-pulse rounded-lg" />
|
||||
</SkeletonContainer>
|
||||
))}
|
||||
|
||||
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4">
|
||||
<SkeletonContainer className="gap-2">
|
||||
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
|
||||
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
|
||||
</SkeletonContainer>
|
||||
</div>
|
||||
|
||||
<SkeletonRow className="justify-end">
|
||||
<SkeletonRectangle className="my-0 h-8 w-18 animate-pulse rounded-lg" />
|
||||
<SkeletonRectangle className="my-0 h-8 w-22 animate-pulse rounded-lg" />
|
||||
</SkeletonRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeployReadyForm({
|
||||
appInstanceId,
|
||||
environments,
|
||||
releases,
|
||||
defaultReleaseId,
|
||||
lockedEnvId,
|
||||
presetReleaseId,
|
||||
runtimeRows,
|
||||
}: DeployReadyFormProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
|
||||
const startDeploy = useMutation(consoleQuery.enterprise.appDeploymentService.createDeployment.mutationOptions())
|
||||
const presetRelease = presetReleaseId ? releases.find(r => r.id === presetReleaseId) : undefined
|
||||
const displayedRelease: ReleaseRow | undefined = presetRelease ?? (presetReleaseId ? { id: presetReleaseId } : undefined)
|
||||
const isExistingRelease = Boolean(presetReleaseId)
|
||||
|
||||
const [selectedEnvId, setSelectedEnvId] = useState<string>(
|
||||
() => lockedEnvId ?? environments[0]?.id ?? '',
|
||||
)
|
||||
const selectedEnvironmentId = selectedEnvId || lockedEnvId || environments[0]?.id || ''
|
||||
const selectedEnvironment = environments.find(env => env.id === selectedEnvironmentId)
|
||||
const [selectedReleaseId, setSelectedReleaseId] = useState<string>(
|
||||
() => displayedRelease?.id ?? defaultReleaseId ?? '',
|
||||
)
|
||||
const selectedRelease = releases.find(release => release.id === selectedReleaseId)
|
||||
const targetReleaseId = displayedRelease?.id ?? selectedRelease?.id ?? selectedReleaseId
|
||||
const targetRelease = displayedRelease ?? selectedRelease ?? (targetReleaseId ? { id: targetReleaseId } : undefined)
|
||||
const deploymentRows = runtimeRows.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row))
|
||||
const selectedDeploymentRow = deploymentRows.find(row => environmentId(row.environment) === selectedEnvironmentId)
|
||||
const action = releaseDeploymentAction({
|
||||
targetRelease,
|
||||
currentRelease: selectedDeploymentRow?.currentRelease,
|
||||
releaseRows: releases,
|
||||
isExistingRelease,
|
||||
})
|
||||
const bindingOptions = useQuery(consoleQuery.enterprise.appDeploymentService.getDeploymentPlan.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
appInstanceId,
|
||||
releaseId: targetReleaseId || '',
|
||||
},
|
||||
},
|
||||
enabled: Boolean(appInstanceId && targetReleaseId),
|
||||
}))
|
||||
const bindingSlots = bindingOptions.data?.plan?.slots?.filter(slot => slot.slot) ?? []
|
||||
const [manualBindings, setManualBindings] = useState<BindingSelections>({})
|
||||
const selectedBindings = selectedBindingSelections(bindingSlots, manualBindings)
|
||||
const deploymentBindings = selectedDeploymentBindings(bindingSlots, selectedBindings)
|
||||
const bindingOptionsLoading = Boolean(targetReleaseId && (bindingOptions.isLoading || bindingOptions.isFetching))
|
||||
const bindingOptionsReady = Boolean(targetReleaseId && bindingOptions.data && !bindingOptionsLoading && !bindingOptions.isError)
|
||||
const requiredBindingsReady = bindingSlots.every(slot => !hasMissingRequiredBinding(slot, selectedBindings[bindingSlotKey(slot)]))
|
||||
const isSubmitting = startDeploy.isPending
|
||||
const canDeploy = Boolean(
|
||||
selectedEnvironmentId
|
||||
&& selectedEnvironment
|
||||
&& targetReleaseId
|
||||
&& bindingOptionsReady
|
||||
&& requiredBindingsReady
|
||||
&& !isSubmitting,
|
||||
)
|
||||
|
||||
const lockedEnv = lockedEnvId ? environments.find(e => e.id === lockedEnvId) : undefined
|
||||
const actionTitle = action === 'rollback'
|
||||
? t('deployDrawer.rollbackTitle')
|
||||
: action === 'promote'
|
||||
? t('deployDrawer.promoteTitle')
|
||||
: action === 'deployExistingRelease'
|
||||
? t('deployDrawer.deployExistingReleaseTitle')
|
||||
: t('deployDrawer.title')
|
||||
const actionDescription = action === 'rollback'
|
||||
? t('deployDrawer.rollbackDescription')
|
||||
: action === 'promote'
|
||||
? t('deployDrawer.promoteDescription')
|
||||
: action === 'deployExistingRelease'
|
||||
? t('deployDrawer.deployExistingReleaseDescription')
|
||||
: t('deployDrawer.description')
|
||||
const submitLabel = isSubmitting
|
||||
? t('deployDrawer.deploying')
|
||||
: action === 'rollback'
|
||||
? t('deployDrawer.rollback')
|
||||
: action === 'promote'
|
||||
? t('deployDrawer.promote')
|
||||
: action === 'deployExistingRelease'
|
||||
? t('deployDrawer.deployExistingRelease')
|
||||
: t('deployDrawer.deploy')
|
||||
|
||||
const handleDeploy = () => {
|
||||
if (!canDeploy || !targetReleaseId)
|
||||
return
|
||||
|
||||
startDeploy.mutate(
|
||||
{
|
||||
params: {
|
||||
appInstanceId,
|
||||
environmentId: selectedEnvironmentId,
|
||||
},
|
||||
body: {
|
||||
appInstanceId,
|
||||
environmentId: selectedEnvironmentId,
|
||||
releaseId: targetReleaseId,
|
||||
bindings: deploymentBindings,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
closeDeployDrawer()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('deployDrawer.deployFailed'))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<DialogTitle className="title-xl-semi-bold text-text-primary">
|
||||
{actionTitle}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{actionDescription}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<Field label={t('deployDrawer.releaseLabel')}>
|
||||
{isExistingRelease && displayedRelease
|
||||
? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="shrink-0 font-mono system-sm-semibold text-text-primary">{releaseLabel(displayedRelease)}</span>
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
|
||||
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">{releaseCommit(displayedRelease)}</span>
|
||||
</div>
|
||||
<span className="shrink-0 system-xs-regular text-text-quaternary">{displayedRelease.createdAt}</span>
|
||||
</div>
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t('deployDrawer.existingReleaseHint')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
: releases.length === 0
|
||||
? (
|
||||
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-3 py-3 system-sm-regular text-text-tertiary">
|
||||
{t('deployDrawer.noReleaseAvailable')}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<DeploymentSelect
|
||||
value={selectedReleaseId}
|
||||
onChange={setSelectedReleaseId}
|
||||
options={releases.filter(release => release.id).map(release => ({
|
||||
value: release.id!,
|
||||
label: `${releaseLabel(release)} · ${releaseCommit(release)}`,
|
||||
}))}
|
||||
placeholder={t('deployDrawer.selectRelease')}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t('deployDrawer.targetEnv')}
|
||||
hint={lockedEnvId ? t('deployDrawer.lockedHint') : undefined}
|
||||
>
|
||||
{lockedEnv
|
||||
? <EnvironmentRow env={lockedEnv} />
|
||||
: (
|
||||
<DeploymentSelect
|
||||
value={selectedEnvironmentId}
|
||||
onChange={setSelectedEnvId}
|
||||
options={environments.filter(env => env.id).map(env => ({
|
||||
value: env.id!,
|
||||
label: `${environmentName(env)} · ${t(environmentMode(env) === 'isolated' ? 'mode.isolated' : 'mode.shared')} · ${(env.type ?? 'env').toUpperCase()}`,
|
||||
}))}
|
||||
placeholder={t('deployDrawer.selectEnv')}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{targetReleaseId && (
|
||||
<BindingOptionsPanel
|
||||
slots={bindingSlots}
|
||||
selections={selectedBindings}
|
||||
isLoading={bindingOptionsLoading}
|
||||
hasError={bindingOptions.isError}
|
||||
onChange={(slot, value) => setManualBindings(prev => ({ ...prev, [slot]: value }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={closeDeployDrawer}>
|
||||
{t('deployDrawer.cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={!canDeploy} onClick={handleDeploy}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeployForm({
|
||||
appInstanceId,
|
||||
lockedEnvId,
|
||||
presetReleaseId,
|
||||
}: DeployFormProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const releaseHistoryQuery = useQuery(consoleQuery.enterprise.appReleaseService.listReleases.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
query: {
|
||||
pageNumber: 1,
|
||||
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
|
||||
},
|
||||
},
|
||||
}))
|
||||
const runtimeInstancesQuery = useQuery(consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
},
|
||||
}))
|
||||
|
||||
if (releaseHistoryQuery.isLoading || runtimeInstancesQuery.isLoading) {
|
||||
return <DeployFormSkeleton />
|
||||
}
|
||||
|
||||
if (releaseHistoryQuery.isError || runtimeInstancesQuery.isError) {
|
||||
return (
|
||||
<div className="p-4 system-sm-regular text-text-destructive">
|
||||
{t('common.loadFailed')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const environments = runtimeInstancesQuery.data?.data
|
||||
?.map(row => row.environment)
|
||||
.filter((environment): environment is EnvironmentOption => Boolean(environment?.id)) ?? []
|
||||
const releases = releaseHistoryQuery.data?.data?.filter(release => release.id) ?? []
|
||||
const defaultReleaseId = releases[0]?.id
|
||||
const runtimeRows = runtimeInstancesQuery.data?.data ?? []
|
||||
const formKey = `${appInstanceId}-${lockedEnvId ?? 'any'}-${presetReleaseId ?? 'new'}-${defaultReleaseId ?? 'none'}`
|
||||
|
||||
return (
|
||||
<DeployReadyForm
|
||||
key={formKey}
|
||||
appInstanceId={appInstanceId}
|
||||
environments={environments}
|
||||
releases={releases}
|
||||
defaultReleaseId={defaultReleaseId}
|
||||
lockedEnvId={lockedEnvId}
|
||||
presetReleaseId={presetReleaseId}
|
||||
runtimeRows={runtimeRows}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -1,94 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AppDeployEnvironment } from '@dify/contracts/enterprise/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { environmentHealth, environmentMode, environmentName } from '../../environment'
|
||||
import { HealthBadge, ModeBadge } from '../status-badge'
|
||||
|
||||
type EnvironmentOption = AppDeployEnvironment & {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function Field({ label, hint, children }: {
|
||||
label: string
|
||||
hint?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">{label}</div>
|
||||
{hint && <span className="system-xs-regular text-text-quaternary">{hint}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
disabled?: boolean
|
||||
disabledReason?: string
|
||||
}
|
||||
|
||||
type SelectProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options: SelectOption[]
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function DeploymentSelect({ value, onChange, options, placeholder }: SelectProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const selectedOption = options.find(option => option.value === value)
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value || null}
|
||||
onValueChange={(next) => {
|
||||
if (!next)
|
||||
return
|
||||
onChange(next)
|
||||
}}
|
||||
disabled={options.length === 0}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
'h-8 min-w-0 border border-components-input-border-active px-2 text-left system-sm-medium',
|
||||
!selectedOption && 'text-text-quaternary',
|
||||
)}
|
||||
>
|
||||
{selectedOption?.label ?? placeholder ?? t('deployDrawer.defaultSelect')}
|
||||
</SelectTrigger>
|
||||
<SelectContent popupClassName="w-(--anchor-width)">
|
||||
{options.map(opt => (
|
||||
<SelectItem
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
disabled={opt.disabled}
|
||||
title={opt.disabled ? opt.disabledReason : undefined}
|
||||
>
|
||||
<SelectItemText>{opt.label}</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnvironmentRow({ env }: { env: EnvironmentOption }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="system-sm-semibold text-text-primary">{environmentName(env)}</span>
|
||||
<ModeBadge mode={environmentMode(env)} />
|
||||
<HealthBadge health={environmentHealth(env)} />
|
||||
</div>
|
||||
<span className="system-xs-regular text-text-tertiary uppercase">{env.type ?? 'env'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
'use client'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type DeployStatus = 'ready' | 'deploying' | 'deploy_failed' | 'unknown'
|
||||
type EnvironmentMode = 'shared' | 'isolated'
|
||||
type EnvironmentHealth = 'ready' | 'degraded'
|
||||
|
||||
const statusStyles: Record<DeployStatus, string> = {
|
||||
ready: 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700',
|
||||
deploying: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
|
||||
deploy_failed: 'border-util-colors-red-red-200 bg-util-colors-red-red-50 text-util-colors-red-red-700',
|
||||
unknown: 'border-divider-subtle bg-background-default-subtle text-text-tertiary',
|
||||
}
|
||||
|
||||
const statusKey = {
|
||||
ready: 'status.ready',
|
||||
deploying: 'status.deploying',
|
||||
deploy_failed: 'status.deployFailed',
|
||||
unknown: 'status.unknown',
|
||||
} as const satisfies Record<DeployStatus, string>
|
||||
|
||||
const baseBadge = 'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 system-xs-medium whitespace-nowrap'
|
||||
|
||||
export function StatusBadge({ status, className }: {
|
||||
status: DeployStatus
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
return (
|
||||
<span className={cn(baseBadge, statusStyles[status], className)}>
|
||||
{status === 'deploying' && (
|
||||
<span className="size-1.5 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
{t(statusKey[status])}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModeBadge({ mode, className }: {
|
||||
mode: EnvironmentMode
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const style = mode === 'shared'
|
||||
? 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700'
|
||||
: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700'
|
||||
return (
|
||||
<span className={cn(baseBadge, style, className)}>
|
||||
{t(mode === 'shared' ? 'mode.shared' : 'mode.isolated')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function HealthBadge({ health, className }: {
|
||||
health: EnvironmentHealth
|
||||
className?: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const style = health === 'ready'
|
||||
? 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700'
|
||||
: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700'
|
||||
return (
|
||||
<span className={cn(baseBadge, style, className)}>
|
||||
{t(health === 'ready' ? 'health.ready' : 'health.degraded')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -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,113 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
type SectionProps = {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
children: ReactNode
|
||||
layout?: 'block' | 'row'
|
||||
tone?: 'default' | 'destructive'
|
||||
}
|
||||
|
||||
export function SectionState({ children }: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-24 items-center justify-center border-y border-dashed border-divider-subtle px-4 py-6 text-center system-sm-regular text-text-tertiary">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DetailListState({ children }: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-36 items-center justify-center border-y border-dashed border-divider-subtle px-4 py-12 text-center system-sm-regular text-text-tertiary">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
children,
|
||||
layout = 'block',
|
||||
tone = 'default',
|
||||
}: SectionProps) {
|
||||
const titleClassName = cn(
|
||||
'system-sm-semibold',
|
||||
tone === 'destructive'
|
||||
? 'text-util-colors-red-red-700'
|
||||
: layout === 'row'
|
||||
? 'text-text-secondary'
|
||||
: 'text-text-primary',
|
||||
)
|
||||
const descriptionClassName = cn(
|
||||
'mt-1 body-xs-regular',
|
||||
tone === 'destructive' ? 'text-util-colors-red-red-600' : 'text-text-tertiary',
|
||||
)
|
||||
|
||||
if (layout === 'row') {
|
||||
return (
|
||||
<section className="border-b border-divider-subtle py-4 first:pt-0 last:border-b-0 last:pb-0">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:gap-x-6">
|
||||
<div className="flex min-w-0 shrink-0 flex-col sm:w-40 sm:pt-1">
|
||||
<div className={titleClassName}>
|
||||
{title}
|
||||
</div>
|
||||
{description && (
|
||||
<p className={descriptionClassName}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 grow">
|
||||
{action
|
||||
? (
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="min-w-0 grow">
|
||||
{children}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: children}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="border-b border-divider-subtle py-6 first:pt-0 last:border-b-0 last:pb-0">
|
||||
<div className="mb-3 flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className={titleClassName}>
|
||||
{title}
|
||||
</div>
|
||||
{description && (
|
||||
<p className={cn(descriptionClassName, 'max-w-150')}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{Boolean(action) && (
|
||||
<div className="shrink-0">
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -1,143 +0,0 @@
|
||||
'use client'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { deploymentStatusPollingInterval } from '../runtime-status'
|
||||
import { openDeployDrawerAtom } from '../store'
|
||||
import {
|
||||
DetailListState,
|
||||
} from './common'
|
||||
import { DeploymentEnvironmentList } from './deploy-tab/deployment-environment-list'
|
||||
import {
|
||||
DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME,
|
||||
DETAIL_LIST_CLASS_NAME,
|
||||
DETAIL_LIST_DESKTOP_ROW_CLASS_NAME,
|
||||
DETAIL_LIST_HEADER_ROW_CLASS_NAME,
|
||||
DETAIL_LIST_ROW_CLASS_NAME,
|
||||
} from './list-styles'
|
||||
|
||||
function NewDeploymentButton({ appInstanceId }: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="medium"
|
||||
variant="primary"
|
||||
className="gap-1.5"
|
||||
onClick={() => openDeployDrawer({ appInstanceId })}
|
||||
>
|
||||
{t('deployTab.newDeployment')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const DEPLOYMENT_TABLE_ROW_SKELETON_KEYS = ['production', 'staging']
|
||||
|
||||
function DeploymentEnvironmentListSkeleton() {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${DETAIL_LIST_CLASS_NAME} pc:hidden`}>
|
||||
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
|
||||
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-4 w-18 animate-pulse rounded-md" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<SkeletonRectangle className="h-2.5 w-24 animate-pulse" />
|
||||
<SkeletonRow className="gap-2">
|
||||
<SkeletonRectangle className="h-3 w-16 animate-pulse" />
|
||||
<SkeletonRectangle className="h-2.5 w-18 animate-pulse" />
|
||||
</SkeletonRow>
|
||||
</div>
|
||||
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden pc:block">
|
||||
<div className={DETAIL_LIST_CLASS_NAME}>
|
||||
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
|
||||
<div>{t('deployTab.col.environment')}</div>
|
||||
<div>{t('deployTab.col.status')}</div>
|
||||
<div>{t('deployTab.col.currentRelease')}</div>
|
||||
<div className="text-right">{t('deployTab.col.actions')}</div>
|
||||
</div>
|
||||
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
|
||||
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
|
||||
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
|
||||
<div className="min-w-0">
|
||||
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<SkeletonRectangle className="my-0 h-4 w-18 animate-pulse rounded-md" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<SkeletonRow className="gap-2">
|
||||
<SkeletonRectangle className="h-3 w-16 animate-pulse" />
|
||||
<SkeletonRectangle className="h-2.5 w-18 animate-pulse" />
|
||||
</SkeletonRow>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeployTab({ appInstanceId }: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
},
|
||||
refetchInterval: query => deploymentStatusPollingInterval(query.state.data),
|
||||
}))
|
||||
const environmentDeployments = environmentDeploymentsQuery.data
|
||||
const rows = environmentDeployments?.data?.filter(row => row.environment?.id) ?? []
|
||||
const isLoading = environmentDeploymentsQuery.isLoading
|
||||
const hasError = environmentDeploymentsQuery.isError
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1080px] min-w-0 flex-col gap-4 px-6 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="system-sm-semibold text-text-primary">
|
||||
{t('deployTab.envCount')}
|
||||
{' '}
|
||||
<span className="system-sm-regular text-text-tertiary">
|
||||
(
|
||||
{rows.length}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
<NewDeploymentButton appInstanceId={appInstanceId} />
|
||||
</div>
|
||||
|
||||
{isLoading
|
||||
? <DeploymentEnvironmentListSkeleton />
|
||||
: hasError
|
||||
? <DetailListState>{t('common.loadFailed')}</DetailListState>
|
||||
: rows.length === 0
|
||||
? <DetailListState>{t('deployTab.empty')}</DetailListState>
|
||||
: (
|
||||
<DeploymentEnvironmentList appInstanceId={appInstanceId} rows={rows} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,334 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
environmentId,
|
||||
environmentName,
|
||||
} from '../../environment'
|
||||
import { releaseCommit, releaseLabel } from '../../release'
|
||||
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
|
||||
import { openDeployDrawerAtom } from '../../store'
|
||||
import {
|
||||
DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME,
|
||||
DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME,
|
||||
DETAIL_LIST_CLASS_NAME,
|
||||
DETAIL_LIST_DESKTOP_ROW_CLASS_NAME,
|
||||
DETAIL_LIST_HEADER_ROW_CLASS_NAME,
|
||||
DETAIL_LIST_ROW_CLASS_NAME,
|
||||
} from '../list-styles'
|
||||
import { DeploymentStatusSummary } from './deployment-status-summary'
|
||||
|
||||
function EnvironmentSummary({ environment }: {
|
||||
environment: EnvironmentDeployment['environment']
|
||||
}) {
|
||||
return (
|
||||
<span className="block truncate system-sm-semibold text-text-primary">
|
||||
{environmentName(environment)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CurrentReleaseSummary({ release }: {
|
||||
release: EnvironmentDeployment['currentRelease']
|
||||
}) {
|
||||
if (!release?.id && !release?.name)
|
||||
return <span className="system-sm-regular text-text-quaternary">—</span>
|
||||
|
||||
const commit = releaseCommit(release)
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span className="truncate font-mono system-sm-medium text-text-primary">
|
||||
{releaseLabel(release)}
|
||||
</span>
|
||||
{commit !== '—' && (
|
||||
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">
|
||||
{commit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeploymentRowActions({ appInstanceId, envId, row }: {
|
||||
appInstanceId: string
|
||||
envId: string
|
||||
row: EnvironmentDeployment
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
|
||||
const undeployDeployment = useMutation(consoleQuery.enterprise.appDeploymentService.undeployRuntimeInstance.mutationOptions())
|
||||
const isUndeployed = isUndeployedDeploymentRow(row)
|
||||
const status = deploymentStatus(row)
|
||||
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
|
||||
const [actionsOpen, setActionsOpen] = useState(false)
|
||||
const [isUndeploying, setIsUndeploying] = useState(false)
|
||||
const undeployInFlightRef = useRef(false)
|
||||
const isUndeployRequesting = undeployDeployment.isPending || isUndeploying
|
||||
const undeployActionDisabled = isUndeployRequesting || !envId
|
||||
const isDeploying = status === 'deploying'
|
||||
const deployActionLabel = isUndeployed
|
||||
? t('deployDrawer.deploy')
|
||||
: status === 'deploy_failed'
|
||||
? t('deployTab.viewError')
|
||||
: t('deployTab.deployOtherVersion')
|
||||
|
||||
function handleDeployAction() {
|
||||
openDeployDrawer({ appInstanceId, environmentId: envId })
|
||||
setActionsOpen(false)
|
||||
}
|
||||
|
||||
function handleUndeploy() {
|
||||
if (!envId || undeployInFlightRef.current)
|
||||
return
|
||||
undeployInFlightRef.current = true
|
||||
setIsUndeploying(true)
|
||||
undeployDeployment.mutate(
|
||||
{
|
||||
params: { appInstanceId, environmentId: envId },
|
||||
body: { appInstanceId, environmentId: envId },
|
||||
},
|
||||
{
|
||||
onSettled: () => {
|
||||
undeployInFlightRef.current = false
|
||||
setIsUndeploying(false)
|
||||
setShowUndeployConfirm(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
>
|
||||
{!isDeploying && (
|
||||
<DropdownMenu modal={false} open={actionsOpen} onOpenChange={setActionsOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('deployTab.moreActions')}
|
||||
className={DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
{actionsOpen && (
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-44">
|
||||
<DropdownMenuItem
|
||||
className="gap-2 px-3"
|
||||
onClick={handleDeployAction}
|
||||
>
|
||||
<span aria-hidden className="i-ri-rocket-line size-4 shrink-0 text-text-tertiary" />
|
||||
<span className="system-sm-regular text-text-secondary">{deployActionLabel}</span>
|
||||
</DropdownMenuItem>
|
||||
{!isUndeployed && (
|
||||
<>
|
||||
<div className="my-1 border-t border-divider-subtle" aria-hidden />
|
||||
<DropdownMenuItem
|
||||
disabled={undeployActionDisabled}
|
||||
aria-disabled={undeployActionDisabled}
|
||||
className={cn(
|
||||
'gap-2 px-3 text-util-colors-red-red-600',
|
||||
undeployActionDisabled && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (undeployActionDisabled)
|
||||
return
|
||||
setActionsOpen(false)
|
||||
setShowUndeployConfirm(true)
|
||||
}}
|
||||
>
|
||||
<span aria-hidden className="i-ri-logout-box-line size-4 shrink-0" />
|
||||
<span className="system-sm-regular">{t('deployTab.undeploy')}</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{!isUndeployed && !isDeploying && (
|
||||
<AlertDialog
|
||||
open={showUndeployConfirm}
|
||||
onOpenChange={(open) => {
|
||||
if (isUndeployRequesting)
|
||||
return
|
||||
setShowUndeployConfirm(open)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="w-130">
|
||||
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
|
||||
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t('deployTab.undeployConfirmTitle', { name: environmentName(row.environment) })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="system-md-regular text-text-tertiary">
|
||||
{t('deployTab.undeployConfirmDesc')}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton variant="secondary" disabled={isUndeployRequesting}>
|
||||
{t('deployDrawer.cancel')}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton
|
||||
loading={isUndeployRequesting}
|
||||
disabled={undeployActionDisabled}
|
||||
onClick={handleUndeploy}
|
||||
>
|
||||
{t('deployTab.confirmUndeploy')}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CurrentReleaseMobileSummary({ release }: {
|
||||
release: EnvironmentDeployment['currentRelease']
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
if (!release?.id && !release?.name)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t('deployTab.col.currentRelease')}
|
||||
</span>
|
||||
<CurrentReleaseSummary release={release} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeploymentEnvironmentMobileRow({ appInstanceId, row }: {
|
||||
appInstanceId: string
|
||||
row: EnvironmentDeployment
|
||||
}) {
|
||||
const envId = environmentId(row.environment)
|
||||
const release = row.currentRelease
|
||||
const status = deploymentStatus(row)
|
||||
const showFailureBanner = status === 'deploy_failed' && Boolean(row.status)
|
||||
|
||||
return (
|
||||
<div className="border-b border-divider-subtle last:border-b-0">
|
||||
<div className="flex flex-col gap-3 p-4 text-left">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<EnvironmentSummary environment={row.environment} />
|
||||
<DeploymentStatusSummary row={row} />
|
||||
</div>
|
||||
{!isUndeployedDeploymentRow(row) && <CurrentReleaseMobileSummary release={release} />}
|
||||
<div className="flex min-w-0 items-center justify-start gap-2">
|
||||
<DeploymentRowActions appInstanceId={appInstanceId} envId={envId} row={row} />
|
||||
</div>
|
||||
</div>
|
||||
{showFailureBanner && (
|
||||
<div className="flex items-center gap-2 border-l-2 border-util-colors-red-red-500 bg-util-colors-red-red-50 px-3 py-2 system-xs-regular text-util-colors-red-red-700">
|
||||
<span aria-hidden className="i-ri-alert-line size-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{row.status}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeploymentEnvironmentDesktopRows({ appInstanceId, rows }: {
|
||||
appInstanceId: string
|
||||
rows: EnvironmentDeployment[]
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{rows.map((row, index) => {
|
||||
const envId = environmentId(row.environment)
|
||||
const status = deploymentStatus(row)
|
||||
const showFailureBanner = status === 'deploy_failed' && Boolean(row.status)
|
||||
const isLast = index === rows.length - 1
|
||||
|
||||
return (
|
||||
<div
|
||||
key={envId}
|
||||
className={DETAIL_LIST_ROW_CLASS_NAME}
|
||||
>
|
||||
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
|
||||
<div className="min-w-0">
|
||||
<EnvironmentSummary environment={row.environment} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<DeploymentStatusSummary row={row} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<CurrentReleaseSummary release={row.currentRelease} />
|
||||
</div>
|
||||
<div className="flex w-8 justify-end">
|
||||
<DeploymentRowActions appInstanceId={appInstanceId} envId={envId} row={row} />
|
||||
</div>
|
||||
</div>
|
||||
{showFailureBanner && (
|
||||
<div className={cn('flex items-center gap-2 border-t border-l-2 border-divider-subtle border-l-util-colors-red-red-500 bg-util-colors-red-red-50 px-4 py-2 system-xs-regular text-util-colors-red-red-700', isLast && 'rounded-b-lg')}>
|
||||
<span aria-hidden className="i-ri-alert-line size-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{row.status}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeploymentEnvironmentList({ appInstanceId, rows }: {
|
||||
appInstanceId: string
|
||||
rows: EnvironmentDeployment[]
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(DETAIL_LIST_CLASS_NAME, 'pc:hidden')}>
|
||||
{rows.map(row => (
|
||||
<DeploymentEnvironmentMobileRow
|
||||
key={environmentId(row.environment)}
|
||||
appInstanceId={appInstanceId}
|
||||
row={row}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden pc:block">
|
||||
<div className={DETAIL_LIST_CLASS_NAME}>
|
||||
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME}`}>
|
||||
<div>{t('deployTab.col.environment')}</div>
|
||||
<div>{t('deployTab.col.status')}</div>
|
||||
<div>{t('deployTab.col.currentRelease')}</div>
|
||||
<div className="text-right">{t('deployTab.col.actions')}</div>
|
||||
</div>
|
||||
<DeploymentEnvironmentDesktopRows appInstanceId={appInstanceId} rows={rows} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { releaseLabel } from '../../release'
|
||||
import {
|
||||
deploymentStatus,
|
||||
isUndeployedDeploymentRow,
|
||||
} from '../../runtime-status'
|
||||
|
||||
const StatusIconSlot = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<span className="flex size-3 shrink-0 items-center justify-center">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeploymentStatusSummary({ row }: {
|
||||
row: EnvironmentDeployment
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
if (isUndeployedDeploymentRow(row)) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 system-sm-medium text-text-tertiary">
|
||||
<StatusIconSlot>
|
||||
<span className="size-1.5 rounded-full bg-text-quaternary" />
|
||||
</StatusIconSlot>
|
||||
{t('status.notDeployed')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const status = deploymentStatus(row)
|
||||
|
||||
if (status === 'deploying') {
|
||||
const hasTargetRelease = !!(row.currentRelease?.name || row.currentRelease?.id)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-blue-blue-700">
|
||||
<StatusIconSlot>
|
||||
<span className="i-ri-loader-4-line size-2 animate-spin" />
|
||||
</StatusIconSlot>
|
||||
{hasTargetRelease
|
||||
? t('deployTab.status.deployingRelease', { release: releaseLabel(row.currentRelease) })
|
||||
: t('status.deploying')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'deploy_failed') {
|
||||
const hasRunningRelease = !!row.currentRelease?.id
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-red-red-700">
|
||||
<StatusIconSlot>
|
||||
<span className="i-ri-alert-line size-3" />
|
||||
</StatusIconSlot>
|
||||
{t(hasRunningRelease ? 'deployTab.status.runningWithFailed' : 'deployTab.status.deployFailed')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'unknown') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 system-sm-medium text-text-tertiary">
|
||||
<StatusIconSlot>
|
||||
<span className="i-ri-question-line size-3" />
|
||||
</StatusIconSlot>
|
||||
{t('status.unknown')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 system-sm-medium text-util-colors-green-green-700">
|
||||
<StatusIconSlot>
|
||||
<span className="size-1.5 rounded-full bg-util-colors-green-green-500" />
|
||||
</StatusIconSlot>
|
||||
{t('status.ready')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -1,269 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ComponentProps, PropsWithoutRef } from 'react'
|
||||
import type { InstanceDetailTabKey } from './tabs'
|
||||
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useHover, useKeyPress, useLocalStorageState } from 'ahooks'
|
||||
import { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getAppModeLabel } from '@/app/components/app-sidebar/app-info/app-mode-labels'
|
||||
import NavLink from '@/app/components/app-sidebar/nav-link'
|
||||
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { toAppMode } from '../app-mode'
|
||||
|
||||
type TabDef = {
|
||||
key: InstanceDetailTabKey
|
||||
icon: NavIcon
|
||||
selectedIcon: NavIcon
|
||||
}
|
||||
|
||||
type DeploymentSidebarMode = 'expand' | 'collapse'
|
||||
|
||||
const DEPLOYMENT_SIDEBAR_MODE_KEY = 'deployment-sidebar-collapse-or-expand'
|
||||
|
||||
type TailwindNavIconProps = PropsWithoutRef<ComponentProps<'svg'>> & {
|
||||
title?: string
|
||||
titleId?: string
|
||||
}
|
||||
|
||||
function OverviewIcon({ className }: TailwindNavIconProps) {
|
||||
return <span aria-hidden className={cn('i-ri-dashboard-2-line', className)} />
|
||||
}
|
||||
function OverviewSelectedIcon({ className }: TailwindNavIconProps) {
|
||||
return <span aria-hidden className={cn('i-ri-dashboard-2-fill', className)} />
|
||||
}
|
||||
function DeployIcon({ className }: TailwindNavIconProps) {
|
||||
return <span aria-hidden className={cn('i-ri-rocket-line', className)} />
|
||||
}
|
||||
function DeploySelectedIcon({ className }: TailwindNavIconProps) {
|
||||
return <span aria-hidden className={cn('i-ri-rocket-fill', className)} />
|
||||
}
|
||||
function VersionsIcon({ className }: TailwindNavIconProps) {
|
||||
return <span aria-hidden className={cn('i-ri-stack-line', className)} />
|
||||
}
|
||||
function VersionsSelectedIcon({ className }: TailwindNavIconProps) {
|
||||
return <span aria-hidden className={cn('i-ri-stack-fill', className)} />
|
||||
}
|
||||
function SettingsIcon({ className }: TailwindNavIconProps) {
|
||||
return <span aria-hidden className={cn('i-ri-settings-3-line', className)} />
|
||||
}
|
||||
function SettingsSelectedIcon({ className }: TailwindNavIconProps) {
|
||||
return <span aria-hidden className={cn('i-ri-settings-3-fill', className)} />
|
||||
}
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ key: 'overview', icon: OverviewIcon, selectedIcon: OverviewSelectedIcon },
|
||||
{ key: 'deploy', icon: DeployIcon, selectedIcon: DeploySelectedIcon },
|
||||
{ key: 'releases', icon: VersionsIcon, selectedIcon: VersionsSelectedIcon },
|
||||
{ key: 'settings', icon: SettingsIcon, selectedIcon: SettingsSelectedIcon },
|
||||
]
|
||||
|
||||
function isShortcutFromInputArea(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement))
|
||||
return false
|
||||
|
||||
return target.tagName === 'INPUT'
|
||||
|| target.tagName === 'TEXTAREA'
|
||||
|| target.isContentEditable
|
||||
}
|
||||
|
||||
function useDeploymentSidebarMode(isMobile: boolean) {
|
||||
const [persistedMode, setPersistedMode] = useLocalStorageState<DeploymentSidebarMode>(
|
||||
DEPLOYMENT_SIDEBAR_MODE_KEY,
|
||||
{ defaultValue: 'expand' },
|
||||
)
|
||||
const sidebarMode = isMobile ? 'collapse' : persistedMode ?? 'expand'
|
||||
|
||||
function toggleSidebarMode() {
|
||||
setPersistedMode(sidebarMode === 'expand' ? 'collapse' : 'expand')
|
||||
}
|
||||
|
||||
return {
|
||||
sidebarMode,
|
||||
toggleSidebarMode,
|
||||
}
|
||||
}
|
||||
|
||||
type DeploymentSidebarProps = {
|
||||
appInstanceId: string
|
||||
}
|
||||
|
||||
function DeploymentSidebarInstanceInfo({ appInstanceId, expand }: {
|
||||
appInstanceId: string
|
||||
expand: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const { t: tCommon } = useTranslation()
|
||||
const overviewQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
},
|
||||
}))
|
||||
const app = overviewQuery.data?.overview?.appInstance
|
||||
const isLoading = !app?.id && overviewQuery.isLoading
|
||||
const isUnavailable = !app?.id || overviewQuery.isError
|
||||
const instanceName = app?.name ?? appInstanceId
|
||||
const appModeLabel = app?.id ? getAppModeLabel(toAppMode(app.mode), tCommon) : ''
|
||||
const sourceAppLink = app?.sourceAppId && app.sourceAppAvailable !== false ? `/app/${app.sourceAppId}/overview` : undefined
|
||||
const sourceAppName = app?.sourceAppName ?? t('detail.sourceApp')
|
||||
|
||||
return (
|
||||
<div className={cn('shrink-0', expand ? 'p-2' : 'p-1')}>
|
||||
<div className={cn('flex flex-col gap-2 rounded-lg', expand ? 'p-1' : 'items-center p-1')}>
|
||||
{isLoading
|
||||
? (
|
||||
<>
|
||||
<SkeletonRectangle className={cn('my-0 animate-pulse rounded-lg', expand ? 'size-10' : 'size-8')} />
|
||||
{expand && (
|
||||
<SkeletonContainer className="w-full gap-1">
|
||||
<SkeletonRectangle className="my-0 h-5 w-32 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-3 w-20 animate-pulse" />
|
||||
</SkeletonContainer>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: isUnavailable
|
||||
? (
|
||||
<>
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-components-icon-bg-orange-solid text-text-primary-on-surface">
|
||||
<span className="i-ri-rocket-line size-4" />
|
||||
</div>
|
||||
{expand && (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="truncate system-md-semibold whitespace-nowrap text-text-secondary">
|
||||
{t('detail.notFound')}
|
||||
</div>
|
||||
<div className="max-w-full truncate font-mono system-2xs-regular text-text-tertiary" title={appInstanceId}>
|
||||
{appInstanceId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<AppIcon
|
||||
size={expand ? 'large' : 'medium'}
|
||||
iconType="emoji"
|
||||
icon={app.icon}
|
||||
background={app.iconBackground}
|
||||
/>
|
||||
</div>
|
||||
{expand && (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex w-full">
|
||||
<div className="truncate system-md-semibold whitespace-nowrap text-text-secondary" title={instanceName}>
|
||||
{instanceName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex max-w-full items-center gap-1.5 system-2xs-medium-uppercase text-text-tertiary">
|
||||
<span className="shrink-0 whitespace-nowrap">{appModeLabel}</span>
|
||||
{sourceAppLink && (
|
||||
<>
|
||||
<span aria-hidden className="shrink-0 text-text-quaternary">·</span>
|
||||
<Link
|
||||
href={sourceAppLink}
|
||||
className="inline-flex min-w-0 items-center gap-0.5 rounded-sm text-text-tertiary transition-colors hover:text-text-secondary"
|
||||
title={t('detail.openSourceApp', { name: sourceAppName })}
|
||||
>
|
||||
<span className="truncate">
|
||||
{t('detail.sourceAppLink')}
|
||||
</span>
|
||||
<span aria-hidden className="i-ri-arrow-right-up-line size-3 shrink-0 text-text-quaternary" />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{app.description && (
|
||||
<div
|
||||
className="line-clamp-2 system-xs-regular text-text-tertiary"
|
||||
title={app.description}
|
||||
>
|
||||
{app.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeploymentSidebar({ appInstanceId }: DeploymentSidebarProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
const isHoveringSidebar = useHover(sidebarRef)
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const { sidebarMode, toggleSidebarMode } = useDeploymentSidebarMode(isMobile)
|
||||
const expand = sidebarMode === 'expand'
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
|
||||
if (isShortcutFromInputArea(e.target))
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
toggleSidebarMode()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={sidebarRef}
|
||||
className={cn(
|
||||
'flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all',
|
||||
expand ? 'w-54' : 'w-14',
|
||||
)}
|
||||
>
|
||||
<DeploymentSidebarInstanceInfo appInstanceId={appInstanceId} expand={expand} />
|
||||
|
||||
<div className="relative px-4 py-2">
|
||||
<Divider
|
||||
type="horizontal"
|
||||
bgStyle={expand ? 'gradient' : 'solid'}
|
||||
className={cn(
|
||||
'my-0 h-px',
|
||||
expand
|
||||
? 'bg-linear-to-r from-divider-subtle to-background-gradient-mask-transparent'
|
||||
: 'bg-divider-subtle',
|
||||
)}
|
||||
/>
|
||||
{!isMobile && isHoveringSidebar && (
|
||||
<ToggleButton
|
||||
className="absolute -top-1 -right-3 z-20"
|
||||
expand={expand}
|
||||
handleToggle={toggleSidebarMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
'flex grow flex-col gap-y-0.5',
|
||||
expand ? 'px-3 py-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
{TABS.map(tab => (
|
||||
<NavLink
|
||||
key={tab.key}
|
||||
mode={sidebarMode}
|
||||
iconMap={{ selected: tab.selectedIcon, normal: tab.icon }}
|
||||
name={t(`tabs.${tab.key}.name`)}
|
||||
href={`/deployments/${appInstanceId}/${tab.key}`}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { InstanceDetailTabKey } from './tabs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useSelectedLayoutSegment } from '@/next/navigation'
|
||||
import { DeployDrawer } from '../components/deploy-drawer'
|
||||
import { DeploymentSidebar } from './deployment-sidebar'
|
||||
import { isInstanceDetailTabKey } from './tabs'
|
||||
|
||||
export function InstanceDetail({ appInstanceId, children }: {
|
||||
appInstanceId: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const selectedSegment = useSelectedLayoutSegment()
|
||||
const selectedTab = selectedSegment ?? undefined
|
||||
const activeTab: InstanceDetailTabKey = isInstanceDetailTabKey(selectedTab) ? selectedTab : 'overview'
|
||||
|
||||
useDocumentTitle(t('documentTitle.detail'))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-full min-w-0 overflow-hidden rounded-t-2xl shadow-xs">
|
||||
<DeploymentSidebar appInstanceId={appInstanceId} />
|
||||
<div className="min-w-0 grow overflow-hidden bg-components-panel-bg">
|
||||
<div className="h-full min-w-0 overflow-y-auto">
|
||||
<div className="mx-auto flex w-full max-w-[1280px] flex-col gap-y-0.5 px-6 pt-3 pb-2 2xl:max-w-[1440px]">
|
||||
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
|
||||
<div className="system-sm-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DeployDrawer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
|
||||
export const DETAIL_LIST_CLASS_NAME = 'overflow-hidden rounded-lg border border-divider-subtle bg-background-default'
|
||||
export const DETAIL_LIST_ROW_CLASS_NAME = 'border-b border-divider-subtle last:border-b-0 hover:bg-background-default-hover'
|
||||
export const DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME = cn(
|
||||
'inline-flex size-8 items-center justify-center rounded-md text-text-tertiary outline-hidden',
|
||||
'hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
)
|
||||
export const DETAIL_LIST_HEADER_ROW_CLASS_NAME = 'grid min-h-8 items-center gap-6 border-b border-divider-subtle px-4 py-1.5 system-2xs-medium-uppercase text-text-tertiary'
|
||||
export const DETAIL_LIST_DESKTOP_ROW_CLASS_NAME = 'grid min-h-12 items-center gap-6 px-4 py-2'
|
||||
export const DEPLOYMENT_DETAIL_LIST_GRID_CLASS_NAME = 'grid-cols-[minmax(160px,1fr)_minmax(150px,0.75fr)_minmax(180px,1fr)_auto]'
|
||||
export const RELEASE_DETAIL_LIST_GRID_CLASS_NAME = 'grid-cols-[minmax(150px,1fr)_minmax(130px,0.75fr)_minmax(140px,0.8fr)_minmax(150px,1fr)_auto]'
|
||||
@ -1,160 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { SectionState } from './common'
|
||||
import { EnvironmentStrip, EnvironmentStripSkeleton } from './overview-tab/environment-strip'
|
||||
import { computeOverviewStats } from './overview-tab/overview-drift'
|
||||
import { ReleaseHero, ReleaseHeroSkeleton } from './overview-tab/release-hero'
|
||||
import { useSourceAppAvailability } from './source-app-availability'
|
||||
|
||||
const OVERVIEW_RELEASE_WINDOW = 20
|
||||
|
||||
function OverviewLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1080px] min-w-0 flex-col gap-6 px-6 py-6">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceAppDeletedNotice() {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<section
|
||||
role="status"
|
||||
className="flex items-start gap-3 rounded-lg border border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 px-4 py-3 text-util-colors-warning-warning-700"
|
||||
>
|
||||
<span aria-hidden className="mt-0.5 i-ri-error-warning-fill size-4 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="system-sm-semibold text-util-colors-warning-warning-700">
|
||||
{t('overview.sourceAppDeletedTitle')}
|
||||
</div>
|
||||
<p className="mt-1 system-sm-regular text-util-colors-warning-warning-700">
|
||||
{t('overview.sourceAppDeletedDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseOverviewSection({ appInstanceId, children }: {
|
||||
appInstanceId: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<section className="flex min-w-0 flex-col gap-3">
|
||||
<div className="flex min-w-0 items-baseline justify-between gap-3">
|
||||
<h3 className="system-sm-semibold text-text-primary">
|
||||
{t('overview.recentReleases')}
|
||||
</h3>
|
||||
<Link
|
||||
href={`/deployments/${appInstanceId}/releases`}
|
||||
className="inline-flex shrink-0 items-center gap-1 system-xs-medium text-text-tertiary transition-colors hover:text-text-secondary"
|
||||
>
|
||||
{t('overview.previousReleases.viewAll')}
|
||||
<span aria-hidden className="i-ri-arrow-right-line size-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverviewTab({ appInstanceId }: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const input = { params: { appInstanceId } }
|
||||
const overviewQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({ input }))
|
||||
const runtimeInstancesQuery = useQuery(consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({ input }))
|
||||
const releasesQuery = useQuery(consoleQuery.enterprise.appReleaseService.listReleases.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
query: { pageNumber: 1, resultsPerPage: OVERVIEW_RELEASE_WINDOW },
|
||||
},
|
||||
}))
|
||||
const instance = overviewQuery.data?.overview?.appInstance
|
||||
const sourceAppAvailability = useSourceAppAvailability(instance)
|
||||
|
||||
if (overviewQuery.isLoading) {
|
||||
return (
|
||||
<OverviewLayout>
|
||||
<ReleaseOverviewSection appInstanceId={appInstanceId}>
|
||||
<ReleaseHeroSkeleton />
|
||||
</ReleaseOverviewSection>
|
||||
</OverviewLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (overviewQuery.isError) {
|
||||
return (
|
||||
<OverviewLayout>
|
||||
<SectionState>{t('common.loadFailed')}</SectionState>
|
||||
</OverviewLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (!instance?.id) {
|
||||
return (
|
||||
<OverviewLayout>
|
||||
<SectionState>{t('detail.notFound')}</SectionState>
|
||||
</OverviewLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (releasesQuery.isLoading) {
|
||||
return (
|
||||
<OverviewLayout>
|
||||
{sourceAppAvailability.sourceAppUnavailable && <SourceAppDeletedNotice />}
|
||||
<ReleaseOverviewSection appInstanceId={appInstanceId}>
|
||||
<ReleaseHeroSkeleton />
|
||||
</ReleaseOverviewSection>
|
||||
<EnvironmentStripSkeleton />
|
||||
</OverviewLayout>
|
||||
)
|
||||
}
|
||||
|
||||
if (releasesQuery.isError) {
|
||||
return (
|
||||
<OverviewLayout>
|
||||
<SectionState>{t('common.loadFailed')}</SectionState>
|
||||
</OverviewLayout>
|
||||
)
|
||||
}
|
||||
|
||||
const releaseRows = releasesQuery.data?.data ?? []
|
||||
const runtimeRows = runtimeInstancesQuery.data?.data?.filter(row => row.environment?.id) ?? []
|
||||
const latestRelease = releaseRows[0]
|
||||
const stats = computeOverviewStats(runtimeRows, releaseRows)
|
||||
|
||||
return (
|
||||
<OverviewLayout>
|
||||
{sourceAppAvailability.sourceAppUnavailable && <SourceAppDeletedNotice />}
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
<ReleaseOverviewSection appInstanceId={appInstanceId}>
|
||||
<ReleaseHero
|
||||
appInstanceId={appInstanceId}
|
||||
latestRelease={latestRelease}
|
||||
stats={stats}
|
||||
/>
|
||||
</ReleaseOverviewSection>
|
||||
<EnvironmentStrip
|
||||
appInstanceId={appInstanceId}
|
||||
rows={runtimeRows}
|
||||
releaseRows={releaseRows}
|
||||
stats={stats}
|
||||
isLoading={runtimeInstancesQuery.isLoading}
|
||||
isError={runtimeInstancesQuery.isError}
|
||||
/>
|
||||
</div>
|
||||
</OverviewLayout>
|
||||
)
|
||||
}
|
||||
@ -1,180 +0,0 @@
|
||||
import type { EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeDrift, computeOverviewStats, latestReleaseId } from '../overview-drift'
|
||||
|
||||
function row(overrides: EnvironmentDeployment): EnvironmentDeployment {
|
||||
return overrides
|
||||
}
|
||||
|
||||
function release(overrides: ReleaseRow): ReleaseRow {
|
||||
return overrides
|
||||
}
|
||||
|
||||
describe('computeDrift', () => {
|
||||
it('should return undeployed when the runtime row signals undeployed', () => {
|
||||
// Arrange
|
||||
const runtime = row({
|
||||
environment: { id: 'env-1', name: 'prod' },
|
||||
status: 'undeployed',
|
||||
})
|
||||
|
||||
// Act
|
||||
const result = computeDrift(runtime, [])
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({ kind: 'undeployed' })
|
||||
})
|
||||
|
||||
it('should return undeployed when there is no id, current release, or detail', () => {
|
||||
// Arrange
|
||||
const runtime = row({
|
||||
environment: { id: 'env-1', name: 'prod' },
|
||||
})
|
||||
|
||||
// Act
|
||||
const result = computeDrift(runtime, [release({ id: 'r-1' })])
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({ kind: 'undeployed' })
|
||||
})
|
||||
|
||||
it('should return unknown when the deployed release has no id', () => {
|
||||
// Arrange
|
||||
const runtime = row({
|
||||
environment: { id: 'env-1' },
|
||||
status: 'ready',
|
||||
runtime: { runtimeInstanceId: 'rt-1', replicas: 1 },
|
||||
})
|
||||
|
||||
// Act
|
||||
const result = computeDrift(runtime, [release({ id: 'r-1' })])
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({ kind: 'unknown' })
|
||||
})
|
||||
|
||||
it('should return unknown when the deployed release is not in the loaded window', () => {
|
||||
// Arrange
|
||||
const runtime = row({
|
||||
environment: { id: 'env-1' },
|
||||
status: 'ready',
|
||||
runtime: { runtimeInstanceId: 'rt-1' },
|
||||
currentRelease: { id: 'r-older' },
|
||||
})
|
||||
|
||||
// Act
|
||||
const result = computeDrift(runtime, [release({ id: 'r-3' }), release({ id: 'r-2' })])
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({ kind: 'unknown' })
|
||||
})
|
||||
|
||||
it('should return up-to-date when the deployed release is the newest in the window', () => {
|
||||
// Arrange
|
||||
const runtime = row({
|
||||
environment: { id: 'env-1' },
|
||||
status: 'ready',
|
||||
runtime: { runtimeInstanceId: 'rt-1' },
|
||||
currentRelease: { id: 'r-3' },
|
||||
})
|
||||
|
||||
// Act
|
||||
const result = computeDrift(runtime, [
|
||||
release({ id: 'r-3' }),
|
||||
release({ id: 'r-2' }),
|
||||
release({ id: 'r-1' }),
|
||||
])
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({ kind: 'up-to-date' })
|
||||
})
|
||||
|
||||
it('should return behind with the index distance when the deployed release is older', () => {
|
||||
// Arrange
|
||||
const runtime = row({
|
||||
environment: { id: 'env-1' },
|
||||
status: 'ready',
|
||||
runtime: { runtimeInstanceId: 'rt-1' },
|
||||
currentRelease: { id: 'r-1' },
|
||||
})
|
||||
|
||||
// Act
|
||||
const result = computeDrift(runtime, [
|
||||
release({ id: 'r-3' }),
|
||||
release({ id: 'r-2' }),
|
||||
release({ id: 'r-1' }),
|
||||
])
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({ kind: 'behind', steps: 2 })
|
||||
})
|
||||
|
||||
it('should return unknown when the release window is empty', () => {
|
||||
// Arrange
|
||||
const runtime = row({
|
||||
environment: { id: 'env-1' },
|
||||
status: 'ready',
|
||||
runtime: { runtimeInstanceId: 'rt-1' },
|
||||
currentRelease: { id: 'r-1' },
|
||||
})
|
||||
|
||||
// Act
|
||||
const result = computeDrift(runtime, [])
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual({ kind: 'unknown' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('latestReleaseId', () => {
|
||||
it('should return the first release id', () => {
|
||||
expect(latestReleaseId([release({ id: 'r-3' }), release({ id: 'r-2' })])).toBe('r-3')
|
||||
})
|
||||
|
||||
it('should return undefined when the list is empty', () => {
|
||||
expect(latestReleaseId([])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return undefined when the first release has no id', () => {
|
||||
expect(latestReleaseId([release({})])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeOverviewStats', () => {
|
||||
const releases = [release({ id: 'r-3' }), release({ id: 'r-2' }), release({ id: 'r-1' })]
|
||||
|
||||
it('should classify each row into a single bucket', () => {
|
||||
// Arrange
|
||||
const rows: EnvironmentDeployment[] = [
|
||||
row({ runtime: { runtimeInstanceId: 'rt-1' }, environment: { id: 'env-1' }, status: 'ready', currentRelease: { id: 'r-3' } }),
|
||||
row({ runtime: { runtimeInstanceId: 'rt-2' }, environment: { id: 'env-2' }, status: 'ready', currentRelease: { id: 'r-1' } }),
|
||||
row({ runtime: { runtimeInstanceId: 'rt-3' }, environment: { id: 'env-3' }, status: 'deploying', currentRelease: { id: 'r-3' } }),
|
||||
row({ runtime: { runtimeInstanceId: 'rt-4' }, environment: { id: 'env-4' }, status: 'deploy_failed', currentRelease: { id: 'r-2' } }),
|
||||
row({ environment: { id: 'env-5' }, status: 'undeployed' }),
|
||||
]
|
||||
|
||||
// Act
|
||||
const stats = computeOverviewStats(rows, releases)
|
||||
|
||||
// Assert
|
||||
expect(stats).toEqual({ total: 5, ready: 1, behind: 1, failed: 1, deploying: 1, undeployed: 1 })
|
||||
})
|
||||
|
||||
it('should not count failed envs as behind even when on an older release', () => {
|
||||
// Arrange
|
||||
const rows: EnvironmentDeployment[] = [
|
||||
row({ runtime: { runtimeInstanceId: 'rt-1' }, environment: { id: 'env-1' }, status: 'deploy_failed', currentRelease: { id: 'r-1' } }),
|
||||
]
|
||||
|
||||
// Act
|
||||
const stats = computeOverviewStats(rows, releases)
|
||||
|
||||
// Assert
|
||||
expect(stats.failed).toBe(1)
|
||||
expect(stats.behind).toBe(0)
|
||||
})
|
||||
|
||||
it('should return all zeros for an empty grid', () => {
|
||||
expect(computeOverviewStats([], releases)).toEqual({ total: 0, ready: 0, behind: 0, failed: 0, deploying: 0, undeployed: 0 })
|
||||
})
|
||||
})
|
||||
@ -1,177 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { environmentId, environmentName } from '../../environment'
|
||||
import { releaseCommit, releaseLabel } from '../../release'
|
||||
import { deploymentStatus } from '../../runtime-status'
|
||||
import { openDeployDrawerAtom } from '../../store'
|
||||
import { computeDrift, latestReleaseId } from './overview-drift'
|
||||
|
||||
type EnvironmentChipProps = {
|
||||
appInstanceId: string
|
||||
row: EnvironmentDeployment
|
||||
releaseRows: ReleaseRow[]
|
||||
}
|
||||
|
||||
type ChipKind = 'empty' | 'latest' | 'behind' | 'older' | 'deploying' | 'failed'
|
||||
|
||||
type ChipConfig = {
|
||||
kind: ChipKind
|
||||
dotClass: string
|
||||
suffixClass: string
|
||||
showRelease: boolean
|
||||
intent: 'drawer' | 'navigate' | 'disabled'
|
||||
releaseId?: string
|
||||
}
|
||||
|
||||
export function EnvironmentChip({ appInstanceId, row, releaseRows }: EnvironmentChipProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
|
||||
const router = useRouter()
|
||||
|
||||
const envId = environmentId(row.environment)
|
||||
const drift = computeDrift(row, releaseRows)
|
||||
const status = deploymentStatus(row)
|
||||
const latestId = latestReleaseId(releaseRows)
|
||||
const hasAnyRelease = releaseRows.length > 0
|
||||
const currentReleaseId = row.currentRelease?.id
|
||||
|
||||
const config = resolveConfig({ drift, status, hasAnyRelease, latestId, currentReleaseId })
|
||||
|
||||
const suffix = renderSuffix(config.kind, drift, t)
|
||||
const showRelease = config.showRelease && Boolean(row.currentRelease?.id)
|
||||
const isDisabled = config.intent === 'disabled'
|
||||
const tooltip = isDisabled ? t('overview.chip.needsReleaseFirst') : config.intent === 'navigate' ? t('overview.chip.openInDeployTab') : undefined
|
||||
|
||||
function handleClick() {
|
||||
if (config.intent === 'disabled')
|
||||
return
|
||||
if (config.intent === 'navigate') {
|
||||
router.push(`/deployments/${appInstanceId}/deploy`)
|
||||
return
|
||||
}
|
||||
openDeployDrawer({ appInstanceId, environmentId: envId, releaseId: config.releaseId })
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
title={tooltip}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'inline-flex max-w-[280px] items-center gap-2 rounded-full border px-3 py-1.5 system-xs-medium transition-colors',
|
||||
'border-divider-subtle bg-components-panel-bg text-text-secondary',
|
||||
'hover:bg-state-base-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-components-panel-bg',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', config.dotClass)} />
|
||||
<span className="truncate text-text-primary">{environmentName(row.environment)}</span>
|
||||
{showRelease && (
|
||||
<span className="flex shrink-0 items-baseline gap-1 font-mono text-text-tertiary">
|
||||
<span>{releaseLabel(row.currentRelease)}</span>
|
||||
<span className="system-2xs-regular">{releaseCommit(row.currentRelease)}</span>
|
||||
</span>
|
||||
)}
|
||||
<span aria-hidden className="text-text-quaternary">·</span>
|
||||
<span className={cn('shrink-0', config.suffixClass)}>{suffix}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function resolveConfig({ drift, status, hasAnyRelease, latestId, currentReleaseId }: {
|
||||
drift: ReturnType<typeof computeDrift>
|
||||
status: ReturnType<typeof deploymentStatus>
|
||||
hasAnyRelease: boolean
|
||||
latestId: string | undefined
|
||||
currentReleaseId: string | undefined
|
||||
}): ChipConfig {
|
||||
if (status === 'deploying') {
|
||||
return {
|
||||
kind: 'deploying',
|
||||
dotClass: 'bg-util-colors-blue-blue-500 animate-pulse',
|
||||
suffixClass: 'text-util-colors-blue-blue-700',
|
||||
showRelease: false,
|
||||
intent: 'navigate',
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'deploy_failed') {
|
||||
return {
|
||||
kind: 'failed',
|
||||
dotClass: 'bg-util-colors-red-red-500',
|
||||
suffixClass: 'text-util-colors-red-red-700',
|
||||
showRelease: true,
|
||||
intent: 'drawer',
|
||||
releaseId: currentReleaseId ?? latestId,
|
||||
}
|
||||
}
|
||||
|
||||
if (drift.kind === 'undeployed') {
|
||||
return {
|
||||
kind: 'empty',
|
||||
dotClass: 'bg-text-quaternary',
|
||||
suffixClass: 'text-text-quaternary',
|
||||
showRelease: false,
|
||||
intent: hasAnyRelease ? 'drawer' : 'disabled',
|
||||
releaseId: latestId,
|
||||
}
|
||||
}
|
||||
|
||||
if (drift.kind === 'up-to-date') {
|
||||
return {
|
||||
kind: 'latest',
|
||||
dotClass: 'bg-util-colors-green-green-500',
|
||||
suffixClass: 'text-util-colors-green-green-700',
|
||||
showRelease: true,
|
||||
intent: 'drawer',
|
||||
releaseId: currentReleaseId,
|
||||
}
|
||||
}
|
||||
|
||||
if (drift.kind === 'behind') {
|
||||
return {
|
||||
kind: 'behind',
|
||||
dotClass: 'bg-util-colors-green-green-500',
|
||||
suffixClass: 'text-util-colors-warning-warning-700',
|
||||
showRelease: true,
|
||||
intent: 'drawer',
|
||||
releaseId: latestId,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'older',
|
||||
dotClass: 'bg-util-colors-green-green-500',
|
||||
suffixClass: 'text-text-tertiary',
|
||||
showRelease: true,
|
||||
intent: 'drawer',
|
||||
releaseId: latestId,
|
||||
}
|
||||
}
|
||||
|
||||
function renderSuffix(
|
||||
kind: ChipKind,
|
||||
drift: ReturnType<typeof computeDrift>,
|
||||
t: ReturnType<typeof useTranslation<'deployments'>>['t'],
|
||||
): string {
|
||||
switch (kind) {
|
||||
case 'empty':
|
||||
return t('overview.chip.empty')
|
||||
case 'latest':
|
||||
return t('overview.chip.latest')
|
||||
case 'behind':
|
||||
return t('overview.chip.behind', { count: drift.kind === 'behind' ? drift.steps : 0 })
|
||||
case 'older':
|
||||
return t('overview.chip.olderRelease')
|
||||
case 'deploying':
|
||||
return t('overview.chip.deploying')
|
||||
case 'failed':
|
||||
return t('overview.chip.failed')
|
||||
}
|
||||
}
|
||||
@ -1,104 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { OverviewStats } from './overview-drift'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import Link from '@/next/link'
|
||||
import { environmentId } from '../../environment'
|
||||
import { SectionState } from '../common'
|
||||
import { EnvironmentChip } from './environment-chip'
|
||||
|
||||
type EnvironmentStripProps = {
|
||||
appInstanceId: string
|
||||
rows: EnvironmentDeployment[]
|
||||
releaseRows: ReleaseRow[]
|
||||
stats: OverviewStats
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
export function EnvironmentStrip({ appInstanceId, rows, releaseRows, stats, isLoading, isError }: EnvironmentStripProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const onLatest = stats.ready
|
||||
const showSummary = stats.total > 0
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<h3 className="system-sm-semibold text-text-primary">{t('overview.strip.title')}</h3>
|
||||
{showSummary && (
|
||||
<>
|
||||
<span aria-hidden className="text-text-quaternary">·</span>
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t('overview.strip.summary', { count: onLatest, total: stats.total })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats.failed > 0 && <FailureBanner appInstanceId={appInstanceId} failedCount={stats.failed} t={t} />}
|
||||
|
||||
{isLoading
|
||||
? <ChipSkeletons />
|
||||
: isError
|
||||
? <SectionState>{t('common.loadFailed')}</SectionState>
|
||||
: rows.length === 0
|
||||
? <SectionState>{t('overview.strip.empty')}</SectionState>
|
||||
: (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rows.map(row => (
|
||||
<EnvironmentChip
|
||||
key={environmentId(row.environment)}
|
||||
appInstanceId={appInstanceId}
|
||||
row={row}
|
||||
releaseRows={releaseRows}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FailureBanner({ appInstanceId, failedCount, t }: {
|
||||
appInstanceId: string
|
||||
failedCount: number
|
||||
t: ReturnType<typeof useTranslation<'deployments'>>['t']
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-util-colors-red-red-200 bg-util-colors-red-red-50 px-3 py-2 system-sm-regular text-util-colors-red-red-700">
|
||||
<span aria-hidden className="i-ri-error-warning-fill size-4 shrink-0" />
|
||||
<span className="min-w-0 grow truncate">
|
||||
{t('overview.strip.failedAlert', { count: failedCount })}
|
||||
</span>
|
||||
<Link
|
||||
href={`/deployments/${appInstanceId}/deploy`}
|
||||
className="shrink-0 system-xs-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
{t('overview.strip.investigate')}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SKELETON_KEYS = ['a', 'b', 'c', 'd']
|
||||
|
||||
function ChipSkeletons() {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SKELETON_KEYS.map(key => (
|
||||
<SkeletonRectangle key={key} className="my-0 h-7 w-32 animate-pulse rounded-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnvironmentStripSkeleton() {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<SkeletonRectangle className="my-0 h-3 w-44 animate-pulse" />
|
||||
<ChipSkeletons />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -1,64 +0,0 @@
|
||||
import type { EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
|
||||
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
|
||||
|
||||
export type Drift
|
||||
= | { kind: 'undeployed' }
|
||||
| { kind: 'unknown' }
|
||||
| { kind: 'up-to-date' }
|
||||
| { kind: 'behind', steps: number }
|
||||
|
||||
export function computeDrift(row: EnvironmentDeployment, releaseRows: ReleaseRow[]): Drift {
|
||||
if (isUndeployedDeploymentRow(row))
|
||||
return { kind: 'undeployed' }
|
||||
|
||||
const currentReleaseId = row.currentRelease?.id
|
||||
if (!currentReleaseId)
|
||||
return { kind: 'unknown' }
|
||||
|
||||
const idx = releaseRows.findIndex(release => release.id === currentReleaseId)
|
||||
if (idx === -1)
|
||||
return { kind: 'unknown' }
|
||||
if (idx === 0)
|
||||
return { kind: 'up-to-date' }
|
||||
return { kind: 'behind', steps: idx }
|
||||
}
|
||||
|
||||
export function latestReleaseId(releaseRows: ReleaseRow[]): string | undefined {
|
||||
return releaseRows[0]?.id || undefined
|
||||
}
|
||||
|
||||
export type OverviewStats = {
|
||||
total: number
|
||||
ready: number
|
||||
behind: number
|
||||
failed: number
|
||||
deploying: number
|
||||
undeployed: number
|
||||
}
|
||||
|
||||
export function computeOverviewStats(rows: EnvironmentDeployment[], releaseRows: ReleaseRow[]): OverviewStats {
|
||||
const stats: OverviewStats = { total: rows.length, ready: 0, behind: 0, failed: 0, deploying: 0, undeployed: 0 }
|
||||
for (const row of rows) {
|
||||
const drift = computeDrift(row, releaseRows)
|
||||
if (drift.kind === 'undeployed') {
|
||||
stats.undeployed += 1
|
||||
continue
|
||||
}
|
||||
const status = deploymentStatus(row)
|
||||
if (status === 'deploy_failed') {
|
||||
stats.failed += 1
|
||||
continue
|
||||
}
|
||||
if (status === 'deploying') {
|
||||
stats.deploying += 1
|
||||
continue
|
||||
}
|
||||
if (drift.kind === 'behind') {
|
||||
stats.behind += 1
|
||||
continue
|
||||
}
|
||||
if (status === 'ready')
|
||||
stats.ready += 1
|
||||
}
|
||||
return stats
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReleaseRow } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { OverviewStats } from './overview-drift'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { formatDate, releaseLabel } from '../../release'
|
||||
import { CreateReleaseControl } from '../versions-tab/create-release-control'
|
||||
|
||||
type ReleaseHeroProps = {
|
||||
appInstanceId: string
|
||||
latestRelease?: ReleaseRow
|
||||
stats: OverviewStats
|
||||
}
|
||||
|
||||
export function ReleaseHero({ appInstanceId, latestRelease, stats }: ReleaseHeroProps) {
|
||||
const { t, i18n } = useTranslation('deployments')
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
|
||||
const hasRelease = Boolean(latestRelease?.id)
|
||||
const author = latestRelease?.createdBy?.name ?? ''
|
||||
const ago = latestRelease?.createdAt ? formatTimeFromNow(new Date(latestRelease.createdAt).getTime()) : ''
|
||||
const deployedEnvironmentNames = Array.from(new Set(
|
||||
latestRelease?.deployedTo
|
||||
?.map(item => item.environmentName || item.environmentId)
|
||||
.filter((name): name is string => Boolean(name)) ?? [],
|
||||
))
|
||||
const deployedTargets = deployedEnvironmentNames.join(i18n.language.startsWith('zh') ? '、' : ', ')
|
||||
|
||||
const metaParts: { key: string, value: string }[] = []
|
||||
if (author)
|
||||
metaParts.push({ key: 'author', value: t('overview.hero.byName', { name: author }) })
|
||||
if (ago)
|
||||
metaParts.push({ key: 'ago', value: ago })
|
||||
if (deployedTargets)
|
||||
metaParts.push({ key: 'deployedTo', value: `${t('versions.col.deployedTo')} ${deployedTargets}` })
|
||||
else if (hasRelease && stats.total === 0)
|
||||
metaParts.push({ key: 'untargeted', value: t('overview.hero.untargeted') })
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg">
|
||||
<div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
{hasRelease
|
||||
? (
|
||||
<>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700"
|
||||
>
|
||||
<span className="i-ri-stack-fill size-5" />
|
||||
</span>
|
||||
<h2 className="truncate font-mono text-2xl font-semibold text-text-primary">
|
||||
{releaseLabel(latestRelease)}
|
||||
</h2>
|
||||
</div>
|
||||
{metaParts.length > 0 && (
|
||||
<p
|
||||
className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-1 system-sm-regular text-text-tertiary"
|
||||
title={latestRelease?.createdAt ? formatDate(latestRelease.createdAt) : undefined}
|
||||
>
|
||||
{metaParts.map((part, index) => (
|
||||
<span key={part.key} className="inline-flex items-baseline gap-1.5">
|
||||
{index > 0 && <span aria-hidden className="text-text-quaternary">·</span>}
|
||||
<span>{part.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<h2 className="system-xl-semibold text-text-primary">
|
||||
{t('overview.hero.empty')}
|
||||
</h2>
|
||||
<p className="max-w-[640px] system-sm-regular text-text-tertiary">
|
||||
{t('overview.hero.emptyDescription')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<CreateReleaseControl appInstanceId={appInstanceId} size="medium" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReleaseHeroSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-components-panel-border bg-components-panel-bg p-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-3">
|
||||
<SkeletonRectangle className="my-0 h-7 w-40 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-3 w-60 animate-pulse" />
|
||||
</div>
|
||||
<SkeletonRectangle className="my-0 h-9 w-32 animate-pulse rounded-lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,414 +0,0 @@
|
||||
'use client'
|
||||
import type { AppInstanceBasicInfo, GetAppInstanceSettingsReply } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { isUndeployedDeploymentRow } from '../runtime-status'
|
||||
import { Section, SectionState } from './common'
|
||||
import { AccessChannelsSection } from './settings-tab/access/channels-section'
|
||||
import { DeveloperApiSection } from './settings-tab/access/developer-api-section'
|
||||
import { AccessPermissionsSection } from './settings-tab/access/permissions-section'
|
||||
|
||||
type AppInstanceWithId = AppInstanceBasicInfo & { id: string }
|
||||
|
||||
const SETTINGS_FORM_SKELETON_FIELDS = [
|
||||
{ key: 'name', inputClassName: 'my-0 h-8 w-full animate-pulse rounded-lg' },
|
||||
{ key: 'description', inputClassName: 'my-0 h-24 w-full animate-pulse rounded-lg' },
|
||||
]
|
||||
|
||||
function SettingsFormSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{SETTINGS_FORM_SKELETON_FIELDS.map(field => (
|
||||
<div key={field.key} className="flex flex-col gap-2">
|
||||
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
|
||||
<SkeletonRectangle className={field.inputClassName} />
|
||||
</div>
|
||||
))}
|
||||
<SkeletonRow className="justify-end gap-2">
|
||||
<SkeletonRectangle className="my-0 h-8 w-18 animate-pulse rounded-lg" />
|
||||
<SkeletonRectangle className="my-0 h-8 w-16 animate-pulse rounded-lg" />
|
||||
</SkeletonRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteInstanceSkeleton() {
|
||||
return (
|
||||
<div className="flex min-h-9 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<SkeletonRectangle className="h-3 w-3/5 animate-pulse" />
|
||||
<SkeletonRow className="items-center justify-between gap-2">
|
||||
<SkeletonRectangle className="h-3 w-48 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-8 w-18 animate-pulse rounded-lg" />
|
||||
</SkeletonRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type DeleteInstanceControlProps = {
|
||||
app: AppInstanceWithId
|
||||
settings?: GetAppInstanceSettingsReply
|
||||
hasDeployments: boolean
|
||||
}
|
||||
|
||||
function DeleteInstanceButton({
|
||||
app,
|
||||
settings,
|
||||
hasDeployments,
|
||||
}: DeleteInstanceControlProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const router = useRouter()
|
||||
const deleteInstance = useMutation(consoleQuery.enterprise.appInstanceService.deleteAppInstance.mutationOptions())
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const appInstanceId = app.id
|
||||
const appName = app.name ?? appInstanceId
|
||||
const canDelete = !hasDeployments && Boolean(settings)
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteInstance.mutate(
|
||||
{
|
||||
params: {
|
||||
appInstanceId,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.deleted'))
|
||||
router.push('/deployments')
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.deleteFailed'))
|
||||
},
|
||||
onSettled: () => {
|
||||
setShowDeleteConfirm(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
tone="destructive"
|
||||
disabled={!canDelete || deleteInstance.isPending}
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
{t('settings.delete')}
|
||||
</Button>
|
||||
|
||||
<AlertDialog open={showDeleteConfirm} onOpenChange={open => !open && setShowDeleteConfirm(false)}>
|
||||
<AlertDialogContent className="w-130">
|
||||
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
|
||||
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t('settings.deleteConfirmTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="system-md-regular text-text-tertiary">
|
||||
{t('settings.deleteConfirmDesc', { name: appName })}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions>
|
||||
<AlertDialogCancelButton variant="secondary">
|
||||
{t('createModal.cancel')}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={handleDelete}>
|
||||
{t('settings.delete')}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteInstanceControl({
|
||||
app,
|
||||
settings,
|
||||
hasDeployments,
|
||||
}: DeleteInstanceControlProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<div className="flex min-h-9 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="system-xs-regular text-text-secondary">
|
||||
{hasDeployments
|
||||
? t('settings.undeployFirst')
|
||||
: t('settings.safeToDelete')}
|
||||
</div>
|
||||
<DeleteInstanceButton
|
||||
app={app}
|
||||
settings={settings}
|
||||
hasDeployments={hasDeployments}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DangerSection({ children }: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<section className="border-b border-divider-subtle py-4 first:pt-0 last:border-b-0 last:pb-0">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:gap-x-6">
|
||||
<div className="flex min-w-0 shrink-0 flex-col sm:w-40 sm:pt-1">
|
||||
<div className="system-sm-semibold text-util-colors-red-red-700">
|
||||
{t('settings.danger')}
|
||||
</div>
|
||||
<p className="mt-1 body-xs-regular text-util-colors-red-red-600">
|
||||
{t('settings.dangerDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0 grow">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsForm({ app, settings }: {
|
||||
app: AppInstanceWithId
|
||||
settings?: GetAppInstanceSettingsReply
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const updateInstance = useMutation(consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions())
|
||||
const appName = app.name ?? app.id
|
||||
const [name, setName] = useState(settings?.name ?? appName)
|
||||
const [description, setDescription] = useState(settings?.description ?? app.description ?? '')
|
||||
const initialName = settings?.name ?? appName
|
||||
const initialDescription = settings?.description ?? app.description ?? ''
|
||||
const canSave = Boolean(name.trim() && (name !== initialName || description !== initialDescription) && !updateInstance.isPending)
|
||||
|
||||
const handleSave = () => {
|
||||
const appInstanceId = app.id
|
||||
if (!canSave)
|
||||
return
|
||||
updateInstance.mutate(
|
||||
{
|
||||
params: {
|
||||
appInstanceId,
|
||||
},
|
||||
body: {
|
||||
appInstanceId,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.updated'))
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.updateFailed'))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t('settings.general')}
|
||||
description={t('settings.descriptionHelp')}
|
||||
layout="row"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="settings-name">
|
||||
{t('settings.name')}
|
||||
</label>
|
||||
<Input
|
||||
id="settings-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="settings-desc">
|
||||
{t('settings.description')}
|
||||
</label>
|
||||
<Textarea
|
||||
id="settings-desc"
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="min-h-24"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={updateInstance.isPending || (name === initialName && description === initialDescription)}
|
||||
onClick={() => {
|
||||
setName(initialName)
|
||||
setDescription(initialDescription)
|
||||
}}
|
||||
>
|
||||
{t('settings.reset')}
|
||||
</Button>
|
||||
<Button variant="primary" disabled={!canSave} onClick={handleSave}>
|
||||
{t('settings.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsFormSection({ appInstanceId }: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInput = { params: { appInstanceId } }
|
||||
const overviewQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
|
||||
input: appInput,
|
||||
}))
|
||||
const overview = overviewQuery.data
|
||||
const app = overview?.overview?.appInstance
|
||||
const settingsQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceSettings.queryOptions({
|
||||
input: appInput,
|
||||
}))
|
||||
|
||||
if (overviewQuery.isLoading || settingsQuery.isLoading) {
|
||||
return (
|
||||
<Section
|
||||
title={t('settings.general')}
|
||||
description={t('settings.descriptionHelp')}
|
||||
layout="row"
|
||||
>
|
||||
<SettingsFormSkeleton />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
if (overviewQuery.isError || settingsQuery.isError) {
|
||||
return (
|
||||
<Section
|
||||
title={t('settings.general')}
|
||||
description={t('settings.descriptionHelp')}
|
||||
layout="row"
|
||||
>
|
||||
<SectionState>{t('common.loadFailed')}</SectionState>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
if (!app?.id) {
|
||||
return (
|
||||
<Section
|
||||
title={t('settings.general')}
|
||||
description={t('settings.descriptionHelp')}
|
||||
layout="row"
|
||||
>
|
||||
<SectionState>{t('detail.notFound')}</SectionState>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
const appName = app.name ?? app.id
|
||||
const formKey = `${app.id}-${settingsQuery.data?.name ?? appName}-${settingsQuery.data?.description ?? app.description ?? ''}`
|
||||
const appWithId = {
|
||||
...app,
|
||||
id: app.id,
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsForm
|
||||
key={formKey}
|
||||
app={appWithId}
|
||||
settings={settingsQuery.data}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteInstanceControlSection({ appInstanceId }: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const appInput = { params: { appInstanceId } }
|
||||
const overviewQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
|
||||
input: appInput,
|
||||
}))
|
||||
const overview = overviewQuery.data
|
||||
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({
|
||||
input: appInput,
|
||||
}))
|
||||
const settingsQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceSettings.queryOptions({
|
||||
input: appInput,
|
||||
}))
|
||||
const environmentDeployments = environmentDeploymentsQuery.data
|
||||
const app = overview?.overview?.appInstance
|
||||
|
||||
if (overviewQuery.isLoading || environmentDeploymentsQuery.isLoading || settingsQuery.isLoading) {
|
||||
return (
|
||||
<DangerSection>
|
||||
<DeleteInstanceSkeleton />
|
||||
</DangerSection>
|
||||
)
|
||||
}
|
||||
|
||||
if (overviewQuery.isError || environmentDeploymentsQuery.isError || settingsQuery.isError) {
|
||||
return (
|
||||
<DangerSection>
|
||||
<SectionState>{t('common.loadFailed')}</SectionState>
|
||||
</DangerSection>
|
||||
)
|
||||
}
|
||||
|
||||
if (!app?.id) {
|
||||
return (
|
||||
<DangerSection>
|
||||
<SectionState>{t('detail.notFound')}</SectionState>
|
||||
</DangerSection>
|
||||
)
|
||||
}
|
||||
|
||||
const hasDeployments = environmentDeployments?.data?.some(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? false
|
||||
const appWithId = {
|
||||
...app,
|
||||
id: app.id,
|
||||
}
|
||||
|
||||
return (
|
||||
<DangerSection>
|
||||
<DeleteInstanceControl
|
||||
app={appWithId}
|
||||
settings={settingsQuery.data}
|
||||
hasDeployments={hasDeployments}
|
||||
/>
|
||||
</DangerSection>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsTab({ appInstanceId }: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1080px] min-w-0 flex-col gap-y-4 px-6 py-6 sm:py-8">
|
||||
<AccessPermissionsSection appInstanceId={appInstanceId} />
|
||||
<AccessChannelsSection appInstanceId={appInstanceId} />
|
||||
<DeveloperApiSection appInstanceId={appInstanceId} />
|
||||
<SettingsFormSection appInstanceId={appInstanceId} />
|
||||
<DeleteInstanceControlSection appInstanceId={appInstanceId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,160 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AppDeployEnvironment, DeveloperApiKeyRow } from '@dify/contracts/enterprise/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { environmentName } from '../../../environment'
|
||||
|
||||
function ApiKeyRow({ appInstanceId, apiKey }: {
|
||||
appInstanceId: string
|
||||
apiKey: DeveloperApiKeyRow
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const revokeApiKey = useMutation(consoleQuery.enterprise.appDeployAccessService.deleteDeveloperApiKey.mutationOptions())
|
||||
const displayValue = apiKey.maskedKey || apiKey.id || '—'
|
||||
const environmentLabel = environmentName(apiKey.environment)
|
||||
|
||||
function handleRevoke() {
|
||||
const environmentId = apiKey.environment?.id
|
||||
if (!apiKey.id || !environmentId)
|
||||
return
|
||||
|
||||
revokeApiKey.mutate({
|
||||
params: {
|
||||
appInstanceId,
|
||||
environmentId,
|
||||
apiKeyId: apiKey.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className="flex min-w-35 flex-col">
|
||||
<span className="system-sm-medium text-text-primary">{apiKey.name || apiKey.id}</span>
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t('access.api.envPrefix', { env: environmentLabel })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 rounded-lg border border-components-input-border-active bg-components-input-bg-normal pr-1 pl-2">
|
||||
<div className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary">
|
||||
{displayValue}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRevoke}
|
||||
aria-label={t('access.revoke')}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive"
|
||||
>
|
||||
<span className="i-ri-delete-bin-line size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApiKeyList({ appInstanceId, apiKeys }: {
|
||||
appInstanceId: string
|
||||
apiKeys: DeveloperApiKeyRow[]
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col divide-y divide-divider-subtle">
|
||||
{apiKeys.map(apiKey => (
|
||||
<ApiKeyRow
|
||||
key={apiKey.id}
|
||||
appInstanceId={appInstanceId}
|
||||
apiKey={apiKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ApiKeyGenerateMenu({ appInstanceId, environments, apiKeys, onCreatedToken }: {
|
||||
appInstanceId: string
|
||||
environments: AppDeployEnvironment[]
|
||||
apiKeys: DeveloperApiKeyRow[]
|
||||
onCreatedToken: (token: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [open, setOpen] = useState(false)
|
||||
const generateApiKey = useMutation(consoleQuery.enterprise.appDeployAccessService.createDeveloperApiKey.mutationOptions())
|
||||
const selectableEnvironments = environments.filter(env => env.id)
|
||||
const disabled = selectableEnvironments.length === 0
|
||||
|
||||
function createApiKeyLabel(environmentId: string) {
|
||||
const existingCount = apiKeys.filter(key =>
|
||||
key.environment?.id === environmentId,
|
||||
).length
|
||||
const name = environments.find(env => env.id === environmentId)?.name ?? 'env'
|
||||
|
||||
return `${name}-key-${String(existingCount + 1).padStart(3, '0')}`
|
||||
}
|
||||
|
||||
function handleGenerateApiKey(environmentId: string) {
|
||||
generateApiKey.mutate(
|
||||
{
|
||||
params: {
|
||||
appInstanceId,
|
||||
environmentId,
|
||||
},
|
||||
body: {
|
||||
appInstanceId,
|
||||
environmentId,
|
||||
name: createApiKeyLabel(environmentId),
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
if (response.token)
|
||||
onCreatedToken(response.token)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center gap-1.5 rounded-lg px-3 system-sm-medium',
|
||||
'border border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-text',
|
||||
'hover:bg-components-button-secondary-bg-hover',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<span className="i-ri-add-line size-3.5" />
|
||||
{t('access.api.newKey')}
|
||||
<span className="i-ri-arrow-down-s-line size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
{open && !disabled && (
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-55">
|
||||
{selectableEnvironments.map(env => (
|
||||
<DropdownMenuItem
|
||||
key={env.id}
|
||||
className="gap-2 px-3"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
handleGenerateApiKey(env.id!)
|
||||
}}
|
||||
>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{t('access.api.newKeyForEnv', { env: environmentName(env) })}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@ -1,198 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { environmentName } from '../../../environment'
|
||||
import { webappUrl } from '../../../webapp-url'
|
||||
import { Section, SectionState } from '../../common'
|
||||
import { CopyPill, EndpointRow } from './common'
|
||||
import { getUrlOrigin } from './url'
|
||||
|
||||
const ACCESS_CHANNEL_SKELETON_SECTIONS = [
|
||||
{ key: 'webapp', className: 'flex flex-col gap-2' },
|
||||
{ key: 'cli', className: 'flex flex-col gap-2 border-t border-divider-subtle pt-3' },
|
||||
]
|
||||
|
||||
function AccessChannelsSwitch({ appInstanceId, checked, disabled }: {
|
||||
appInstanceId: string
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const toggleAccessChannel = useMutation(consoleQuery.enterprise.appDeployAccessService.updateAccessChannels.mutationOptions())
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(enabled) => {
|
||||
toggleAccessChannel.mutate({
|
||||
params: { appInstanceId },
|
||||
body: { appInstanceId, enabled },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccessChannelsSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
{ACCESS_CHANNEL_SKELETON_SECTIONS.map(section => (
|
||||
<div
|
||||
key={section.key}
|
||||
className={section.className}
|
||||
>
|
||||
<SkeletonRow className="items-center gap-2">
|
||||
<SkeletonRectangle className="h-3.5 w-24 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-5 w-24 animate-pulse rounded-full" />
|
||||
</SkeletonRow>
|
||||
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
|
||||
<SkeletonRow className="flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<SkeletonRectangle className="h-3 w-35 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-8 min-w-65 flex-1 animate-pulse rounded-lg" />
|
||||
<SkeletonRectangle className="my-0 h-8 w-24 animate-pulse rounded-lg" />
|
||||
</SkeletonRow>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccessChannelsSection({
|
||||
appInstanceId,
|
||||
}: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const accessConfigQuery = useQuery(consoleQuery.enterprise.appDeployAccessService.getAppInstanceAccess.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
},
|
||||
}))
|
||||
const accessConfig = accessConfigQuery.data
|
||||
const runEnabled = accessConfig?.accessChannels?.enabled ?? false
|
||||
const webappRows = accessConfig?.accessChannels?.webappRows?.filter(row => row.url) ?? []
|
||||
const cliDomain = getUrlOrigin(accessConfig?.accessChannels?.cli?.url)
|
||||
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t('access.channels.title')}
|
||||
description={t('access.channels.description')}
|
||||
layout="row"
|
||||
action={(
|
||||
accessConfigQuery.isLoading
|
||||
? <SwitchSkeleton />
|
||||
: (
|
||||
<AccessChannelsSwitch
|
||||
appInstanceId={appInstanceId}
|
||||
checked={runEnabled}
|
||||
disabled={accessConfigQuery.isError}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
>
|
||||
{accessConfigQuery.isLoading
|
||||
? <AccessChannelsSkeleton />
|
||||
: accessConfigQuery.isError
|
||||
? <SectionState>{t('common.loadFailed')}</SectionState>
|
||||
: runEnabled
|
||||
? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="system-sm-medium text-text-primary">
|
||||
{t('access.runAccess.webapp')}
|
||||
</div>
|
||||
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
|
||||
{t('access.channels.followPermission')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('access.runAccess.webappDesc')}
|
||||
</div>
|
||||
{webappRows.length > 0
|
||||
? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{webappRows.map((row) => {
|
||||
const endpointUrl = webappUrl(row.url)
|
||||
|
||||
return (
|
||||
<EndpointRow
|
||||
key={`webapp-${row.environment?.id ?? row.url}`}
|
||||
envName={environmentName(row.environment)}
|
||||
label={t('access.runAccess.urlLabel')}
|
||||
value={endpointUrl}
|
||||
openLabel={t('access.runAccess.openWebapp')}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<SectionState>
|
||||
{t('access.runAccess.webappEmpty')}
|
||||
</SectionState>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 border-t border-divider-subtle pt-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="system-sm-medium text-text-primary">
|
||||
{t('access.cli.title')}
|
||||
</div>
|
||||
<span className="inline-flex h-5 items-center rounded-full bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
|
||||
{t('access.channels.followPermission')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('access.cli.description')}
|
||||
</div>
|
||||
{cliDomain
|
||||
? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CopyPill
|
||||
label={t('access.cli.domain')}
|
||||
value={cliDomain}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<a
|
||||
href={cliDocsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
||||
>
|
||||
<span className="i-ri-download-cloud-2-line size-3.5" />
|
||||
{t('access.cli.install')}
|
||||
</a>
|
||||
<a
|
||||
href={cliDocsUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
||||
>
|
||||
<span className="i-ri-book-open-line size-3.5" />
|
||||
{t('access.cli.docs')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('access.cli.empty')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('access.channels.disabled')}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useClipboard } from '@/hooks/use-clipboard'
|
||||
|
||||
type CopyPillProps = {
|
||||
label: string
|
||||
value: string
|
||||
prefix?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CopyPill({ label, value, prefix, className }: CopyPillProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const { copied, copy } = useClipboard({
|
||||
onCopyError: () => {
|
||||
toast.error(t('access.copyFailed'))
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 items-center gap-1 rounded-lg border border-components-input-border-active bg-components-input-bg-normal pr-1 pl-1.5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-5 shrink-0 items-center rounded-md border border-divider-subtle px-1.5 system-2xs-medium text-text-tertiary">
|
||||
{label}
|
||||
</div>
|
||||
{prefix}
|
||||
<div className="min-w-0 flex-1 truncate px-1 font-mono system-sm-medium text-text-secondary">
|
||||
{value}
|
||||
</div>
|
||||
<div className="h-3.5 w-px shrink-0 bg-divider-regular" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(value)}
|
||||
aria-label={t('access.copy')}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
>
|
||||
<span className={cn(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'size-3.5')} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type EndpointRowProps = {
|
||||
envName: string
|
||||
label: string
|
||||
value: string
|
||||
openLabel?: string
|
||||
}
|
||||
|
||||
export function EndpointRow({ envName, label, value, openLabel }: EndpointRowProps) {
|
||||
return (
|
||||
<div className="grid items-center gap-x-3 gap-y-1.5 sm:grid-cols-[minmax(88px,108px)_minmax(0,1fr)_auto]">
|
||||
<span className="min-w-0 truncate system-xs-regular text-text-tertiary">
|
||||
{envName}
|
||||
</span>
|
||||
<CopyPill label={label} value={value} className="min-w-0" />
|
||||
{openLabel && (
|
||||
<a
|
||||
href={value}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
|
||||
>
|
||||
<span className="i-ri-external-link-line size-3.5" />
|
||||
{openLabel}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,207 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
AppDeployEnvironment,
|
||||
EnvironmentAccessRow,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { Section, SectionState } from '../../common'
|
||||
import { ApiKeyGenerateMenu, ApiKeyList } from './api-keys'
|
||||
import { CopyPill } from './common'
|
||||
|
||||
type CreatedApiToken = {
|
||||
appInstanceId: string
|
||||
token: string
|
||||
}
|
||||
|
||||
const DEVELOPER_API_KEY_SKELETON_KEYS = ['primary-key', 'secondary-key']
|
||||
|
||||
function permissionEnvironment(row: EnvironmentAccessRow): AppDeployEnvironment | undefined {
|
||||
return row.environment?.id ? row.environment : undefined
|
||||
}
|
||||
|
||||
function DeveloperApiSwitch({ appInstanceId, checked, disabled }: {
|
||||
appInstanceId: string
|
||||
checked: boolean
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.appDeployAccessService.updateDeveloperApi.mutationOptions())
|
||||
|
||||
return (
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(enabled) => {
|
||||
toggleDeveloperAPI.mutate({
|
||||
params: { appInstanceId },
|
||||
body: { appInstanceId, enabled },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CreatedApiTokenCard({ token, onDismiss }: {
|
||||
token: string
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-y border-divider-subtle bg-background-default-subtle px-3 py-2.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="system-sm-medium text-text-primary">
|
||||
{t('access.api.newTokenTitle')}
|
||||
</span>
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t('access.api.newTokenDescription')}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label={t('access.api.dismissToken')}
|
||||
className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
>
|
||||
<span className="i-ri-close-line size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<CopyPill
|
||||
label={t('access.api.newTokenLabel')}
|
||||
value={token}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeveloperApiSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
|
||||
<SkeletonRow className="items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<SkeletonRectangle className="h-3.5 w-28 animate-pulse" />
|
||||
<SkeletonRectangle className="h-3 w-40 animate-pulse" />
|
||||
</div>
|
||||
<SkeletonRectangle className="my-0 h-8 w-24 animate-pulse rounded-lg" />
|
||||
</SkeletonRow>
|
||||
<div className="flex flex-col divide-y divide-divider-subtle">
|
||||
{DEVELOPER_API_KEY_SKELETON_KEYS.map(key => (
|
||||
<SkeletonRow key={key} className="items-center gap-3 py-1.5">
|
||||
<div className="flex min-w-35 flex-col gap-1.5">
|
||||
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
|
||||
<SkeletonRectangle className="h-2.5 w-18 animate-pulse" />
|
||||
</div>
|
||||
<SkeletonRectangle className="my-0 h-8 min-w-0 flex-1 animate-pulse rounded-lg" />
|
||||
</SkeletonRow>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeveloperApiSection({
|
||||
appInstanceId,
|
||||
}: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [createdApiToken, setCreatedApiToken] = useState<CreatedApiToken>()
|
||||
const accessConfigQuery = useQuery(consoleQuery.enterprise.appDeployAccessService.getAppInstanceAccess.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
},
|
||||
}))
|
||||
const accessConfig = accessConfigQuery.data
|
||||
const apiEnabled = accessConfig?.developerApi?.enabled ?? false
|
||||
const apiUrl = accessConfig?.developerApi?.apiUrl
|
||||
const apiKeys = accessConfig?.developerApi?.apiKeys ?? []
|
||||
const environments = accessConfig?.permissions
|
||||
?.map(permissionEnvironment)
|
||||
.filter((environment): environment is AppDeployEnvironment => Boolean(environment)) ?? []
|
||||
const visibleCreatedApiToken = createdApiToken?.appInstanceId === appInstanceId
|
||||
? createdApiToken.token
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t('access.api.developerTitle')}
|
||||
description={t('access.api.description')}
|
||||
layout="row"
|
||||
action={(
|
||||
accessConfigQuery.isLoading
|
||||
? <SwitchSkeleton />
|
||||
: (
|
||||
<DeveloperApiSwitch
|
||||
appInstanceId={appInstanceId}
|
||||
checked={apiEnabled}
|
||||
disabled={accessConfigQuery.isError}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
>
|
||||
{accessConfigQuery.isLoading
|
||||
? <DeveloperApiSkeleton />
|
||||
: accessConfigQuery.isError
|
||||
? <SectionState>{t('common.loadFailed')}</SectionState>
|
||||
: apiEnabled
|
||||
? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{apiUrl && (
|
||||
<CopyPill
|
||||
label={t('access.api.endpoint')}
|
||||
value={apiUrl}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="system-sm-medium text-text-primary">
|
||||
{t('access.api.backendTitle')}
|
||||
</span>
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t('access.api.keyList')}
|
||||
</span>
|
||||
</div>
|
||||
<ApiKeyGenerateMenu
|
||||
appInstanceId={appInstanceId}
|
||||
environments={environments}
|
||||
apiKeys={apiKeys}
|
||||
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
|
||||
/>
|
||||
</div>
|
||||
{visibleCreatedApiToken && (
|
||||
<CreatedApiTokenCard
|
||||
token={visibleCreatedApiToken}
|
||||
onDismiss={() => setCreatedApiToken(undefined)}
|
||||
/>
|
||||
)}
|
||||
{apiKeys.length === 0
|
||||
? (
|
||||
<SectionState>
|
||||
{environments.length === 0
|
||||
? t('access.api.empty')
|
||||
: t('access.api.noKeys')}
|
||||
</SectionState>
|
||||
)
|
||||
: (
|
||||
<ApiKeyList
|
||||
appInstanceId={appInstanceId}
|
||||
apiKeys={apiKeys}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('access.api.disabled')}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@ -1,78 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
EnvironmentAccessRow,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { Section, SectionState } from '../../common'
|
||||
import { EnvironmentPermissionRow } from './permissions'
|
||||
|
||||
const ACCESS_PERMISSIONS_SKELETON_KEYS = ['production', 'staging', 'development']
|
||||
|
||||
function hasEnvironment(row: EnvironmentAccessRow): row is EnvironmentAccessRow & {
|
||||
environment: NonNullable<EnvironmentAccessRow['environment']>
|
||||
} {
|
||||
return Boolean(row.environment?.id)
|
||||
}
|
||||
|
||||
function AccessPermissionsSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{ACCESS_PERMISSIONS_SKELETON_KEYS.map(key => (
|
||||
<SkeletonRow key={key} className="flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||
<SkeletonRectangle className="h-3 w-35 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-8 w-55 animate-pulse rounded-lg" />
|
||||
</SkeletonRow>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AccessPermissionsSection({
|
||||
appInstanceId,
|
||||
}: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const accessConfigQuery = useQuery(consoleQuery.enterprise.appDeployAccessService.getAppInstanceAccess.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
},
|
||||
}))
|
||||
const accessConfig = accessConfigQuery.data
|
||||
const permissionRows = accessConfig?.permissions?.filter(hasEnvironment) ?? []
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t('access.permissions.title')}
|
||||
description={t('access.permissions.description')}
|
||||
layout="row"
|
||||
>
|
||||
{accessConfigQuery.isLoading
|
||||
? <AccessPermissionsSkeleton />
|
||||
: accessConfigQuery.isError
|
||||
? <SectionState>{t('common.loadFailed')}</SectionState>
|
||||
: permissionRows.length === 0
|
||||
? (
|
||||
<SectionState>
|
||||
{t('access.runAccess.noEnvs')}
|
||||
</SectionState>
|
||||
)
|
||||
: (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{permissionRows.map(row => (
|
||||
<EnvironmentPermissionRow
|
||||
key={row.environment.id}
|
||||
appInstanceId={appInstanceId}
|
||||
environment={row.environment}
|
||||
summaryPolicy={row}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@ -1,442 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type {
|
||||
AccessSubject,
|
||||
AppDeployEnvironment,
|
||||
EnvironmentAccessRow,
|
||||
Subject,
|
||||
} from '@dify/contracts/enterprise/types.gen'
|
||||
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChipRemove,
|
||||
ComboboxChips,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxInputGroup,
|
||||
ComboboxInputTrigger,
|
||||
ComboboxItem,
|
||||
ComboboxItemIndicator,
|
||||
ComboboxItemText,
|
||||
ComboboxList,
|
||||
ComboboxStatus,
|
||||
ComboboxValue,
|
||||
} from '@langgenius/dify-ui/combobox'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { environmentName } from '../../../environment'
|
||||
|
||||
type AccessPermissionKind = 'organization' | 'specific' | 'anyone'
|
||||
|
||||
function accessModeToPermissionKey(mode?: string): AccessPermissionKind {
|
||||
const normalized = mode?.toLowerCase() ?? ''
|
||||
if (normalized === 'private')
|
||||
return 'specific'
|
||||
if (normalized === 'public')
|
||||
return 'anyone'
|
||||
return 'organization'
|
||||
}
|
||||
|
||||
function permissionKeyToAccessMode(key: AccessPermissionKind) {
|
||||
if (key === 'organization')
|
||||
return 'private_all'
|
||||
if (key === 'specific')
|
||||
return 'private'
|
||||
return 'public'
|
||||
}
|
||||
|
||||
const permissionIcon: Record<AccessPermissionKind, string> = {
|
||||
organization: 'i-ri-team-line',
|
||||
specific: 'i-ri-lock-line',
|
||||
anyone: 'i-ri-global-line',
|
||||
}
|
||||
|
||||
const permissionOrder: AccessPermissionKind[] = ['organization', 'specific', 'anyone']
|
||||
|
||||
function PermissionPicker({ value, disabled, onChange }: {
|
||||
value: AccessPermissionKind
|
||||
disabled?: boolean
|
||||
onChange: (kind: AccessPermissionKind) => void
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const icon = permissionIcon[value]
|
||||
const label = t(`access.permission.${value}`)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'inline-flex h-8 w-full min-w-0 items-center gap-2 rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-2.5 system-sm-regular text-text-secondary hover:bg-state-base-hover',
|
||||
disabled && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<span className={cn(icon, 'size-4 shrink-0 text-text-tertiary')} />
|
||||
<span className="flex-1 truncate text-left">{label}</span>
|
||||
<span className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-start" popupClassName="w-85 p-1">
|
||||
{permissionOrder.map((kind) => {
|
||||
const itemIcon = permissionIcon[kind]
|
||||
const isSelected = kind === value
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={kind}
|
||||
onClick={() => onChange(kind)}
|
||||
className="mx-0 h-auto items-start gap-3 rounded-lg px-2.5 py-2"
|
||||
>
|
||||
<span className={cn(itemIcon, 'mt-0.5 size-4 shrink-0 text-text-tertiary')} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate system-sm-medium text-text-primary">
|
||||
{t(`access.permission.${kind}`)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="system-xs-regular text-text-tertiary">
|
||||
{t(`access.permission.${kind}Desc`)}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<span className="mt-0.5 i-ri-check-line size-4 shrink-0 text-text-accent" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
type SelectableAccessSubject = {
|
||||
id: string
|
||||
subjectType: string
|
||||
name?: string
|
||||
memberCount?: number
|
||||
}
|
||||
|
||||
function normalizeSubject(subject: Subject): SelectableAccessSubject | undefined {
|
||||
const id = subject.subjectId || subject.accountData?.id || subject.groupData?.id
|
||||
const subjectType = subject.subjectType || (subject.groupData ? 'group' : 'account')
|
||||
if (!id || !subjectType)
|
||||
return undefined
|
||||
|
||||
return {
|
||||
id,
|
||||
subjectType,
|
||||
name: subject.accountData?.name || subject.accountData?.email || subject.groupData?.name || id,
|
||||
memberCount: subject.groupData?.groupSize,
|
||||
}
|
||||
}
|
||||
|
||||
function subjectKey(subject: Pick<SelectableAccessSubject, 'id' | 'subjectType'>) {
|
||||
return `${subject.subjectType}:${subject.id}`
|
||||
}
|
||||
|
||||
function getSubjectLabel(subject: SelectableAccessSubject) {
|
||||
return subject.name || subject.id
|
||||
}
|
||||
|
||||
function getSubjectValue(subject: SelectableAccessSubject) {
|
||||
return subjectKey(subject)
|
||||
}
|
||||
|
||||
function isSameSubject(item: SelectableAccessSubject, value: SelectableAccessSubject) {
|
||||
return item.id === value.id && item.subjectType === value.subjectType
|
||||
}
|
||||
|
||||
const SUBJECT_PICKER_SKELETON_KEYS = ['first-subject', 'second-subject', 'third-subject']
|
||||
|
||||
function policySubjects(subjects: SelectableAccessSubject[]): AccessSubject[] {
|
||||
return subjects.map(subject => ({
|
||||
subjectId: subject.id,
|
||||
subjectType: subject.subjectType,
|
||||
}))
|
||||
}
|
||||
|
||||
function selectedSubjectsFromPolicy(policy?: EnvironmentAccessRow) {
|
||||
return policy?.subjects
|
||||
?.map(normalizeSubject)
|
||||
.filter((subject): subject is SelectableAccessSubject => Boolean(subject)) ?? []
|
||||
}
|
||||
|
||||
function SubjectIcon({ subject }: {
|
||||
subject: SelectableAccessSubject
|
||||
}) {
|
||||
const isGroup = subject.subjectType === 'group'
|
||||
|
||||
return (
|
||||
<span className={cn(isGroup ? 'i-ri-group-line' : 'i-ri-user-line', 'size-3.5 shrink-0 text-text-tertiary')} aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
|
||||
type SubjectPickerProps = {
|
||||
disabled?: boolean
|
||||
selectedSubjects: SelectableAccessSubject[]
|
||||
onChange: (subjects: SelectableAccessSubject[]) => void
|
||||
}
|
||||
|
||||
function SubjectPicker({
|
||||
disabled,
|
||||
selectedSubjects,
|
||||
onChange,
|
||||
}: SubjectPickerProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const debouncedKeyword = useDebounce(keyword, { wait: 300 })
|
||||
const subjectsQuery = useQuery(consoleQuery.enterprise.accessSubjectService.listAccessSubjects.queryOptions({
|
||||
input: {
|
||||
query: {
|
||||
keyword: debouncedKeyword.trim() || undefined,
|
||||
pageNumber: 1,
|
||||
resultsPerPage: 50,
|
||||
},
|
||||
},
|
||||
enabled: open,
|
||||
}))
|
||||
const subjects = subjectsQuery.data?.subjects
|
||||
?.map(normalizeSubject)
|
||||
.filter((subject): subject is SelectableAccessSubject => Boolean(subject)) ?? []
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen)
|
||||
setKeyword('')
|
||||
setOpen(nextOpen)
|
||||
}
|
||||
|
||||
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
|
||||
if (details.reason !== 'item-press')
|
||||
setKeyword(inputValue)
|
||||
}
|
||||
|
||||
const handleValueChange = (nextSubjects: SelectableAccessSubject[]) => {
|
||||
setKeyword('')
|
||||
onChange(nextSubjects)
|
||||
}
|
||||
|
||||
return (
|
||||
<Combobox<SelectableAccessSubject, true>
|
||||
multiple
|
||||
open={open}
|
||||
value={selectedSubjects}
|
||||
inputValue={keyword}
|
||||
items={subjects}
|
||||
disabled={disabled}
|
||||
itemToStringLabel={getSubjectLabel}
|
||||
itemToStringValue={getSubjectValue}
|
||||
isItemEqualToValue={isSameSubject}
|
||||
filter={null}
|
||||
onOpenChange={handleOpenChange}
|
||||
onInputValueChange={handleInputValueChange}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<ComboboxInputGroup className="h-auto min-h-8 w-full max-w-full overflow-hidden py-1 pr-1">
|
||||
<ComboboxValue>
|
||||
{(selectedValue: SelectableAccessSubject[]) => (
|
||||
<>
|
||||
{selectedValue.length > 0 && (
|
||||
<ComboboxChips className="flex-nowrap overflow-hidden">
|
||||
{selectedValue.map(subject => (
|
||||
<ComboboxChip
|
||||
key={subjectKey(subject)}
|
||||
className="shrink-0 rounded-full border border-divider-subtle bg-components-badge-white-to-dark"
|
||||
>
|
||||
<SubjectIcon subject={subject} />
|
||||
<span className="max-w-32 truncate">{getSubjectLabel(subject)}</span>
|
||||
{subject.subjectType === 'group' && subject.memberCount != null && (
|
||||
<span className="system-2xs-regular text-text-tertiary">{subject.memberCount}</span>
|
||||
)}
|
||||
<ComboboxChipRemove
|
||||
disabled={disabled || selectedSubjects.length <= 1}
|
||||
aria-label={t('operation.remove', { ns: 'common' })}
|
||||
>
|
||||
<span className="i-ri-close-circle-fill size-3.5" aria-hidden="true" />
|
||||
</ComboboxChipRemove>
|
||||
</ComboboxChip>
|
||||
))}
|
||||
</ComboboxChips>
|
||||
)}
|
||||
<ComboboxInput
|
||||
aria-label={t('access.members.pickPlaceholder')}
|
||||
placeholder={selectedValue.length ? '' : t('access.members.pickPlaceholder')}
|
||||
className={cn('px-2 py-0 system-sm-medium', selectedValue.length ? 'min-w-16' : 'min-w-0')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ComboboxValue>
|
||||
<ComboboxInputTrigger />
|
||||
</ComboboxInputGroup>
|
||||
<ComboboxContent popupClassName="w-(--anchor-width) min-w-90 p-0">
|
||||
{subjectsQuery.isLoading
|
||||
? (
|
||||
<ComboboxStatus className="flex flex-col gap-2 px-3 py-3">
|
||||
{SUBJECT_PICKER_SKELETON_KEYS.map(key => (
|
||||
<SkeletonRow key={key} className="h-6">
|
||||
<SkeletonRectangle className="h-3 w-full animate-pulse" />
|
||||
</SkeletonRow>
|
||||
))}
|
||||
</ComboboxStatus>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<ComboboxList className="p-1">
|
||||
{subjects.map(subject => (
|
||||
<ComboboxItem
|
||||
key={subjectKey(subject)}
|
||||
value={subject}
|
||||
className="mx-0"
|
||||
>
|
||||
<ComboboxItemText className="flex items-center gap-2 px-0">
|
||||
<SubjectIcon subject={subject} />
|
||||
<span className="min-w-0 flex-1 truncate">{getSubjectLabel(subject)}</span>
|
||||
{subject.subjectType === 'group' && subject.memberCount != null && (
|
||||
<span className="shrink-0 system-xs-regular text-text-tertiary">
|
||||
{t('access.members.memberCount', { count: subject.memberCount })}
|
||||
</span>
|
||||
)}
|
||||
</ComboboxItemText>
|
||||
<ComboboxItemIndicator />
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</ComboboxList>
|
||||
<ComboboxEmpty className="px-3 py-5 text-center system-xs-regular">
|
||||
{t('access.members.empty')}
|
||||
</ComboboxEmpty>
|
||||
</>
|
||||
)}
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
type EnvironmentPermissionRowProps = {
|
||||
appInstanceId: string
|
||||
environment: AppDeployEnvironment
|
||||
summaryPolicy?: EnvironmentAccessRow
|
||||
}
|
||||
|
||||
export function EnvironmentPermissionRow({
|
||||
appInstanceId,
|
||||
environment,
|
||||
summaryPolicy,
|
||||
}: EnvironmentPermissionRowProps) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const environmentId = environment.id
|
||||
const setEnvironmentAccessPolicy = useMutation(consoleQuery.enterprise.appDeployAccessService.updateEnvironmentAccessPolicy.mutationOptions())
|
||||
const policyKind = accessModeToPermissionKey(summaryPolicy?.accessMode)
|
||||
const policySubjectFingerprint = summaryPolicy?.subjects
|
||||
?.map(subject => `${subject.subjectType ?? ''}:${subject.subjectId ?? subject.accountData?.id ?? subject.groupData?.id ?? ''}`)
|
||||
.join(',')
|
||||
const policyFingerprint = [
|
||||
summaryPolicy?.accessMode ?? '',
|
||||
policySubjectFingerprint ?? '',
|
||||
].join(':')
|
||||
const policySelectedSubjects = policyKind === 'specific' ? selectedSubjectsFromPolicy(summaryPolicy) : []
|
||||
const [draft, setDraft] = useState<{
|
||||
fingerprint?: string
|
||||
kind?: AccessPermissionKind
|
||||
subjects?: SelectableAccessSubject[]
|
||||
}>({})
|
||||
const hasDraft = draft.fingerprint === policyFingerprint
|
||||
const permissionKind = hasDraft && draft.kind ? draft.kind : policyKind
|
||||
const subjects = hasDraft && draft.subjects ? draft.subjects : policySelectedSubjects
|
||||
const isSaving = setEnvironmentAccessPolicy.isPending
|
||||
const controlsDisabled = isSaving
|
||||
|
||||
const persistPolicy = (nextKind: AccessPermissionKind, nextSubjects: SelectableAccessSubject[]) => {
|
||||
if (!environmentId)
|
||||
return
|
||||
if (nextKind === 'specific' && nextSubjects.length === 0)
|
||||
return
|
||||
|
||||
setEnvironmentAccessPolicy.mutate(
|
||||
{
|
||||
params: {
|
||||
appInstanceId,
|
||||
environmentId,
|
||||
},
|
||||
body: {
|
||||
appInstanceId,
|
||||
environmentId,
|
||||
accessMode: permissionKeyToAccessMode(nextKind),
|
||||
subjects: nextKind === 'specific' ? policySubjects(nextSubjects) : [],
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDraft({})
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('access.permission.updateFailed'))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handlePermissionChange = (nextKind: AccessPermissionKind) => {
|
||||
setDraft({
|
||||
fingerprint: policyFingerprint,
|
||||
kind: nextKind,
|
||||
subjects: nextKind === 'specific' ? subjects : [],
|
||||
})
|
||||
if (nextKind === 'specific') {
|
||||
persistPolicy(nextKind, subjects)
|
||||
return
|
||||
}
|
||||
persistPolicy(nextKind, [])
|
||||
}
|
||||
|
||||
const handleSubjectsChange = (nextSubjects: SelectableAccessSubject[]) => {
|
||||
if (nextSubjects.length === 0)
|
||||
return
|
||||
setDraft({
|
||||
fingerprint: policyFingerprint,
|
||||
kind: 'specific',
|
||||
subjects: nextSubjects,
|
||||
})
|
||||
persistPolicy('specific', nextSubjects)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-x-3 gap-y-2 sm:grid-cols-[minmax(88px,108px)_minmax(0,1fr)] lg:grid-cols-[minmax(88px,108px)_minmax(160px,180px)_minmax(0,1fr)]">
|
||||
<span className="pt-1.5 system-xs-regular text-text-tertiary">
|
||||
{environmentName(environment)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<PermissionPicker
|
||||
value={permissionKind}
|
||||
disabled={controlsDisabled}
|
||||
onChange={handlePermissionChange}
|
||||
/>
|
||||
</div>
|
||||
{permissionKind === 'specific' && (
|
||||
<div className="min-w-0 sm:col-start-2 lg:col-start-3">
|
||||
<SubjectPicker
|
||||
selectedSubjects={subjects}
|
||||
disabled={controlsDisabled}
|
||||
onChange={handleSubjectsChange}
|
||||
/>
|
||||
{subjects.length === 0 && (
|
||||
<span className="mt-1.5 block system-xs-regular text-text-tertiary">
|
||||
{t('access.members.emptySelection')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
export function getUrlOrigin(url?: string) {
|
||||
if (!url)
|
||||
return undefined
|
||||
try {
|
||||
return new URL(url).origin
|
||||
}
|
||||
catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
@ -1,76 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AppInstanceBasicInfo } from '@dify/contracts/enterprise/types.gen'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { get } from '@/service/base'
|
||||
|
||||
const SOURCE_APP_DETAIL_QUERY_KEY = 'source-app-detail'
|
||||
|
||||
type SourceAppDetail = {
|
||||
id?: string
|
||||
}
|
||||
|
||||
type SourceAppAvailability = {
|
||||
canCreateRelease: boolean
|
||||
isChecking: boolean
|
||||
sourceAppUnavailable: boolean
|
||||
}
|
||||
|
||||
export function useSourceAppAvailability(
|
||||
appInstance?: Pick<AppInstanceBasicInfo, 'sourceAppAvailable' | 'sourceAppId'>,
|
||||
): SourceAppAvailability {
|
||||
const sourceAppId = appInstance?.sourceAppId
|
||||
const shouldVerifySourceApp = Boolean(sourceAppId && appInstance?.sourceAppAvailable !== false)
|
||||
const sourceAppDetailQuery = useQuery({
|
||||
queryKey: [SOURCE_APP_DETAIL_QUERY_KEY, sourceAppId],
|
||||
queryFn: async () => {
|
||||
if (!sourceAppId)
|
||||
return true
|
||||
|
||||
const sourceApp = await get<SourceAppDetail>(`/apps/${sourceAppId}`, {}, { silent: true })
|
||||
return sourceApp.id === sourceAppId
|
||||
},
|
||||
enabled: shouldVerifySourceApp,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
if (!appInstance) {
|
||||
return {
|
||||
canCreateRelease: false,
|
||||
isChecking: false,
|
||||
sourceAppUnavailable: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (appInstance.sourceAppAvailable === false) {
|
||||
return {
|
||||
canCreateRelease: false,
|
||||
isChecking: false,
|
||||
sourceAppUnavailable: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceAppId) {
|
||||
return {
|
||||
canCreateRelease: true,
|
||||
isChecking: false,
|
||||
sourceAppUnavailable: false,
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceAppDetailQuery.isLoading) {
|
||||
return {
|
||||
canCreateRelease: false,
|
||||
isChecking: true,
|
||||
sourceAppUnavailable: false,
|
||||
}
|
||||
}
|
||||
|
||||
const sourceAppUnavailable = sourceAppDetailQuery.isError || sourceAppDetailQuery.data === false
|
||||
|
||||
return {
|
||||
canCreateRelease: !sourceAppUnavailable,
|
||||
isChecking: false,
|
||||
sourceAppUnavailable,
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
export const INSTANCE_DETAIL_TAB_KEYS = ['overview', 'deploy', 'releases', 'settings'] as const
|
||||
|
||||
export type InstanceDetailTabKey = typeof INSTANCE_DETAIL_TAB_KEYS[number]
|
||||
|
||||
const INSTANCE_DETAIL_TAB_KEY_SET = new Set<string>(INSTANCE_DETAIL_TAB_KEYS)
|
||||
|
||||
export function isInstanceDetailTabKey(value?: string): value is InstanceDetailTabKey {
|
||||
return value != null && INSTANCE_DETAIL_TAB_KEY_SET.has(value)
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
'use client'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useSourceAppAvailability } from './source-app-availability'
|
||||
import { CreateReleaseControl } from './versions-tab/create-release-control'
|
||||
import { ReleaseHistoryTable } from './versions-tab/release-history-table'
|
||||
|
||||
function SourceAppUnavailableNotice({ appInstanceId }: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const { data: overview } = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
},
|
||||
}))
|
||||
const sourceAppAvailability = useSourceAppAvailability(overview?.overview?.appInstance)
|
||||
if (!sourceAppAvailability.sourceAppUnavailable)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-2 system-sm-regular text-text-tertiary">
|
||||
{t('versions.sourceAppUnavailable')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VersionsTab({ appInstanceId }: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-[1080px] min-w-0 flex-col gap-4 px-6 py-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="system-sm-semibold text-text-primary">
|
||||
{t('versions.releaseHistory')}
|
||||
</div>
|
||||
<CreateReleaseControl appInstanceId={appInstanceId} size="medium" />
|
||||
</div>
|
||||
|
||||
<SourceAppUnavailableNotice appInstanceId={appInstanceId} />
|
||||
|
||||
<ReleaseHistoryTable appInstanceId={appInstanceId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,104 +0,0 @@
|
||||
import type { EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getReleaseDeployments } from '../release-deployments'
|
||||
|
||||
describe('getReleaseDeployments', () => {
|
||||
it('should prefer runtime deployment state when history has the same environment', () => {
|
||||
// Arrange
|
||||
const releaseRow = {
|
||||
id: 'release-1',
|
||||
deployedTo: [
|
||||
{
|
||||
environmentId: 'env-1',
|
||||
environmentName: 'Production',
|
||||
},
|
||||
],
|
||||
} satisfies ReleaseRow
|
||||
const deploymentRows = [
|
||||
{
|
||||
runtime: { runtimeInstanceId: 'deployment-1' },
|
||||
environment: {
|
||||
id: 'env-1',
|
||||
name: 'Production',
|
||||
},
|
||||
status: 'ready',
|
||||
currentRelease: {
|
||||
id: 'release-1',
|
||||
},
|
||||
},
|
||||
] satisfies EnvironmentDeployment[]
|
||||
|
||||
// Act
|
||||
const result = getReleaseDeployments(releaseRow, deploymentRows)
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual([
|
||||
{
|
||||
environmentId: 'env-1',
|
||||
environmentName: 'Production',
|
||||
state: 'active',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should merge history deployments with runtime deployments for different environments', () => {
|
||||
// Arrange
|
||||
const releaseRow = {
|
||||
id: 'release-1',
|
||||
deployedTo: [
|
||||
{
|
||||
environmentId: 'env-1',
|
||||
environmentName: 'Production',
|
||||
},
|
||||
],
|
||||
} satisfies ReleaseRow
|
||||
const deploymentRows = [
|
||||
{
|
||||
runtime: { runtimeInstanceId: 'deployment-2' },
|
||||
environment: {
|
||||
id: 'env-2',
|
||||
name: 'Staging',
|
||||
},
|
||||
status: 'deploying',
|
||||
currentRelease: {
|
||||
id: 'release-1',
|
||||
},
|
||||
},
|
||||
] satisfies EnvironmentDeployment[]
|
||||
|
||||
// Act
|
||||
const result = getReleaseDeployments(releaseRow, deploymentRows)
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual([
|
||||
{
|
||||
environmentId: 'env-2',
|
||||
environmentName: 'Staging',
|
||||
state: 'deploying',
|
||||
},
|
||||
{
|
||||
environmentId: 'env-1',
|
||||
environmentName: 'Production',
|
||||
state: 'active',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should return no deployments when the release row has no release id', () => {
|
||||
// Arrange
|
||||
const releaseRow = {
|
||||
deployedTo: [
|
||||
{
|
||||
environmentId: 'env-1',
|
||||
environmentName: 'Production',
|
||||
},
|
||||
],
|
||||
} satisfies ReleaseRow
|
||||
|
||||
// Act
|
||||
const result = getReleaseDeployments(releaseRow, [])
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
@ -1,130 +0,0 @@
|
||||
import type { EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ReleaseHistoryTable } from '../release-history-table'
|
||||
|
||||
const mockUseQuery = vi.fn()
|
||||
|
||||
vi.mock('@tanstack/react-query', () => ({
|
||||
keepPreviousData: Symbol('keepPreviousData'),
|
||||
useQuery: (options: { queryKey?: string[] }) => mockUseQuery(options),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: () => '9 days ago',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
enterprise: {
|
||||
appInstanceService: {
|
||||
getAppInstanceOverview: {
|
||||
queryOptions: () => ({ queryKey: ['app-instance-overview'] }),
|
||||
},
|
||||
},
|
||||
appReleaseService: {
|
||||
listReleases: {
|
||||
queryOptions: () => ({ queryKey: ['release-history'] }),
|
||||
},
|
||||
},
|
||||
appDeploymentService: {
|
||||
listEnvironmentDeployments: {
|
||||
queryOptions: () => ({ queryKey: ['runtime-instances'] }),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
function release(overrides: Partial<ReleaseRow> = {}): ReleaseRow {
|
||||
return {
|
||||
id: 'release-1',
|
||||
name: 'R-001',
|
||||
createdAt: '2026-05-05T10:00:00Z',
|
||||
createdBy: { name: 'App-runner-demo' },
|
||||
deployedTo: [
|
||||
{
|
||||
environmentId: 'env-1',
|
||||
environmentName: 'default',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeInstance(overrides: Partial<EnvironmentDeployment> = {}): EnvironmentDeployment {
|
||||
return {
|
||||
runtime: { runtimeInstanceId: 'runtime-1' },
|
||||
environment: { id: 'env-1', name: 'default' },
|
||||
status: 'ready',
|
||||
currentRelease: { id: 'release-1' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ReleaseHistoryTable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseQuery.mockImplementation((options: { queryKey?: string[] }) => {
|
||||
switch (options.queryKey?.[0]) {
|
||||
case 'app-instance-overview':
|
||||
return {
|
||||
data: { overview: { appInstance: { sourceAppAvailable: true } } },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}
|
||||
case 'release-history':
|
||||
return {
|
||||
data: {
|
||||
data: [release()],
|
||||
pagination: { totalCount: 1 },
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}
|
||||
case 'runtime-instances':
|
||||
return {
|
||||
data: { data: [runtimeInstance()] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// The desktop release history should use the same compact table shell as knowledge documents.
|
||||
describe('Rendering', () => {
|
||||
it('should render the desktop release history as a compact document-style table', () => {
|
||||
// Arrange & Act
|
||||
const { container } = render(<ReleaseHistoryTable appInstanceId="instance-1" />)
|
||||
|
||||
// Assert
|
||||
const table = screen.getByRole('table')
|
||||
expect(table).toHaveClass('border-collapse', 'border-0', 'text-sm', 'min-w-[700px]')
|
||||
expect(container.querySelector('thead')).toHaveClass(
|
||||
'h-8',
|
||||
'border-b',
|
||||
'border-divider-subtle',
|
||||
'text-xs',
|
||||
'leading-8',
|
||||
'font-medium',
|
||||
'text-text-tertiary',
|
||||
'uppercase',
|
||||
)
|
||||
expect(container.querySelector('tbody tr')).toHaveClass(
|
||||
'h-8',
|
||||
'border-b',
|
||||
'border-divider-subtle',
|
||||
'hover:bg-background-default-hover',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,198 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useSourceAppAvailability } from '../source-app-availability'
|
||||
|
||||
const DESCRIPTION_MAX_LENGTH = 512
|
||||
const DESCRIPTION_WARN_THRESHOLD = 460
|
||||
|
||||
export function CreateReleaseControl({ appInstanceId, variant = 'primary', size = 'small' }: {
|
||||
appInstanceId: string
|
||||
variant?: 'primary' | 'secondary'
|
||||
size?: 'small' | 'medium'
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const createRelease = useMutation(consoleQuery.enterprise.appReleaseService.createRelease.mutationOptions())
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [description, setDescription] = useState('')
|
||||
const { data: overview } = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
},
|
||||
}))
|
||||
const sourceAppAvailability = useSourceAppAvailability(overview?.overview?.appInstance)
|
||||
const canCreateRelease = sourceAppAvailability.canCreateRelease
|
||||
|
||||
function closeDialog() {
|
||||
setIsCreating(false)
|
||||
setDescription('')
|
||||
}
|
||||
|
||||
function handleCreateRelease(form: HTMLFormElement) {
|
||||
if (!canCreateRelease || createRelease.isPending)
|
||||
return
|
||||
|
||||
const formData = new FormData(form)
|
||||
const releaseName = String(formData.get('name') ?? '').trim()
|
||||
const releaseDescription = description.trim()
|
||||
if (!releaseName)
|
||||
return
|
||||
|
||||
createRelease.mutate(
|
||||
{
|
||||
params: {
|
||||
appInstanceId,
|
||||
},
|
||||
body: {
|
||||
name: releaseName,
|
||||
description: releaseDescription || undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (response) => {
|
||||
if (!response.release?.id) {
|
||||
toast.error(t('versions.createFailed'))
|
||||
return
|
||||
}
|
||||
const createdName = response.release.name ?? releaseName
|
||||
toast.success(t('versions.createSuccess', { name: createdName }))
|
||||
form.reset()
|
||||
closeDialog()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('versions.createFailed'))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const descriptionLength = description.length
|
||||
const isNearLimit = descriptionLength >= DESCRIPTION_WARN_THRESHOLD
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size={size}
|
||||
variant={variant}
|
||||
disabled={!canCreateRelease}
|
||||
onClick={() => setIsCreating(true)}
|
||||
>
|
||||
{t('versions.createRelease')}
|
||||
</Button>
|
||||
|
||||
<Dialog
|
||||
open={isCreating}
|
||||
onOpenChange={(open) => {
|
||||
if (!open)
|
||||
closeDialog()
|
||||
else
|
||||
setIsCreating(true)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-140 overflow-hidden p-0">
|
||||
<DialogCloseButton />
|
||||
{isCreating && (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
handleCreateRelease(event.currentTarget)
|
||||
}}
|
||||
>
|
||||
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="title-xl-semi-bold text-text-primary">
|
||||
{t('versions.createRelease')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
|
||||
{t('versions.createReleaseDescription')}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 px-6 py-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-name">
|
||||
{t('versions.releaseNameLabel')}
|
||||
</label>
|
||||
<Input
|
||||
id="release-name"
|
||||
name="name"
|
||||
placeholder={t('versions.releaseNamePlaceholder')}
|
||||
maxLength={128}
|
||||
required
|
||||
autoFocus
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-description">
|
||||
{t('versions.releaseDescriptionLabel')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="system-xs-regular text-text-quaternary">
|
||||
{t('versions.optional')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'system-xs-regular tabular-nums',
|
||||
isNearLimit ? 'text-util-colors-warning-warning-700' : 'text-text-quaternary',
|
||||
)}
|
||||
>
|
||||
{descriptionLength}
|
||||
/
|
||||
{DESCRIPTION_MAX_LENGTH}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
id="release-description"
|
||||
name="description"
|
||||
placeholder={t('versions.releaseDescriptionPlaceholder')}
|
||||
maxLength={DESCRIPTION_MAX_LENGTH}
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="min-h-24 w-full resize-none appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
|
||||
<div className="system-xs-regular text-text-tertiary">
|
||||
{t('versions.createReleaseHint')}
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={createRelease.isPending}
|
||||
onClick={closeDialog}
|
||||
>
|
||||
{t('versions.cancelCreate')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="min-w-22"
|
||||
disabled={!canCreateRelease || createRelease.isPending}
|
||||
>
|
||||
{createRelease.isPending ? t('versions.creating') : t('versions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,178 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AppDeployEnvironment, EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { environmentId, environmentName } from '../../environment'
|
||||
import { releaseDeploymentAction } from '../../release-action'
|
||||
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
|
||||
import { openDeployDrawerAtom } from '../../store'
|
||||
import { DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME } from '../list-styles'
|
||||
|
||||
type EnvironmentOption = AppDeployEnvironment & {
|
||||
id: string
|
||||
}
|
||||
|
||||
type DeployMenuRowState = 'promote' | 'deploy' | 'rollback' | 'current' | 'deploying'
|
||||
|
||||
type DeployMenuRow = {
|
||||
env: EnvironmentOption
|
||||
state: DeployMenuRowState
|
||||
label: string
|
||||
disabledReason?: string
|
||||
}
|
||||
|
||||
type DeployMenuGroup = 'promote' | 'deploy' | 'rollback' | 'unavailable'
|
||||
|
||||
const GROUP_ORDER: DeployMenuGroup[] = ['promote', 'deploy', 'rollback', 'unavailable']
|
||||
|
||||
function stateToGroup(state: DeployMenuRowState): DeployMenuGroup {
|
||||
if (state === 'promote')
|
||||
return 'promote'
|
||||
if (state === 'rollback')
|
||||
return 'rollback'
|
||||
if (state === 'deploy')
|
||||
return 'deploy'
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows }: {
|
||||
appInstanceId: string
|
||||
releaseId: string
|
||||
releaseRows: ReleaseRow[]
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
|
||||
const [open, setOpen] = useState(false)
|
||||
const { data: environmentDeployments } = useQuery(consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
},
|
||||
enabled: open,
|
||||
}))
|
||||
|
||||
const environments: EnvironmentOption[] = (environmentDeployments?.data ?? [])
|
||||
.map(row => row.environment)
|
||||
.filter((env): env is EnvironmentOption => Boolean(env?.id))
|
||||
const deploymentRows = environmentDeployments?.data?.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? []
|
||||
const targetRelease = releaseRows.find(release => release.id === releaseId)
|
||||
|
||||
if (!targetRelease)
|
||||
return null
|
||||
|
||||
const menuRows: DeployMenuRow[] = environments.map((env) => {
|
||||
const envId = env.id
|
||||
const envName = environmentName(env)
|
||||
const row: EnvironmentDeployment | undefined = deploymentRows.find(item => environmentId(item.environment) === envId)
|
||||
const currentRelease = row?.currentRelease
|
||||
const isCurrent = currentRelease?.id === releaseId
|
||||
const isEnvironmentDeploying = row ? deploymentStatus(row) === 'deploying' : false
|
||||
|
||||
if (isEnvironmentDeploying) {
|
||||
return {
|
||||
env,
|
||||
state: 'deploying',
|
||||
label: t('versions.deployingTo', { name: envName }),
|
||||
disabledReason: t('versions.disabledReason.deploying'),
|
||||
}
|
||||
}
|
||||
if (isCurrent) {
|
||||
return {
|
||||
env,
|
||||
state: 'current',
|
||||
label: t('versions.currentOn', { name: envName }),
|
||||
disabledReason: t('versions.disabledReason.current', { name: envName }),
|
||||
}
|
||||
}
|
||||
|
||||
const action = releaseDeploymentAction({
|
||||
targetRelease,
|
||||
currentRelease,
|
||||
releaseRows,
|
||||
isExistingRelease: true,
|
||||
})
|
||||
|
||||
if (!row) {
|
||||
return {
|
||||
env,
|
||||
state: 'deploy',
|
||||
label: t('versions.deployTo', { name: envName }),
|
||||
}
|
||||
}
|
||||
if (action === 'rollback') {
|
||||
return {
|
||||
env,
|
||||
state: 'rollback',
|
||||
label: t('versions.rollbackTo', { name: envName }),
|
||||
}
|
||||
}
|
||||
return {
|
||||
env,
|
||||
state: 'promote',
|
||||
label: t('versions.promoteTo', { name: envName }),
|
||||
}
|
||||
})
|
||||
|
||||
const groupedRows = GROUP_ORDER.map(group => ({
|
||||
group,
|
||||
rows: menuRows.filter(row => stateToGroup(row.state) === group),
|
||||
})).filter(section => section.rows.length > 0)
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('versions.moreActions')}
|
||||
className={DETAIL_LIST_ACTION_TRIGGER_CLASS_NAME}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
{open && (
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-60">
|
||||
{groupedRows.map((section, sectionIndex) => (
|
||||
<div key={section.group}>
|
||||
{sectionIndex > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
|
||||
<div className="px-3 pt-1.5 pb-1 system-2xs-medium-uppercase text-text-quaternary">
|
||||
{t(`versions.groupHeader.${section.group}`)}
|
||||
</div>
|
||||
{section.rows.map((row) => {
|
||||
const isDisabled = row.state === 'current' || row.state === 'deploying'
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={row.env.id}
|
||||
disabled={isDisabled}
|
||||
title={isDisabled ? row.disabledReason : undefined}
|
||||
aria-disabled={isDisabled}
|
||||
className={cn(
|
||||
'gap-2 px-3',
|
||||
isDisabled && 'cursor-not-allowed opacity-60',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isDisabled)
|
||||
return
|
||||
setOpen(false)
|
||||
openDeployDrawer({ appInstanceId, environmentId: row.env.id, releaseId })
|
||||
}}
|
||||
>
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{row.label}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReleaseDeployment, ReleaseDeploymentState } from './release-deployments'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const RELEASE_DEPLOYMENT_STYLES: Record<ReleaseDeploymentState, string> = {
|
||||
active: 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700',
|
||||
deploying: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700',
|
||||
failed: 'border-util-colors-red-red-200 bg-util-colors-red-red-50 text-util-colors-red-red-700',
|
||||
}
|
||||
|
||||
export function DeployedToBadge({ item }: {
|
||||
item: ReleaseDeployment
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const statusLabel = t(`versions.deployedStatus.${item.state}`)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-6 items-center gap-1 rounded-md border px-1.5 system-xs-medium',
|
||||
RELEASE_DEPLOYMENT_STYLES[item.state],
|
||||
)}
|
||||
>
|
||||
{item.state === 'deploying'
|
||||
? <span className="i-ri-loader-4-line size-3.5 animate-spin" />
|
||||
: item.state === 'failed'
|
||||
? <span className="i-ri-alert-line size-3.5" />
|
||||
: <span className="size-1.5 rounded-full bg-current" />}
|
||||
{item.environmentName}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{statusLabel}
|
||||
{' · '}
|
||||
{item.environmentName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@ -1,76 +0,0 @@
|
||||
import type { DeployedEnvironment, EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
|
||||
import { environmentId, environmentName } from '../../environment'
|
||||
import { deploymentStatus } from '../../runtime-status'
|
||||
|
||||
export type ReleaseDeploymentState = 'active' | 'deploying' | 'failed'
|
||||
|
||||
export type ReleaseDeployment = {
|
||||
environmentId: string
|
||||
environmentName: string
|
||||
state: ReleaseDeploymentState
|
||||
}
|
||||
|
||||
function releaseDeploymentState(status?: string): ReleaseDeploymentState {
|
||||
const normalized = status?.toLowerCase() ?? ''
|
||||
if (normalized.includes('deploying') || normalized.includes('pending'))
|
||||
return 'deploying'
|
||||
if (normalized.includes('fail') || normalized.includes('error'))
|
||||
return 'failed'
|
||||
return 'active'
|
||||
}
|
||||
|
||||
function runtimeDeploymentByEnvironmentId(deploymentRows: EnvironmentDeployment[]) {
|
||||
return new Map(
|
||||
deploymentRows
|
||||
.map((deployment) => {
|
||||
const envId = environmentId(deployment.environment)
|
||||
return envId ? [envId, deployment] as const : undefined
|
||||
})
|
||||
.filter((entry): entry is readonly [string, EnvironmentDeployment] => !!entry),
|
||||
)
|
||||
}
|
||||
|
||||
function fromDeployedTo(item: DeployedEnvironment, runtimeDeployments: Map<string, EnvironmentDeployment>): ReleaseDeployment | undefined {
|
||||
if (!item.environmentId)
|
||||
return undefined
|
||||
|
||||
const runtimeDeployment = runtimeDeployments.get(item.environmentId)
|
||||
|
||||
return {
|
||||
environmentId: item.environmentId,
|
||||
environmentName: item.environmentName || item.environmentId,
|
||||
state: runtimeDeployment ? releaseDeploymentState(deploymentStatus(runtimeDeployment)) : 'active',
|
||||
}
|
||||
}
|
||||
|
||||
function dedupeReleaseDeployments(items: ReleaseDeployment[]) {
|
||||
return items.filter((item, index) => {
|
||||
return items.findIndex(candidate => candidate.environmentId === item.environmentId) === index
|
||||
})
|
||||
}
|
||||
|
||||
export function getReleaseDeployments(row: ReleaseRow, deploymentRows: EnvironmentDeployment[]) {
|
||||
const releaseId = row.id
|
||||
if (!releaseId)
|
||||
return []
|
||||
|
||||
const runtimeDeployments = runtimeDeploymentByEnvironmentId(deploymentRows)
|
||||
const historyItems = row.deployedTo?.map(item => fromDeployedTo(item, runtimeDeployments)).filter((item): item is ReleaseDeployment => !!item) ?? []
|
||||
const runtimeItems = deploymentRows.flatMap((deployment) => {
|
||||
const envId = environmentId(deployment.environment)
|
||||
if (!envId)
|
||||
return []
|
||||
|
||||
const items: ReleaseDeployment[] = []
|
||||
if (deployment.currentRelease?.id === releaseId) {
|
||||
items.push({
|
||||
environmentId: envId,
|
||||
environmentName: environmentName(deployment.environment),
|
||||
state: releaseDeploymentState(deploymentStatus(deployment)),
|
||||
})
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
return dedupeReleaseDeployments([...runtimeItems, ...historyItems])
|
||||
}
|
||||
@ -1,379 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { EnvironmentDeployment, ReleaseRow } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { ReleaseDeployment } from './release-deployments'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { RELEASE_HISTORY_PAGE_SIZE } from '../../data'
|
||||
import {
|
||||
formatDate,
|
||||
releaseCommit,
|
||||
releaseLabel,
|
||||
} from '../../release'
|
||||
import { isUndeployedDeploymentRow } from '../../runtime-status'
|
||||
import {
|
||||
DetailListState,
|
||||
} from '../common'
|
||||
import {
|
||||
DETAIL_LIST_CLASS_NAME,
|
||||
DETAIL_LIST_DESKTOP_ROW_CLASS_NAME,
|
||||
DETAIL_LIST_HEADER_ROW_CLASS_NAME,
|
||||
DETAIL_LIST_ROW_CLASS_NAME,
|
||||
RELEASE_DETAIL_LIST_GRID_CLASS_NAME,
|
||||
} from '../list-styles'
|
||||
import { DeployReleaseMenu } from './deploy-release-menu'
|
||||
import { DeployedToBadge } from './deployed-to-badge'
|
||||
import { getReleaseDeployments } from './release-deployments'
|
||||
|
||||
const RELEASE_TABLE_ROW_SKELETON_KEYS = ['latest', 'previous', 'older', 'archived', 'initial']
|
||||
|
||||
type ReleaseRowWithId = ReleaseRow & {
|
||||
id: string
|
||||
}
|
||||
|
||||
function hasReleaseId(row: ReleaseRow): row is ReleaseRowWithId {
|
||||
return Boolean(row.id)
|
||||
}
|
||||
|
||||
function ReleaseHistoryTableSkeleton() {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${DETAIL_LIST_CLASS_NAME} pc:hidden`}>
|
||||
{RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => (
|
||||
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
|
||||
<SkeletonRow className="mt-1 gap-2">
|
||||
<SkeletonRectangle className="h-3 w-28 animate-pulse" />
|
||||
<SkeletonRectangle className="h-3 w-20 animate-pulse" />
|
||||
</SkeletonRow>
|
||||
</div>
|
||||
<SkeletonRectangle className="my-0 h-7 w-8 animate-pulse rounded-lg" />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<ReleaseDeploymentsSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden pc:block">
|
||||
<div className={DETAIL_LIST_CLASS_NAME}>
|
||||
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${RELEASE_DETAIL_LIST_GRID_CLASS_NAME}`}>
|
||||
<div>{t('versions.col.release')}</div>
|
||||
<div>{t('versions.col.createdAt')}</div>
|
||||
<div>{t('versions.col.author')}</div>
|
||||
<div>{t('versions.col.deployedTo')}</div>
|
||||
<div className="text-right">{t('versions.col.action')}</div>
|
||||
</div>
|
||||
{RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => (
|
||||
<div key={key} className={DETAIL_LIST_ROW_CLASS_NAME}>
|
||||
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${RELEASE_DETAIL_LIST_GRID_CLASS_NAME}`}>
|
||||
<div className="min-w-0">
|
||||
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<ReleaseDeploymentsSkeleton />
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseHistoryMobileRows({ appInstanceId, releaseRows, deploymentRows, deployedToLoading, deployedToHasError }: {
|
||||
appInstanceId: string
|
||||
releaseRows: ReleaseRowWithId[]
|
||||
deploymentRows: EnvironmentDeployment[]
|
||||
deployedToLoading?: boolean
|
||||
deployedToHasError?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<div className={`${DETAIL_LIST_CLASS_NAME} pc:hidden`}>
|
||||
{releaseRows.map((row) => {
|
||||
const release = row
|
||||
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
|
||||
const hasDeployments = releaseDeployments.length > 0 || deployedToLoading || deployedToHasError
|
||||
|
||||
return (
|
||||
<div key={release.id} className={DETAIL_LIST_ROW_CLASS_NAME}>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="inline-flex max-w-full cursor-default truncate font-mono system-sm-medium text-text-primary">
|
||||
{releaseLabel(release)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('versions.commitTooltip', { commit: releaseCommit(release) })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 system-xs-regular text-text-secondary">
|
||||
<CreatedAtCell createdAt={release.createdAt} />
|
||||
<span aria-hidden>·</span>
|
||||
<span>{row.createdBy?.name ?? '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 justify-end gap-1">
|
||||
<DeployReleaseMenu releaseId={release.id} appInstanceId={appInstanceId} releaseRows={releaseRows} />
|
||||
</div>
|
||||
</div>
|
||||
{hasDeployments && (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1">
|
||||
<ReleaseDeploymentsContent
|
||||
items={releaseDeployments}
|
||||
isLoading={deployedToLoading}
|
||||
hasError={deployedToHasError}
|
||||
loadFailedLabel={t('common.loadFailed')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseDeploymentsSkeleton() {
|
||||
return (
|
||||
<SkeletonRow className="gap-1">
|
||||
<SkeletonRectangle className="my-0 h-5 w-20 animate-pulse rounded-md" />
|
||||
<SkeletonRectangle className="my-0 h-5 w-18 animate-pulse rounded-md" />
|
||||
</SkeletonRow>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseDeploymentsContent({
|
||||
items,
|
||||
isLoading,
|
||||
hasError,
|
||||
loadFailedLabel,
|
||||
}: {
|
||||
items: ReleaseDeployment[]
|
||||
isLoading?: boolean
|
||||
hasError?: boolean
|
||||
loadFailedLabel: string
|
||||
}) {
|
||||
if (isLoading)
|
||||
return <ReleaseDeploymentsSkeleton />
|
||||
|
||||
if (hasError)
|
||||
return <span className="system-sm-regular text-text-tertiary">{loadFailedLabel}</span>
|
||||
|
||||
if (items.length === 0)
|
||||
return <span className="system-sm-regular text-text-quaternary">—</span>
|
||||
|
||||
return items.map(item => (
|
||||
<DeployedToBadge
|
||||
key={`${item.environmentId}-${item.state}`}
|
||||
item={item}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
function CreatedAtCell({ createdAt }: {
|
||||
createdAt?: string
|
||||
}) {
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
if (!createdAt)
|
||||
return <>—</>
|
||||
const ms = Date.parse(createdAt)
|
||||
if (Number.isNaN(ms))
|
||||
return <>{formatDate(createdAt)}</>
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="cursor-default">
|
||||
{formatTimeFromNow(ms)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>{formatDate(createdAt)}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function ReleaseHistoryRows({ appInstanceId, releaseRows, deploymentRows, deployedToLoading, deployedToHasError }: {
|
||||
appInstanceId: string
|
||||
releaseRows: ReleaseRowWithId[]
|
||||
deploymentRows: EnvironmentDeployment[]
|
||||
deployedToLoading?: boolean
|
||||
deployedToHasError?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReleaseHistoryMobileRows
|
||||
appInstanceId={appInstanceId}
|
||||
releaseRows={releaseRows}
|
||||
deploymentRows={deploymentRows}
|
||||
deployedToLoading={deployedToLoading}
|
||||
deployedToHasError={deployedToHasError}
|
||||
/>
|
||||
<div className="hidden pc:block">
|
||||
<div className={DETAIL_LIST_CLASS_NAME}>
|
||||
<div className={`${DETAIL_LIST_HEADER_ROW_CLASS_NAME} ${RELEASE_DETAIL_LIST_GRID_CLASS_NAME}`}>
|
||||
<div>{t('versions.col.release')}</div>
|
||||
<div>{t('versions.col.createdAt')}</div>
|
||||
<div>{t('versions.col.author')}</div>
|
||||
<div>{t('versions.col.deployedTo')}</div>
|
||||
<div className="text-right">{t('versions.col.action')}</div>
|
||||
</div>
|
||||
{releaseRows.map((row) => {
|
||||
const release = row
|
||||
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
|
||||
|
||||
return (
|
||||
<div key={release.id} className={DETAIL_LIST_ROW_CLASS_NAME}>
|
||||
<div className={`${DETAIL_LIST_DESKTOP_ROW_CLASS_NAME} ${RELEASE_DETAIL_LIST_GRID_CLASS_NAME}`}>
|
||||
<div className="min-w-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<span className="inline-flex max-w-full cursor-default truncate font-mono system-sm-medium text-text-primary">
|
||||
{releaseLabel(release)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{t('versions.commitTooltip', { commit: releaseCommit(release) })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="min-w-0 system-sm-regular text-text-secondary">
|
||||
<CreatedAtCell createdAt={release.createdAt} />
|
||||
</div>
|
||||
<div className="min-w-0 truncate system-sm-regular text-text-secondary">
|
||||
{row.createdBy?.name ?? '—'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<ReleaseDeploymentsContent
|
||||
items={releaseDeployments}
|
||||
isLoading={deployedToLoading}
|
||||
hasError={deployedToHasError}
|
||||
loadFailedLabel={t('common.loadFailed')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<DeployReleaseMenu releaseId={release.id} appInstanceId={appInstanceId} releaseRows={releaseRows} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReleaseHistoryTable({ appInstanceId }: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
const input = { params: { appInstanceId } }
|
||||
const overviewQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
|
||||
input,
|
||||
}))
|
||||
const releaseHistoryQuery = useQuery(consoleQuery.enterprise.appReleaseService.listReleases.queryOptions({
|
||||
input: {
|
||||
...input,
|
||||
query: {
|
||||
pageNumber: currentPage + 1,
|
||||
resultsPerPage: RELEASE_HISTORY_PAGE_SIZE,
|
||||
},
|
||||
},
|
||||
placeholderData: keepPreviousData,
|
||||
}))
|
||||
const releaseRows = releaseHistoryQuery.data?.data?.filter(hasReleaseId) ?? []
|
||||
const totalReleases = releaseHistoryQuery.data?.pagination?.totalCount ?? releaseRows.length
|
||||
const shouldLoadRuntimeInstances = releaseRows.length > 0
|
||||
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({
|
||||
input,
|
||||
enabled: shouldLoadRuntimeInstances,
|
||||
}))
|
||||
const isLoading = releaseHistoryQuery.isLoading
|
||||
|| (releaseRows.length === 0 && overviewQuery.isLoading)
|
||||
const hasError = releaseHistoryQuery.isError
|
||||
|| (releaseRows.length === 0 && overviewQuery.isError)
|
||||
const deployedToLoading = shouldLoadRuntimeInstances && environmentDeploymentsQuery.isLoading
|
||||
const deployedToHasError = shouldLoadRuntimeInstances && environmentDeploymentsQuery.isError
|
||||
const sourceAppUnavailable = overviewQuery.data?.overview?.appInstance?.sourceAppAvailable === false
|
||||
const deploymentRows = environmentDeploymentsQuery.data?.data?.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? []
|
||||
|
||||
if (isLoading) {
|
||||
return <ReleaseHistoryTableSkeleton />
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<DetailListState>
|
||||
{t('common.loadFailed')}
|
||||
</DetailListState>
|
||||
)
|
||||
}
|
||||
|
||||
if (releaseRows.length === 0) {
|
||||
return (
|
||||
<DetailListState>
|
||||
{sourceAppUnavailable ? t('versions.emptySourceUnavailable') : t('versions.emptyWithCreate')}
|
||||
</DetailListState>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<ReleaseHistoryRows
|
||||
appInstanceId={appInstanceId}
|
||||
releaseRows={releaseRows}
|
||||
deploymentRows={deploymentRows}
|
||||
deployedToLoading={deployedToLoading}
|
||||
deployedToHasError={deployedToHasError}
|
||||
/>
|
||||
{totalReleases > RELEASE_HISTORY_PAGE_SIZE && (
|
||||
<Pagination
|
||||
className="border-y border-divider-subtle"
|
||||
current={currentPage}
|
||||
total={totalReleases}
|
||||
limit={RELEASE_HISTORY_PAGE_SIZE}
|
||||
onChange={setCurrentPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
import type { AppDeployEnvironment } from '@dify/contracts/enterprise/types.gen'
|
||||
|
||||
export function environmentId(environment?: AppDeployEnvironment) {
|
||||
return environment?.id ?? ''
|
||||
}
|
||||
|
||||
export function environmentName(environment?: AppDeployEnvironment) {
|
||||
return environment?.name || environment?.id || '—'
|
||||
}
|
||||
|
||||
export function environmentMode(environment?: AppDeployEnvironment) {
|
||||
const type = environment?.type?.toLowerCase() ?? ''
|
||||
return type.includes('isolated') ? 'isolated' : 'shared'
|
||||
}
|
||||
|
||||
function environmentRuntimeName(environment?: AppDeployEnvironment) {
|
||||
return environment?.backend ?? ''
|
||||
}
|
||||
|
||||
export function environmentBackend(environment?: AppDeployEnvironment) {
|
||||
const runtime = environmentRuntimeName(environment).toLowerCase()
|
||||
return runtime.includes('host') ? 'host' : 'k8s'
|
||||
}
|
||||
|
||||
export function environmentHealth(environment?: AppDeployEnvironment) {
|
||||
const status = environment?.status?.toLowerCase() ?? ''
|
||||
return status.includes('ready') ? 'ready' : 'degraded'
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Link from '@/next/link'
|
||||
|
||||
export function CreateDeploymentButton() {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/deployments/create"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary-600 px-3 system-sm-medium text-text-primary-on-surface hover:bg-primary-700"
|
||||
>
|
||||
<span className="i-ri-add-line size-4 shrink-0" aria-hidden="true" />
|
||||
<span>{t('list.createDeployment')}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@ -1,92 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { envFilterQueryState } from './query-state'
|
||||
|
||||
type EnvironmentFilterOption = {
|
||||
value: string
|
||||
text: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
export function EnvironmentFilter() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [envFilter, setEnvFilter] = useQueryState('env', envFilterQueryState)
|
||||
const activeFilter = envFilter === 'all' || envFilter === 'not-deployed'
|
||||
? envFilter
|
||||
: 'all'
|
||||
const filterOptions: EnvironmentFilterOption[] = [
|
||||
{
|
||||
value: 'all',
|
||||
text: t('filter.allEnvs'),
|
||||
icon: <span className="i-ri-apps-2-line size-[14px]" />,
|
||||
},
|
||||
{
|
||||
value: 'not-deployed',
|
||||
text: t('filter.notDeployed'),
|
||||
icon: <span className="i-ri-inbox-line size-[14px]" />,
|
||||
},
|
||||
]
|
||||
const selectedOption = filterOptions.find(option => option.value === activeFilter) ?? filterOptions[0]
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center gap-1 rounded-lg border border-transparent bg-components-input-bg-normal px-2 text-left select-none',
|
||||
open && 'shadow-xs',
|
||||
)}
|
||||
>
|
||||
<div className="p-px text-text-tertiary">
|
||||
{selectedOption?.icon}
|
||||
</div>
|
||||
<div className="max-w-40 min-w-0 truncate system-sm-regular text-text-secondary">
|
||||
{selectedOption?.text}
|
||||
</div>
|
||||
<div className="shrink-0 p-px">
|
||||
<span className={cn('i-ri-arrow-down-s-line size-3.5 text-text-tertiary transition-transform', open && 'rotate-180')} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
{open && (
|
||||
<DropdownMenuContent
|
||||
placement="bottom-start"
|
||||
sideOffset={4}
|
||||
popupClassName="w-60 rounded-lg border border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs"
|
||||
>
|
||||
<div className="max-h-72 overflow-auto p-1">
|
||||
{filterOptions.map(option => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
void setEnvFilter(option.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg py-1.5 pr-2 pl-3 select-none',
|
||||
'cursor-pointer hover:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 text-text-tertiary">{option.icon}</span>
|
||||
<span className="grow truncate text-sm/5 text-text-tertiary">{option.text}</span>
|
||||
{option.value === activeFilter && (
|
||||
<span className="i-custom-vender-line-general-check size-4 shrink-0 text-text-secondary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@ -1,213 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { debounce, useQueryState } from 'nuqs'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../data'
|
||||
import { CreateDeploymentButton } from './create-deployment-button'
|
||||
import { EnvironmentFilter } from './environment-filter'
|
||||
import { InstanceCard } from './instance-card'
|
||||
import { envFilterQueryState, keywordsQueryState } from './query-state'
|
||||
|
||||
const INSTANCE_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
|
||||
const EMPTY_INSTANCE_CARD_KEYS = Array.from({ length: 36 }, (_, index) => `empty-instance-card-${index}`)
|
||||
|
||||
function DeploymentsListState({ children }: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="col-span-full rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-12 text-center system-sm-regular text-text-tertiary">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeploymentsListEmpty() {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<>
|
||||
{EMPTY_INSTANCE_CARD_KEYS.map(key => (
|
||||
<div
|
||||
key={key}
|
||||
className="inline-flex h-40 rounded-xl bg-background-default-lighter"
|
||||
/>
|
||||
))}
|
||||
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-linear-to-t from-background-body to-transparent">
|
||||
<span className="system-md-medium text-text-tertiary">
|
||||
{t('list.empty')}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function InstanceCardSkeleton() {
|
||||
return (
|
||||
<div className="relative col-span-1 inline-flex h-40 flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-xs">
|
||||
<div className="flex h-16.5 shrink-0 grow-0 items-center gap-3 px-3.5 pt-3.5 pb-3">
|
||||
<div className="relative shrink-0">
|
||||
<SkeletonRectangle className="my-0 size-10 animate-pulse rounded-lg" />
|
||||
<SkeletonRectangle className="absolute -right-0.5 -bottom-0.5 my-0 size-4 animate-pulse rounded-sm shadow-xs" />
|
||||
</div>
|
||||
<div className="flex w-0 grow flex-col gap-1.5 py-px">
|
||||
<SkeletonRectangle className="my-0 h-3.5 w-2/3 animate-pulse" />
|
||||
<SkeletonRectangle className="my-0 h-2.5 w-1/3 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex grow flex-col gap-2 px-3.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<SkeletonRectangle className="my-0 h-5 w-18 animate-pulse rounded-md" />
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<SkeletonRectangle className="my-0 size-3.5 animate-pulse rounded-sm" />
|
||||
<SkeletonRectangle className="my-0 h-3 w-3/4 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-1 left-0 flex h-10.5 shrink-0 items-center pt-1 pr-12 pb-1.5 pl-3.5">
|
||||
<div className="flex min-w-0 grow items-center gap-1.5">
|
||||
<SkeletonRectangle className="my-0 size-3.5 animate-pulse rounded-sm" />
|
||||
<SkeletonRectangle className="my-0 h-3 w-1/2 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-1.5 bottom-1 flex h-10.5 w-8 items-center justify-center">
|
||||
<SkeletonRectangle className="my-0 h-1 w-4 animate-pulse rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DeploymentsListSkeleton() {
|
||||
return INSTANCE_CARD_SKELETON_KEYS.map(key => (
|
||||
<InstanceCardSkeleton key={key} />
|
||||
))
|
||||
}
|
||||
|
||||
function DeploymentsSearchInput() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [keywords, setKeywords] = useQueryState('keywords', keywordsQueryState)
|
||||
|
||||
function handleKeywordsChange(next: string) {
|
||||
void setKeywords(next.trim() ? next : null, {
|
||||
limitUrlUpdates: next.trim() ? debounce(300) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
wrapperClassName="w-50"
|
||||
placeholder={t('filter.searchPlaceholder')}
|
||||
value={keywords}
|
||||
onChange={e => handleKeywordsChange(e.target.value)}
|
||||
onClear={() => handleKeywordsChange('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DeploymentsListControls() {
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-3 bg-background-body px-12 pt-7 pb-5">
|
||||
<CreateDeploymentButton />
|
||||
<div className="flex items-center gap-2">
|
||||
<EnvironmentFilter />
|
||||
<DeploymentsSearchInput />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeploymentsList() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const [envFilter] = useQueryState('env', envFilterQueryState)
|
||||
const [keywords] = useQueryState('keywords', keywordsQueryState)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
const queryKeywords = keywords.trim()
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isError,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
} = useInfiniteQuery({
|
||||
...consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({
|
||||
input: pageParam => ({
|
||||
query: {
|
||||
pageNumber: Number(pageParam),
|
||||
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
|
||||
...(envFilter === 'not-deployed' ? { notDeployed: true } : {}),
|
||||
...(queryKeywords ? { query: queryKeywords } : {}),
|
||||
},
|
||||
}),
|
||||
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
}),
|
||||
})
|
||||
const pages = data?.pages ?? []
|
||||
const apps = pages.flatMap(page => page.data ?? [])
|
||||
const showSkeleton = isLoading || (isFetching && pages.length === 0)
|
||||
const showEmptyState = !showSkeleton && !isError && apps.length === 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNextPage || isLoading || isFetchingNextPage || error)
|
||||
return
|
||||
|
||||
const anchor = anchorRef.current
|
||||
const container = containerRef.current
|
||||
if (!anchor || !container)
|
||||
return
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0]?.isIntersecting)
|
||||
void fetchNextPage()
|
||||
}, {
|
||||
root: container,
|
||||
rootMargin: '160px',
|
||||
threshold: 0.1,
|
||||
})
|
||||
|
||||
observer.observe(anchor)
|
||||
return () => observer.disconnect()
|
||||
}, [error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
|
||||
<DeploymentsListControls />
|
||||
<div className={cn(
|
||||
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5',
|
||||
showEmptyState && 'overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{showSkeleton
|
||||
? <DeploymentsListSkeleton />
|
||||
: isError
|
||||
? <DeploymentsListState>{t('common.loadFailed')}</DeploymentsListState>
|
||||
: apps.length === 0
|
||||
? <DeploymentsListEmpty />
|
||||
: apps.map(app => (
|
||||
<InstanceCard
|
||||
key={app.id}
|
||||
app={app}
|
||||
/>
|
||||
))}
|
||||
{isFetchingNextPage && <DeploymentsListSkeleton />}
|
||||
</div>
|
||||
|
||||
<div ref={anchorRef} className="h-0" />
|
||||
<div className="py-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,299 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AppInstance } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { InstanceDetailTabKey } from '../detail/tabs'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLinkItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AppTypeIcon } from '@/app/components/app/type-selector'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { toAppMode } from '../app-mode'
|
||||
import { environmentName } from '../environment'
|
||||
import { deploymentStatus } from '../runtime-status'
|
||||
|
||||
const INSTANCE_CARD_MENU_TAB_KEYS = ['deploy', 'releases', 'settings'] satisfies InstanceDetailTabKey[]
|
||||
|
||||
type EnvironmentStatusItem = {
|
||||
key: 'failed' | 'deploying' | 'ready'
|
||||
name: string
|
||||
label: string
|
||||
className: string
|
||||
}
|
||||
|
||||
function getInstanceTabHref(appInstanceId: string, tabKey: InstanceDetailTabKey) {
|
||||
return `/deployments/${appInstanceId}/${tabKey}`
|
||||
}
|
||||
|
||||
export function InstanceCard({ app }: {
|
||||
app: AppInstance
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const [isStatusTooltipOpen, setIsStatusTooltipOpen] = useState(false)
|
||||
const appInstanceId = app.id ?? ''
|
||||
const appName = app.name ?? appInstanceId
|
||||
const appMode = toAppMode(app.mode)
|
||||
const detailHref = `/deployments/${appInstanceId}/overview`
|
||||
|
||||
const statusCount = (status: string) =>
|
||||
app.statuses?.find(item => item.status === status)?.count ?? 0
|
||||
const failedCount = statusCount('failed') + statusCount('deploy_failed')
|
||||
const deployingCount = statusCount('deploying')
|
||||
const readyCount = statusCount('ready')
|
||||
const envCount = failedCount + deployingCount + readyCount
|
||||
|
||||
const lastDeployedAt = app.lastDeployedAt
|
||||
? Date.parse(app.lastDeployedAt)
|
||||
: null
|
||||
|
||||
const primaryStatus: 'none' | 'failed' | 'deploying' | 'ready' = envCount === 0
|
||||
? 'none'
|
||||
: failedCount > 0
|
||||
? 'failed'
|
||||
: deployingCount > 0
|
||||
? 'deploying'
|
||||
: 'ready'
|
||||
|
||||
const environmentDeploymentsQuery = useQuery({
|
||||
...consoleQuery.enterprise.appDeploymentService.listEnvironmentDeployments.queryOptions({
|
||||
input: {
|
||||
params: { appInstanceId },
|
||||
},
|
||||
}),
|
||||
enabled: isStatusTooltipOpen && primaryStatus !== 'none' && !!appInstanceId,
|
||||
})
|
||||
|
||||
const primaryText = primaryStatus === 'none'
|
||||
? t('card.notDeployed')
|
||||
: primaryStatus === 'failed'
|
||||
? t('card.failed', { count: failedCount })
|
||||
: primaryStatus === 'deploying'
|
||||
? t('card.deploying', { count: deployingCount })
|
||||
: t('card.ready', { count: readyCount })
|
||||
|
||||
const secondaryParts: string[] = []
|
||||
if (primaryStatus === 'failed' && deployingCount > 0)
|
||||
secondaryParts.push(t('card.deploying', { count: deployingCount }))
|
||||
if ((primaryStatus === 'failed' || primaryStatus === 'deploying') && readyCount > 0)
|
||||
secondaryParts.push(t('card.ready', { count: readyCount }))
|
||||
|
||||
const environmentDeployments = environmentDeploymentsQuery.data?.data?.filter(row => row.environment?.id) ?? []
|
||||
const environmentStatusItems = environmentDeployments.flatMap<EnvironmentStatusItem>((row) => {
|
||||
const status = deploymentStatus(row)
|
||||
if (status === 'deploy_failed') {
|
||||
return [{
|
||||
key: 'failed',
|
||||
name: environmentName(row.environment),
|
||||
label: t('status.deployFailed'),
|
||||
className: 'text-util-colors-red-red-700',
|
||||
}]
|
||||
}
|
||||
if (status === 'deploying') {
|
||||
return [{
|
||||
key: 'deploying',
|
||||
name: environmentName(row.environment),
|
||||
label: t('status.deploying'),
|
||||
className: 'text-util-colors-warning-warning-700',
|
||||
}]
|
||||
}
|
||||
if (status === 'ready') {
|
||||
return [{
|
||||
key: 'ready',
|
||||
name: environmentName(row.environment),
|
||||
label: t('status.ready'),
|
||||
className: 'text-util-colors-green-green-700',
|
||||
}]
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const statusTooltip = primaryStatus === 'none'
|
||||
? t('card.tooltip.notDeployed')
|
||||
: (
|
||||
<div className="flex min-w-45 flex-col gap-1">
|
||||
{environmentStatusItems.map(item => (
|
||||
<div key={`${item.key}-${item.name}`} className="flex min-w-0 justify-between gap-3">
|
||||
<span className="truncate text-text-secondary" title={item.name}>{item.name}</span>
|
||||
<span className={cn('shrink-0', item.className)}>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{environmentStatusItems.length === 0 && !environmentDeploymentsQuery.isLoading && !environmentDeploymentsQuery.isError && (
|
||||
<>
|
||||
{failedCount > 0 && (
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-text-tertiary">{t('status.deployFailed')}</span>
|
||||
<span className="text-text-secondary">{failedCount}</span>
|
||||
</div>
|
||||
)}
|
||||
{deployingCount > 0 && (
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-text-tertiary">{t('status.deploying')}</span>
|
||||
<span className="text-text-secondary">{deployingCount}</span>
|
||||
</div>
|
||||
)}
|
||||
{readyCount > 0 && (
|
||||
<div className="flex justify-between gap-3">
|
||||
<span className="text-text-tertiary">{t('status.ready')}</span>
|
||||
<span className="text-text-secondary">{readyCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{environmentDeploymentsQuery.isLoading && (
|
||||
<div className="text-text-quaternary">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)}
|
||||
{environmentDeploymentsQuery.isError && (
|
||||
<div className="text-util-colors-warning-warning-700">
|
||||
{t('common.loadFailed')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!app.id)
|
||||
return null
|
||||
|
||||
const healthPillClass = primaryStatus === 'none'
|
||||
? 'text-text-tertiary bg-background-section-burn'
|
||||
: primaryStatus === 'failed'
|
||||
? 'text-util-colors-red-red-700 bg-util-colors-red-red-50'
|
||||
: primaryStatus === 'deploying'
|
||||
? 'text-util-colors-warning-warning-700 bg-util-colors-warning-warning-50'
|
||||
: 'text-util-colors-green-green-700 bg-util-colors-green-green-50'
|
||||
|
||||
const healthDotClass = primaryStatus === 'none'
|
||||
? 'bg-text-quaternary'
|
||||
: primaryStatus === 'failed'
|
||||
? 'bg-util-colors-red-red-500'
|
||||
: primaryStatus === 'deploying'
|
||||
? 'bg-util-colors-warning-warning-500 animate-pulse'
|
||||
: 'bg-util-colors-green-green-500'
|
||||
|
||||
const appModeLabel = t(`appMode.${appMode}`, { defaultValue: appMode })
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative col-span-1 inline-flex h-40 cursor-pointer flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-xs transition-all duration-200 ease-in-out hover:shadow-lg"
|
||||
>
|
||||
<Link
|
||||
href={detailHref}
|
||||
className="flex h-full flex-col rounded-xl outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<div className="flex h-16.5 shrink-0 grow-0 items-center gap-3 px-3.5 pt-3.5 pb-3">
|
||||
<div className="relative shrink-0">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType="emoji"
|
||||
icon={app.icon}
|
||||
background={app.iconBackground}
|
||||
/>
|
||||
<AppTypeIcon
|
||||
type={appMode}
|
||||
wrapperClassName="absolute -bottom-0.5 -right-0.5 size-4 shadow-xs"
|
||||
className="size-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-0 grow py-px">
|
||||
<div className="flex items-center text-sm/5 font-semibold text-text-secondary">
|
||||
<div className="truncate" title={appName}>{appName}</div>
|
||||
</div>
|
||||
<div className="truncate text-2xs/4.5 font-medium text-text-tertiary" title={appModeLabel}>
|
||||
{appModeLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex grow flex-col gap-2 px-3.5">
|
||||
<Tooltip open={isStatusTooltipOpen} onOpenChange={setIsStatusTooltipOpen}>
|
||||
<TooltipTrigger
|
||||
render={(
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-5 shrink-0 items-center gap-1 rounded-md px-1.5 system-xs-medium',
|
||||
healthPillClass,
|
||||
)}
|
||||
>
|
||||
<span className={cn('size-1.5 rounded-full', healthDotClass)} />
|
||||
{primaryText}
|
||||
</span>
|
||||
{secondaryParts.length > 0 && (
|
||||
<span className="truncate system-xs-regular text-text-tertiary">
|
||||
{secondaryParts.join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>{statusTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex min-w-0 items-center gap-1.5 system-xs-regular text-text-tertiary">
|
||||
<span aria-hidden className="i-ri-apps-2-line size-3.5 shrink-0 text-text-quaternary" />
|
||||
<span className="truncate" title={app.sourceAppName ?? appName}>
|
||||
{t('card.fromApp', { name: app.sourceAppName ?? appName })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-1 left-0 flex h-10.5 shrink-0 items-center pt-1 pr-12 pb-1.5 pl-3.5">
|
||||
<div className="flex min-w-0 grow items-center gap-1.5 system-xs-regular text-text-tertiary">
|
||||
<span aria-hidden className="i-ri-time-line size-3.5 shrink-0 text-text-quaternary" />
|
||||
<span className="truncate">
|
||||
{lastDeployedAt
|
||||
? t('card.lastDeployed', { time: formatTimeFromNow(lastDeployedAt) })
|
||||
: t('card.neverDeployed')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="pointer-events-auto absolute right-1.5 bottom-1 flex h-10.5 items-center">
|
||||
<InstanceCardActions appInstanceId={appInstanceId} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InstanceCardActions({ appInstanceId }: {
|
||||
appInstanceId: string
|
||||
}) {
|
||||
const { t } = useTranslation('deployments')
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t('card.moreActions')}
|
||||
className={cn(
|
||||
'flex size-8 items-center justify-center rounded-md border-none bg-transparent p-2 hover:bg-state-base-hover data-popup-open:bg-state-base-hover data-popup-open:shadow-none',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-more-fill size-4 text-text-tertiary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-54">
|
||||
{INSTANCE_CARD_MENU_TAB_KEYS.map((tabKey) => {
|
||||
const href = getInstanceTabHref(appInstanceId, tabKey)
|
||||
|
||||
return (
|
||||
<DropdownMenuLinkItem
|
||||
key={tabKey}
|
||||
className="gap-2 px-3"
|
||||
render={<Link href={href} />}
|
||||
>
|
||||
<span className="system-sm-regular text-text-secondary">{t(`tabs.${tabKey}.name`)}</span>
|
||||
</DropdownMenuLinkItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
import { parseAsString } from 'nuqs'
|
||||
|
||||
export const envFilterQueryState = parseAsString.withDefault('all').withOptions({ history: 'push' })
|
||||
export const keywordsQueryState = parseAsString.withDefault('').withOptions({ history: 'push' })
|
||||
@ -1,119 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { AppInstance, AppInstanceBasicInfo } from '@dify/contracts/enterprise/types.gen'
|
||||
import type { NavItem } from '@/app/components/header/nav/nav-selector'
|
||||
import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Nav from '@/app/components/header/nav'
|
||||
import { useParams, useSelectedLayoutSegment } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { toAppMode } from '../app-mode'
|
||||
import { CreateInstanceModal } from '../components/create-instance-modal'
|
||||
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../data'
|
||||
|
||||
function navItemFromListApp(app: AppInstance): NavItem[] {
|
||||
if (!app.id || !app.name)
|
||||
return []
|
||||
|
||||
return [{
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
link: `/deployments/${app.id}/overview`,
|
||||
icon_type: 'emoji',
|
||||
icon: app.icon ?? '',
|
||||
icon_background: app.iconBackground ?? null,
|
||||
icon_url: null,
|
||||
mode: toAppMode(app.mode),
|
||||
}]
|
||||
}
|
||||
|
||||
function navItemFromOverview(instance?: AppInstanceBasicInfo): NavItem | undefined {
|
||||
if (!instance?.id)
|
||||
return undefined
|
||||
|
||||
const name = instance.name ?? instance.id
|
||||
|
||||
return {
|
||||
id: instance.id,
|
||||
name,
|
||||
link: `/deployments/${instance.id}/overview`,
|
||||
icon_type: 'emoji',
|
||||
icon: instance.icon ?? '',
|
||||
icon_background: instance.iconBackground ?? null,
|
||||
icon_url: null,
|
||||
mode: toAppMode(instance.mode),
|
||||
}
|
||||
}
|
||||
|
||||
export function DeploymentsNav() {
|
||||
const { t } = useTranslation()
|
||||
const selectedSegment = useSelectedLayoutSegment()
|
||||
const isActive = selectedSegment === 'deployments'
|
||||
const params = useParams<{ appInstanceId?: string }>()
|
||||
const appInstanceId = params?.appInstanceId
|
||||
const hasAppInstanceId = Boolean(appInstanceId)
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||
|
||||
const { data: currentInstance } = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstanceOverview.queryOptions({
|
||||
input: { params: { appInstanceId: appInstanceId ?? '' } },
|
||||
enabled: isActive && hasAppInstanceId,
|
||||
select: data => data.overview?.appInstance,
|
||||
}))
|
||||
|
||||
const listQuery = useInfiniteQuery({
|
||||
...consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({
|
||||
input: pageParam => ({
|
||||
query: {
|
||||
pageNumber: Number(pageParam),
|
||||
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
|
||||
},
|
||||
}),
|
||||
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
|
||||
initialPageParam: 1,
|
||||
placeholderData: keepPreviousData,
|
||||
}),
|
||||
enabled: isActive,
|
||||
})
|
||||
const appNavItems = listQuery.data?.pages.flatMap(page => page.data?.flatMap(navItemFromListApp) ?? []) ?? []
|
||||
const currentNavItem = navItemFromOverview(currentInstance)
|
||||
|
||||
const navigationItems: NavItem[] = isActive
|
||||
? currentNavItem && !appNavItems.some(item => item.id === currentNavItem.id)
|
||||
? [...appNavItems, currentNavItem]
|
||||
: appNavItems
|
||||
: []
|
||||
|
||||
const curNav = appInstanceId
|
||||
? navigationItems.find(item => item.id === appInstanceId)
|
||||
: undefined
|
||||
|
||||
function handleCreate() {
|
||||
setCreateModalOpen(true)
|
||||
}
|
||||
|
||||
function handleLoadMore() {
|
||||
if (listQuery.hasNextPage && !listQuery.isFetchingNextPage)
|
||||
void listQuery.fetchNextPage()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav
|
||||
isApp={false}
|
||||
icon={<span aria-hidden className="i-ri-rocket-line size-4" />}
|
||||
activeIcon={<span aria-hidden className="i-ri-rocket-fill size-4" />}
|
||||
text={t('menus.deployments', { ns: 'common' })}
|
||||
activeSegment="deployments"
|
||||
link="/deployments"
|
||||
curNav={curNav}
|
||||
navigationItems={navigationItems}
|
||||
createText={t('deployments:createModal.title')}
|
||||
onCreate={handleCreate}
|
||||
onLoadMore={handleLoadMore}
|
||||
isLoadingMore={listQuery.isFetchingNextPage}
|
||||
/>
|
||||
<CreateInstanceModal open={createModalOpen} onOpenChange={setCreateModalOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -1,69 +0,0 @@
|
||||
import type { ReleaseRow, ReleaseSummary } from '@dify/contracts/enterprise/types.gen'
|
||||
|
||||
export type ReleaseDeploymentAction = 'deploy' | 'deployExistingRelease' | 'promote' | 'rollback'
|
||||
|
||||
function releaseCreatedAt(release?: ReleaseSummary | ReleaseRow) {
|
||||
const value = release?.createdAt
|
||||
if (!value)
|
||||
return undefined
|
||||
|
||||
const time = Date.parse(value)
|
||||
return Number.isFinite(time) ? time : undefined
|
||||
}
|
||||
|
||||
function releaseById(releaseRows: ReleaseRow[], releaseId?: string) {
|
||||
return releaseRows.find(release => release.id === releaseId)
|
||||
}
|
||||
|
||||
function releaseOrderIndex(releaseRows: ReleaseRow[], releaseId?: string) {
|
||||
return releaseRows.findIndex(release => release.id === releaseId)
|
||||
}
|
||||
|
||||
function compareReleaseOrder(targetRelease: ReleaseSummary | ReleaseRow | undefined, currentRelease: ReleaseSummary, releaseRows: ReleaseRow[]) {
|
||||
if (!targetRelease?.id || !currentRelease.id)
|
||||
return undefined
|
||||
if (targetRelease.id === currentRelease.id)
|
||||
return 0
|
||||
|
||||
const normalizedTargetRelease = releaseById(releaseRows, targetRelease.id) ?? targetRelease
|
||||
const normalizedCurrentRelease = releaseById(releaseRows, currentRelease.id) ?? currentRelease
|
||||
const targetCreatedAt = releaseCreatedAt(normalizedTargetRelease)
|
||||
const currentCreatedAt = releaseCreatedAt(normalizedCurrentRelease)
|
||||
|
||||
if (targetCreatedAt !== undefined && currentCreatedAt !== undefined && targetCreatedAt !== currentCreatedAt)
|
||||
return targetCreatedAt > currentCreatedAt ? 1 : -1
|
||||
|
||||
const targetIndex = releaseOrderIndex(releaseRows, targetRelease.id)
|
||||
const currentIndex = releaseOrderIndex(releaseRows, currentRelease.id)
|
||||
if (targetIndex >= 0 && currentIndex >= 0 && targetIndex !== currentIndex)
|
||||
return targetIndex < currentIndex ? 1 : -1
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function releaseDeploymentAction({
|
||||
targetRelease,
|
||||
currentRelease,
|
||||
releaseRows,
|
||||
isExistingRelease,
|
||||
}: {
|
||||
targetRelease?: ReleaseSummary | ReleaseRow
|
||||
currentRelease?: ReleaseSummary
|
||||
releaseRows: ReleaseRow[]
|
||||
isExistingRelease?: boolean
|
||||
}): ReleaseDeploymentAction {
|
||||
if (!currentRelease?.id)
|
||||
return isExistingRelease ? 'deployExistingRelease' : 'deploy'
|
||||
|
||||
const order = compareReleaseOrder(targetRelease, currentRelease, releaseRows)
|
||||
if (order === -1)
|
||||
return 'rollback'
|
||||
if (order === 1)
|
||||
return 'promote'
|
||||
|
||||
return targetRelease?.id && targetRelease.id !== currentRelease.id
|
||||
? 'promote'
|
||||
: isExistingRelease
|
||||
? 'deployExistingRelease'
|
||||
: 'deploy'
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import type { ReleaseRow, ReleaseSummary } from '@dify/contracts/enterprise/types.gen'
|
||||
|
||||
export function formatDate(value?: string) {
|
||||
if (!value)
|
||||
return '—'
|
||||
return value.replace('T', ' ').replace(/\.\d+Z?$/, '').replace(/Z$/, '').slice(0, 16)
|
||||
}
|
||||
|
||||
export function releaseLabel(release?: ReleaseSummary | ReleaseRow) {
|
||||
return release?.name || release?.id || '—'
|
||||
}
|
||||
|
||||
export function releaseCommit(release?: ReleaseSummary | ReleaseRow) {
|
||||
return release && 'shortCommitId' in release ? release.shortCommitId || '—' : '—'
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import type { ReleaseRuntimeBinding } from '@dify/contracts/enterprise/types.gen'
|
||||
|
||||
export function runtimeBindingSummary(binding?: ReleaseRuntimeBinding) {
|
||||
return binding?.name || binding?.displayValue || binding?.kind || '—'
|
||||
}
|
||||
|
||||
export function isRuntimeEnvVarBinding(binding?: ReleaseRuntimeBinding) {
|
||||
return (binding?.kind?.toLowerCase() ?? '').includes('env')
|
||||
}
|
||||
|
||||
export function isRuntimeModelBinding(binding?: ReleaseRuntimeBinding) {
|
||||
return (binding?.kind?.toLowerCase() ?? '').includes('model')
|
||||
}
|
||||
|
||||
export function isRuntimePluginBinding(binding?: ReleaseRuntimeBinding) {
|
||||
return !isRuntimeEnvVarBinding(binding) && !isRuntimeModelBinding(binding)
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
|
||||
|
||||
type DeploymentUiStatus = 'ready' | 'deploying' | 'deploy_failed' | 'unknown'
|
||||
|
||||
export const DEPLOYMENT_STATUS_POLLING_INTERVAL = 3000
|
||||
|
||||
type DeploymentStatusQueryData = {
|
||||
data?: Array<Pick<EnvironmentDeployment, 'status'>>
|
||||
}
|
||||
|
||||
export function isUndeployedDeploymentRow(row?: EnvironmentDeployment) {
|
||||
return (row?.status?.toLowerCase() ?? '').includes('undeployed') || (!row?.runtime?.runtimeInstanceId && !row?.currentRelease && !row?.runtime)
|
||||
}
|
||||
|
||||
export function deploymentStatus(row?: Pick<EnvironmentDeployment, 'status'>): DeploymentUiStatus {
|
||||
const runtimeStatus = row?.status?.toLowerCase() ?? ''
|
||||
if (!runtimeStatus || runtimeStatus.includes('undeployed'))
|
||||
return 'unknown'
|
||||
if (runtimeStatus.includes('deploying') || runtimeStatus.includes('pending'))
|
||||
return 'deploying'
|
||||
if (runtimeStatus.includes('fail') || runtimeStatus.includes('error'))
|
||||
return 'deploy_failed'
|
||||
if (runtimeStatus.includes('ready')
|
||||
|| runtimeStatus.includes('running')
|
||||
|| runtimeStatus.includes('active')
|
||||
|| runtimeStatus.includes('success')
|
||||
|| runtimeStatus.includes('succeed')
|
||||
|| runtimeStatus.includes('deployed')) {
|
||||
return 'ready'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export function hasDeployingDeployment(rows?: Array<Pick<EnvironmentDeployment, 'status'>>) {
|
||||
return rows?.some(row => deploymentStatus(row) === 'deploying') ?? false
|
||||
}
|
||||
|
||||
export function deploymentStatusPollingInterval(data?: DeploymentStatusQueryData) {
|
||||
return hasDeployingDeployment(data?.data) ? DEPLOYMENT_STATUS_POLLING_INTERVAL : false
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
type OpenDeployDrawerParams = {
|
||||
appInstanceId: string
|
||||
environmentId?: string
|
||||
releaseId?: string
|
||||
}
|
||||
|
||||
export const deployDrawerOpenAtom = atom(false)
|
||||
export const deployDrawerAppInstanceIdAtom = atom<string | undefined>(undefined)
|
||||
export const deployDrawerEnvironmentIdAtom = atom<string | undefined>(undefined)
|
||||
export const deployDrawerReleaseIdAtom = atom<string | undefined>(undefined)
|
||||
|
||||
export const openDeployDrawerAtom = atom(null, (_get, set, params: OpenDeployDrawerParams) => {
|
||||
set(deployDrawerAppInstanceIdAtom, params.appInstanceId)
|
||||
set(deployDrawerEnvironmentIdAtom, params.environmentId)
|
||||
set(deployDrawerReleaseIdAtom, params.releaseId)
|
||||
set(deployDrawerOpenAtom, true)
|
||||
})
|
||||
export const closeDeployDrawerAtom = atom(null, (_get, set) => {
|
||||
set(deployDrawerOpenAtom, false)
|
||||
set(deployDrawerAppInstanceIdAtom, undefined)
|
||||
set(deployDrawerEnvironmentIdAtom, undefined)
|
||||
set(deployDrawerReleaseIdAtom, undefined)
|
||||
})
|
||||
@ -1,26 +0,0 @@
|
||||
import { PUBLIC_API_PREFIX } from '@/config'
|
||||
|
||||
const absoluteUrlRegExp = /^[a-z][a-z\d+.-]*:\/\//i
|
||||
|
||||
function withLeadingSlash(path: string) {
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
}
|
||||
|
||||
function publicWebappOrigin() {
|
||||
try {
|
||||
return new URL(PUBLIC_API_PREFIX).origin
|
||||
}
|
||||
catch {
|
||||
return PUBLIC_API_PREFIX.replace(/\/api\/?$/, '').replace(/\/+$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
export function webappUrl(url?: string) {
|
||||
if (!url)
|
||||
return ''
|
||||
if (absoluteUrlRegExp.test(url))
|
||||
return url
|
||||
|
||||
const origin = publicWebappOrigin()
|
||||
return `${origin}${withLeadingSlash(url)}`
|
||||
}
|
||||
@ -13,7 +13,6 @@ import type datasetHitTesting from '../i18n/en-US/dataset-hit-testing.json'
|
||||
import type datasetPipeline from '../i18n/en-US/dataset-pipeline.json'
|
||||
import type datasetSettings from '../i18n/en-US/dataset-settings.json'
|
||||
import type dataset from '../i18n/en-US/dataset.json'
|
||||
import type deployments from '../i18n/en-US/deployments.json'
|
||||
import type education from '../i18n/en-US/education.json'
|
||||
import type explore from '../i18n/en-US/explore.json'
|
||||
import type layout from '../i18n/en-US/layout.json'
|
||||
@ -47,7 +46,6 @@ export type Resources = {
|
||||
datasetHitTesting: typeof datasetHitTesting
|
||||
datasetPipeline: typeof datasetPipeline
|
||||
datasetSettings: typeof datasetSettings
|
||||
deployments: typeof deployments
|
||||
education: typeof education
|
||||
explore: typeof explore
|
||||
layout: typeof layout
|
||||
@ -81,7 +79,6 @@ export const namespaces = [
|
||||
'datasetHitTesting',
|
||||
'datasetPipeline',
|
||||
'datasetSettings',
|
||||
'deployments',
|
||||
'education',
|
||||
'explore',
|
||||
'layout',
|
||||
|
||||
@ -278,7 +278,6 @@
|
||||
"menus.apps": "Studio",
|
||||
"menus.datasets": "Knowledge",
|
||||
"menus.datasetsTips": "COMING SOON: Import your own text data or write data in real-time via Webhook for LLM context enhancement.",
|
||||
"menus.deployments": "Deployments",
|
||||
"menus.explore": "Explore",
|
||||
"menus.exploreMarketplace": "Explore Marketplace",
|
||||
"menus.newApp": "New App",
|
||||
|
||||
@ -1,452 +0,0 @@
|
||||
{
|
||||
"access.api.backendTitle": "Backend service API",
|
||||
"access.api.description": "Access this instance over HTTP. Each API key is scoped to one environment.",
|
||||
"access.api.developerTitle": "Developer API",
|
||||
"access.api.disabled": "API access is turned off for this instance.",
|
||||
"access.api.dismissToken": "Dismiss token",
|
||||
"access.api.empty": "Deploy to an environment first to start issuing API keys.",
|
||||
"access.api.endpoint": "Endpoint",
|
||||
"access.api.envPrefix": "env: {{env}}",
|
||||
"access.api.keyList": "API key list",
|
||||
"access.api.newKey": "New key",
|
||||
"access.api.newKeyForEnv": "Generate for {{env}}",
|
||||
"access.api.newTokenDescription": "This token is shown only once. Copy it before leaving this page.",
|
||||
"access.api.newTokenLabel": "Token",
|
||||
"access.api.newTokenTitle": "API key created",
|
||||
"access.api.noKeys": "No API keys yet. Create one to start calling the API.",
|
||||
"access.api.title": "API",
|
||||
"access.channels.description": "WebApp and CLI entry points use the access permissions above.",
|
||||
"access.channels.disabled": "Access channels are turned off for this instance.",
|
||||
"access.channels.followPermission": "Follows permissions",
|
||||
"access.channels.title": "Access channels",
|
||||
"access.cli.description": "Use the unified domain when accessing this instance from the CLI.",
|
||||
"access.cli.docs": "Usage guide",
|
||||
"access.cli.domain": "Domain",
|
||||
"access.cli.empty": "CLI endpoint not configured.",
|
||||
"access.cli.install": "Install CLI",
|
||||
"access.cli.title": "CLI",
|
||||
"access.copied": "Copied",
|
||||
"access.copy": "Copy",
|
||||
"access.copyFailed": "Copy failed",
|
||||
"access.copyToast": "Copied to clipboard",
|
||||
"access.hide": "Hide",
|
||||
"access.members.clearAll": "Clear all",
|
||||
"access.members.empty": "No matches found.",
|
||||
"access.members.emptySelection": "Choose at least one group or member to save this permission.",
|
||||
"access.members.groupCount_one": "{{count}} group",
|
||||
"access.members.groupCount_other": "{{count}} groups",
|
||||
"access.members.groups": "Groups",
|
||||
"access.members.individuals": "Members",
|
||||
"access.members.memberCount_one": "{{count}} member",
|
||||
"access.members.memberCount_other": "{{count}} members",
|
||||
"access.members.pickPlaceholder": "Select groups or members",
|
||||
"access.members.searchPlaceholder": "Search groups and members",
|
||||
"access.members.selectedLabel": "Selected",
|
||||
"access.permission.anyone": "Anyone",
|
||||
"access.permission.anyoneDesc": "Anyone with the link, no login required",
|
||||
"access.permission.comingSoon": "Coming soon",
|
||||
"access.permission.external": "Authenticated external users",
|
||||
"access.permission.externalDesc": "External users who completed SSO/OIDC authentication",
|
||||
"access.permission.memberCount_one": "{{count}} member",
|
||||
"access.permission.memberCount_other": "{{count}} members",
|
||||
"access.permission.organization": "Only members within the organization",
|
||||
"access.permission.organizationDesc": "All internal members of your workspace",
|
||||
"access.permission.specific": "Specific members",
|
||||
"access.permission.specificDesc": "Pick groups or individual members",
|
||||
"access.permission.specificUnavailable": "Specific member selection is disabled until real workspace subjects are connected.",
|
||||
"access.permission.updateFailed": "Failed to update access policy.",
|
||||
"access.permissions.description": "Configure who can access this instance in each deployed environment.",
|
||||
"access.permissions.title": "Access permissions",
|
||||
"access.revoke": "Revoke",
|
||||
"access.runAccess.description": "Manage how users can run this app and who is allowed to access it per environment.",
|
||||
"access.runAccess.disabled": "Run access is turned off for this instance.",
|
||||
"access.runAccess.mcp": "MCP",
|
||||
"access.runAccess.mcpDesc": "Expose this instance as a Model Context Protocol server.",
|
||||
"access.runAccess.mcpEmpty": "MCP endpoint not configured.",
|
||||
"access.runAccess.noEnvs": "Deploy to an environment to configure access permissions.",
|
||||
"access.runAccess.openWebapp": "Open WebApp",
|
||||
"access.runAccess.permissions": "Access permissions",
|
||||
"access.runAccess.permissionsDesc": "Who can access this instance in each environment.",
|
||||
"access.runAccess.title": "Run access",
|
||||
"access.runAccess.urlLabel": "URL",
|
||||
"access.runAccess.webapp": "WebApp",
|
||||
"access.runAccess.webappDesc": "Hosted web page for end users.",
|
||||
"access.runAccess.webappEmpty": "WebApp URL not configured.",
|
||||
"access.show": "Show",
|
||||
"appMode.advanced-chat": "Chatflow",
|
||||
"appMode.agent-chat": "Agent",
|
||||
"appMode.chat": "Chatbot",
|
||||
"appMode.completion": "Completion",
|
||||
"appMode.workflow": "Workflow",
|
||||
"card.deploying": "{{count}} deploying",
|
||||
"card.failed": "{{count}} failed",
|
||||
"card.fromApp": "From {{name}}",
|
||||
"card.lastDeployed": "Last deployed {{time}}",
|
||||
"card.menu.delete": "Delete instance",
|
||||
"card.menu.deleteDisabled": "Instance deletion is not available for backend-managed deployments yet.",
|
||||
"card.menu.deploy": "Deploy to an environment",
|
||||
"card.menu.viewDetail": "View instance detail",
|
||||
"card.moreActions": "More actions",
|
||||
"card.neverDeployed": "Not deployed yet",
|
||||
"card.notDeployed": "Not deployed",
|
||||
"card.ready": "{{count}} ready",
|
||||
"card.tooltip.notDeployed": "This instance has not been deployed to any environment yet.",
|
||||
"common.loadFailed": "Failed to load. Try again later.",
|
||||
"common.loading": "Loading...",
|
||||
"createGuide.actions.back": "Back",
|
||||
"createGuide.actions.cancel": "Cancel",
|
||||
"createGuide.actions.continue": "Continue",
|
||||
"createGuide.actions.createAndDeploy": "Create and deploy",
|
||||
"createGuide.actions.deploying": "Deploying...",
|
||||
"createGuide.description": "Create a usable deployment through source, release, bindings, and environment selection.",
|
||||
"createGuide.done.backToList": "Back to deployments",
|
||||
"createGuide.done.description": "Deployment has started for {{environment}}.",
|
||||
"createGuide.done.next": "You can review status, access, and release history from the deployments list.",
|
||||
"createGuide.done.ready": "The deployment request has been submitted.",
|
||||
"createGuide.done.title": "Deployment started",
|
||||
"createGuide.dsl.defaultAppName": "Imported DSL app",
|
||||
"createGuide.dsl.description": "Prepare an app instance from a DSL package. Import execution will be wired when the backend API is ready.",
|
||||
"createGuide.dsl.dropDescription": "DSL import is a UI placeholder for now. The final deploy action will stay disabled from real import work until the API is available.",
|
||||
"createGuide.dsl.dropTitle": "Upload DSL package",
|
||||
"createGuide.dsl.title": "Import DSL",
|
||||
"createGuide.errors.deployFailed": "Failed to create and deploy the app instance.",
|
||||
"createGuide.method.description": "Start from an app already in Studio or prepare a deployment from a DSL package.",
|
||||
"createGuide.methods.bindApp.description": "Use an app that already exists in Studio as the release source.",
|
||||
"createGuide.methods.bindApp.title": "Bind existing Studio app",
|
||||
"createGuide.methods.importDsl.description": "Upload a YAML DSL package and continue through the deployment UI.",
|
||||
"createGuide.methods.importDsl.title": "Import DSL",
|
||||
"createGuide.methods.mocked": "Mocked",
|
||||
"createGuide.nav.back": "Deployments",
|
||||
"createGuide.release.defaultNote": "Initial deployable release",
|
||||
"createGuide.release.description": "Name the deployable instance and capture the first release metadata.",
|
||||
"createGuide.release.instanceName": "Instance name",
|
||||
"createGuide.release.releaseName": "Release name",
|
||||
"createGuide.release.releaseNote": "Release note",
|
||||
"createGuide.release.title": "Create release",
|
||||
"createGuide.review.bindings": "Runtime bindings",
|
||||
"createGuide.review.description": "Confirm the source, release, credentials, and target environment before deployment.",
|
||||
"createGuide.review.environment": "Environment",
|
||||
"createGuide.review.instance": "Instance",
|
||||
"createGuide.review.plan.createInstance": "Create the app instance",
|
||||
"createGuide.review.plan.createRelease": "Create release {{release}}",
|
||||
"createGuide.review.plan.deployTo": "Deploy to {{environment}}",
|
||||
"createGuide.review.plan.resolveBindings": "Resolve runtime credentials",
|
||||
"createGuide.review.planTitle": "Execution plan",
|
||||
"createGuide.review.release": "Release",
|
||||
"createGuide.review.releaseNote": "Release note",
|
||||
"createGuide.review.source": "Source",
|
||||
"createGuide.review.summary": "Deployment summary",
|
||||
"createGuide.review.title": "Review deployment",
|
||||
"createGuide.source.description": "Choose the Studio app that will become the first release source.",
|
||||
"createGuide.source.empty": "No Studio apps found.",
|
||||
"createGuide.source.searchPlaceholder": "Search apps",
|
||||
"createGuide.source.sourceApp": "Source app",
|
||||
"createGuide.source.title": "Choose source",
|
||||
"createGuide.steps.done": "Done",
|
||||
"createGuide.steps.method": "Select a method",
|
||||
"createGuide.steps.release": "Create release",
|
||||
"createGuide.steps.review": "Create and deploy",
|
||||
"createGuide.steps.source": "Choose source",
|
||||
"createGuide.steps.target": "Deploy target",
|
||||
"createGuide.target.bindingHint": "Pick the credentials that will be used when this release runs.",
|
||||
"createGuide.target.bindings": "Runtime bindings",
|
||||
"createGuide.target.description": "Select the runtime environment and resolve the credentials used by the release.",
|
||||
"createGuide.target.environment": "Target environment",
|
||||
"createGuide.target.missingRequiredBinding": "Select a credential for this required binding.",
|
||||
"createGuide.target.noBindingRequired": "No runtime binding required.",
|
||||
"createGuide.target.noCredentialCandidates": "No available credentials.",
|
||||
"createGuide.target.required": "Required",
|
||||
"createGuide.target.selectCredential": "Select a credential",
|
||||
"createGuide.target.title": "Select deploy target",
|
||||
"createGuide.title": "Create deployment",
|
||||
"createModal.appPickerPlaceholder": "Select a source app",
|
||||
"createModal.appSearchEmpty": "No matching apps",
|
||||
"createModal.appSearchPlaceholder": "Search apps…",
|
||||
"createModal.cancel": "Cancel",
|
||||
"createModal.create": "Create",
|
||||
"createModal.createFailed": "Failed to create app instance.",
|
||||
"createModal.description": "Pick a source app from Studio and create a deployable instance.",
|
||||
"createModal.descriptionLabel": "Description",
|
||||
"createModal.descriptionPlaceholder": "Describe what this instance is used for",
|
||||
"createModal.loadMoreApps": "Load more apps",
|
||||
"createModal.loadingApps": "Loading apps…",
|
||||
"createModal.nameLabel": "Instance name",
|
||||
"createModal.namePlaceholder": "Instance name",
|
||||
"createModal.noApps": "No apps found in this workspace. Create one in Studio first.",
|
||||
"createModal.selected": "Selected",
|
||||
"createModal.sourceApp": "Source app (required)",
|
||||
"createModal.title": "Create app instance",
|
||||
"deployDrawer.bindingOptionsFailed": "Failed to load credential options.",
|
||||
"deployDrawer.bindingSelectionHint": "Choose the credentials used by this deployment.",
|
||||
"deployDrawer.bindingsDisabled": "Resolved from the release preview. Editing is not available yet.",
|
||||
"deployDrawer.cancel": "Cancel",
|
||||
"deployDrawer.defaultSelect": "Select...",
|
||||
"deployDrawer.deploy": "Deploy",
|
||||
"deployDrawer.deployExistingRelease": "Deploy existing release",
|
||||
"deployDrawer.deployExistingReleaseDescription": "Deploy an existing release from the release history to a target environment.",
|
||||
"deployDrawer.deployExistingReleaseTitle": "Deploy existing release",
|
||||
"deployDrawer.deployFailed": "Failed to start deployment.",
|
||||
"deployDrawer.deploying": "Deploying...",
|
||||
"deployDrawer.description": "Select a release and deploy it to a target environment.",
|
||||
"deployDrawer.envVars": "Environment variables",
|
||||
"deployDrawer.existingReleaseHint": "This existing release will be deployed as-is. No new release will be created.",
|
||||
"deployDrawer.loadingBindings": "Resolving...",
|
||||
"deployDrawer.lockedHint": "Locked to current environment",
|
||||
"deployDrawer.missingRequiredBinding": "Select a credential for this required binding.",
|
||||
"deployDrawer.modelCreds": "Model credentials",
|
||||
"deployDrawer.needsValidation": " (needs validation)",
|
||||
"deployDrawer.newReleaseHint": "A new release will be created from the current app YAML.",
|
||||
"deployDrawer.noBindingRequired": "Not required",
|
||||
"deployDrawer.noCredentialCandidates": "No available credentials.",
|
||||
"deployDrawer.noReleaseAvailable": "Create a release before deploying this app instance.",
|
||||
"deployDrawer.notFound": "Instance not found.",
|
||||
"deployDrawer.noteLabel": "Release note (optional)",
|
||||
"deployDrawer.notePlaceholder": "e.g. Ship onboarding copy tweak",
|
||||
"deployDrawer.pluginCreds": "Plugin credentials",
|
||||
"deployDrawer.promote": "Promote",
|
||||
"deployDrawer.promoteDescription": "Promote a newer release to the target environment.",
|
||||
"deployDrawer.promoteTitle": "Promote release",
|
||||
"deployDrawer.readOnly": "Read-only",
|
||||
"deployDrawer.releaseLabel": "Release",
|
||||
"deployDrawer.requiredBinding": "Required",
|
||||
"deployDrawer.rollback": "Rollback",
|
||||
"deployDrawer.rollbackDescription": "Rollback the target environment to a previous release.",
|
||||
"deployDrawer.rollbackTitle": "Rollback release",
|
||||
"deployDrawer.runtimeCredentials": "Runtime credentials",
|
||||
"deployDrawer.secretPlaceholder": "secret",
|
||||
"deployDrawer.selectCredential": "Select a credential",
|
||||
"deployDrawer.selectEnv": "Select an environment",
|
||||
"deployDrawer.selectProviderCred": "Select {{provider}} credential",
|
||||
"deployDrawer.selectProviderKey": "Select {{provider}} key",
|
||||
"deployDrawer.selectRelease": "Select a release",
|
||||
"deployDrawer.targetEnv": "Target environment",
|
||||
"deployDrawer.title": "Deploy to environment",
|
||||
"deployDrawer.valuePlaceholder": "value",
|
||||
"deployTab.cancelDeployment": "Cancel deployment",
|
||||
"deployTab.col.actions": "Actions",
|
||||
"deployTab.col.currentRelease": "Current release",
|
||||
"deployTab.col.environment": "Environment",
|
||||
"deployTab.col.status": "Status",
|
||||
"deployTab.col.updated": "Updated",
|
||||
"deployTab.collapseDetails": "Collapse deployment details",
|
||||
"deployTab.confirmUndeploy": "Undeploy",
|
||||
"deployTab.deployOtherVersion": "Deploy another release",
|
||||
"deployTab.deployToEnv": "Deploy to {{name}}",
|
||||
"deployTab.deployToNewEnv": "Deploy to new environment...",
|
||||
"deployTab.empty": "No deployments yet. Deploy this instance to an environment to get started.",
|
||||
"deployTab.envCount": "Environments",
|
||||
"deployTab.expandDetails": "Expand deployment details",
|
||||
"deployTab.moreActions": "More actions",
|
||||
"deployTab.newDeployment": "New deployment",
|
||||
"deployTab.panel.commit": "Commit ID",
|
||||
"deployTab.panel.deploymentId": "Deployment ID",
|
||||
"deployTab.panel.endpoints": "Endpoints",
|
||||
"deployTab.panel.envVars": "Environment variables",
|
||||
"deployTab.panel.error": "Error",
|
||||
"deployTab.panel.failedRelease": "Failed release",
|
||||
"deployTab.panel.health": "Health",
|
||||
"deployTab.panel.instanceInfo": "Instance info",
|
||||
"deployTab.panel.modelCreds": "Model credentials",
|
||||
"deployTab.panel.pluginCreds": "Plugin credentials",
|
||||
"deployTab.panel.release": "Release",
|
||||
"deployTab.panel.releaseCreatedAt": "Release created at",
|
||||
"deployTab.panel.releaseInfo": "Release info",
|
||||
"deployTab.panel.replicas": "Replicas",
|
||||
"deployTab.panel.run": "Run",
|
||||
"deployTab.panel.runtimeBindings": "Runtime bindings",
|
||||
"deployTab.panel.runtimeInfo": "Runtime",
|
||||
"deployTab.panel.runtimeMode": "Runtime mode",
|
||||
"deployTab.panel.runtimeNote": "Runtime note",
|
||||
"deployTab.panel.targetRelease": "Target release",
|
||||
"deployTab.panel.unknownError": "Deployment failed.",
|
||||
"deployTab.promote": "Promote",
|
||||
"deployTab.releaseCreatedAt": "Release created {{time}}",
|
||||
"deployTab.retry": "Retry",
|
||||
"deployTab.shortcut": "Shortcut",
|
||||
"deployTab.status.deployFailed": "Deploy failed",
|
||||
"deployTab.status.deployingRelease": "Deploying ({{release}})",
|
||||
"deployTab.status.runningWithFailed": "Running (last deployment failed)",
|
||||
"deployTab.undeploy": "Undeploy",
|
||||
"deployTab.undeployConfirmDesc": "End-user access will stop immediately. The release can be redeployed later.",
|
||||
"deployTab.undeployConfirmTitle": "Undeploy from {{name}}?",
|
||||
"deployTab.undeployFrom": "Undeploy from {{name}}",
|
||||
"deployTab.viewError": "View error",
|
||||
"deployTab.viewLogs": "View logs",
|
||||
"deployTab.viewProgress": "View progress",
|
||||
"detail.backToInstances": "Back to app instances",
|
||||
"detail.deployingCount": "{{count}} deploying",
|
||||
"detail.envCount_one": "{{count}} env",
|
||||
"detail.envCount_other": "{{count}} envs",
|
||||
"detail.failedCount": "{{count}} failed",
|
||||
"detail.notFound": "Instance not found",
|
||||
"detail.openSourceApp": "Open source app {{name}}",
|
||||
"detail.sourceApp": "Source app",
|
||||
"detail.sourceAppDeleted": "Source app deleted",
|
||||
"detail.sourceAppLink": "Source app",
|
||||
"documentTitle.create": "Create deployment · Deployments",
|
||||
"documentTitle.detail": "Instance · Deployments",
|
||||
"documentTitle.list": "Deployments",
|
||||
"filter.allEnvs": "All environments",
|
||||
"filter.notDeployed": "Not deployed",
|
||||
"filter.searchPlaceholder": "Search instances",
|
||||
"health.degraded": "Degraded",
|
||||
"health.ready": "Ready",
|
||||
"list.createDeployment": "Create deployment",
|
||||
"list.empty": "No app instances found.",
|
||||
"mode.isolated": "Isolated",
|
||||
"mode.shared": "Shared",
|
||||
"newInstance.comingSoon": "Coming soon",
|
||||
"newInstance.fromStudio": "Select from Studio",
|
||||
"newInstance.importDSL": "Import DSL",
|
||||
"newInstance.title": "Create app instance",
|
||||
"overview.accessEndpoints": "Access endpoints",
|
||||
"overview.accessStatus": "Access status",
|
||||
"overview.api": "API",
|
||||
"overview.apiKeysCount_one": "{{count}} API key",
|
||||
"overview.apiKeysCount_other": "{{count}} API keys",
|
||||
"overview.appMode": "App mode",
|
||||
"overview.availableForDeployment": "Available for deployment",
|
||||
"overview.basicInfo": "Basic info",
|
||||
"overview.chip.behind_one": "↑ 1",
|
||||
"overview.chip.behind_other": "↑ {{count}}",
|
||||
"overview.chip.deploying": "deploying",
|
||||
"overview.chip.empty": "empty",
|
||||
"overview.chip.failed": "failed",
|
||||
"overview.chip.latest": "latest",
|
||||
"overview.chip.needsReleaseFirst": "Create a release first",
|
||||
"overview.chip.olderRelease": "older",
|
||||
"overview.chip.openInDeployTab": "View deployment progress",
|
||||
"overview.cli": "CLI",
|
||||
"overview.configureAccess": "Configure access",
|
||||
"overview.configured": "Configured",
|
||||
"overview.createRelease": "Create release",
|
||||
"overview.created": "Created",
|
||||
"overview.deploy": "Deploy",
|
||||
"overview.deployedEnvironments": "deployed",
|
||||
"overview.deploymentOverview": "Deployment overview",
|
||||
"overview.deploymentStatus": "Deployment status",
|
||||
"overview.description": "Description",
|
||||
"overview.developerApi": "Developer API",
|
||||
"overview.disabled": "Disabled",
|
||||
"overview.emptyValue": "Not set",
|
||||
"overview.enabled": "Enabled",
|
||||
"overview.enabledChannels": "access enabled",
|
||||
"overview.endUserAccess": "End-user access",
|
||||
"overview.environments": "Environments",
|
||||
"overview.hero.byName": "by {{name}}",
|
||||
"overview.hero.empty": "No releases yet",
|
||||
"overview.hero.emptyDescription": "Capture the current app to make a release you can deploy.",
|
||||
"overview.hero.propagation_one": "deployed to {{count}}/{{total}} environment",
|
||||
"overview.hero.propagation_other": "deployed to {{count}}/{{total}} environments",
|
||||
"overview.hero.untargeted": "no environments configured yet",
|
||||
"overview.instanceDetails": "Instance details",
|
||||
"overview.instanceId": "Instance ID",
|
||||
"overview.manageDeployments": "Manage deployments",
|
||||
"overview.name": "Name",
|
||||
"overview.noAccessConfig": "No access configuration.",
|
||||
"overview.noReleaseSourceUnavailable": "The source app was deleted. Existing releases can still be deployed, but there is no release yet.",
|
||||
"overview.noReleaseYet": "Create a release before deploying this app instance.",
|
||||
"overview.notConfigured": "Not configured",
|
||||
"overview.previousReleases.empty": "No earlier releases yet.",
|
||||
"overview.previousReleases.retired": "Not currently deployed",
|
||||
"overview.previousReleases.title": "Previous releases",
|
||||
"overview.previousReleases.viewAll": "View all",
|
||||
"overview.ready": "Ready",
|
||||
"overview.recentReleases": "Recent releases",
|
||||
"overview.releaseDeployedTitle": "{{release}} is deployed",
|
||||
"overview.releaseReadyTitle": "{{release}} is ready to deploy",
|
||||
"overview.serviceMap": "Service map",
|
||||
"overview.servingRelease": "Serving {{release}}",
|
||||
"overview.servingReleaseDescription": "This app instance is deployed to {{count}}/{{total}} environments.",
|
||||
"overview.sourceAppDeletedDescription": "Historical releases are still deployable, but new releases cannot be generated from the deleted source app. Switch to another source app to continue.",
|
||||
"overview.sourceAppDeletedTitle": "Source app was deleted",
|
||||
"overview.sourceAppDeletedValue": "Deleted source app",
|
||||
"overview.sourceAppUnavailable": "Unavailable",
|
||||
"overview.strip.empty": "No environments configured.",
|
||||
"overview.strip.failedAlert_one": "1 environment failed to deploy.",
|
||||
"overview.strip.failedAlert_other": "{{count}} environments failed to deploy.",
|
||||
"overview.strip.investigate": "Investigate",
|
||||
"overview.strip.summary_one": "1 of {{total}} on latest",
|
||||
"overview.strip.summary_other": "{{count}} of {{total}} on latest",
|
||||
"overview.strip.title": "Environments",
|
||||
"overview.switchSourceApp": "Switch source app",
|
||||
"overview.switchSourceAppDescription": "Choose the Studio app that future releases should use as their DSL source.",
|
||||
"overview.switchSourceAppHint": "After switching, only newly created releases use the new source app. Historical releases and existing deployments are not changed.",
|
||||
"overview.targetRelease": "Target release",
|
||||
"overview.webapp": "WebApp",
|
||||
"settings.danger": "Danger zone",
|
||||
"settings.dangerDesc": "Deleting this app instance removes deployment metadata after all environments are undeployed.",
|
||||
"settings.delete": "Delete instance",
|
||||
"settings.deleteConfirmDesc": "Delete {{name}}? This cannot be undone.",
|
||||
"settings.deleteConfirmTitle": "Delete app instance",
|
||||
"settings.deleteFailed": "Failed to delete instance.",
|
||||
"settings.deleted": "Instance deleted",
|
||||
"settings.description": "Description",
|
||||
"settings.descriptionHelp": "Update metadata for this app instance.",
|
||||
"settings.general": "General",
|
||||
"settings.name": "Instance name",
|
||||
"settings.reset": "Reset",
|
||||
"settings.safeToDelete": "No active deployments. Safe to delete.",
|
||||
"settings.save": "Save changes",
|
||||
"settings.undeployFirst": "Undeploy from all environments before deleting.",
|
||||
"settings.updateFailed": "Failed to update instance.",
|
||||
"settings.updated": "Instance updated",
|
||||
"status.deployFailed": "Deploy failed",
|
||||
"status.deploying": "Deploying",
|
||||
"status.notDeployed": "Not deployed",
|
||||
"status.ready": "Ready",
|
||||
"status.unknown": "Unknown",
|
||||
"subtitle": "Deploy and manage your apps across environments.",
|
||||
"tabs.deploy.description": "Environments this instance is deployed to and their current releases.",
|
||||
"tabs.deploy.name": "Deploy",
|
||||
"tabs.overview.description": "Deploy releases and review target environments.",
|
||||
"tabs.overview.name": "Overview",
|
||||
"tabs.releases.description": "All releases for this app. Deploy any release to an environment.",
|
||||
"tabs.releases.name": "Releases",
|
||||
"tabs.settings.description": "Access, API keys, metadata, and backend-managed settings.",
|
||||
"tabs.settings.name": "Settings",
|
||||
"title": "App instances",
|
||||
"versions.cancelCreate": "Cancel",
|
||||
"versions.col.action": "Action",
|
||||
"versions.col.author": "Author",
|
||||
"versions.col.commit": "Commit",
|
||||
"versions.col.createdAt": "Created at",
|
||||
"versions.col.deployedTo": "Deployed to",
|
||||
"versions.col.release": "Release",
|
||||
"versions.commitTooltip": "Commit {{commit}}",
|
||||
"versions.create": "Create",
|
||||
"versions.createFailed": "Failed to create release.",
|
||||
"versions.createRelease": "Create release",
|
||||
"versions.createReleaseDescription": "Capture the current workflow as a deployable release.",
|
||||
"versions.createReleaseHint": "New releases can be deployed to any environment.",
|
||||
"versions.createSuccess": "Release \"{{name}}\" created.",
|
||||
"versions.creating": "Creating...",
|
||||
"versions.currentOn": "Current on {{name}}",
|
||||
"versions.deploy": "Deploy",
|
||||
"versions.deployTo": "Deploy to {{name}}",
|
||||
"versions.deployedStatus.active": "Running",
|
||||
"versions.deployedStatus.deploying": "Deploying",
|
||||
"versions.deployedStatus.failed": "Failed",
|
||||
"versions.deployingTo": "{{name}} is deploying",
|
||||
"versions.disabledReason.current": "Already running on {{name}}",
|
||||
"versions.disabledReason.deploying": "Wait for the active deployment to finish",
|
||||
"versions.disabledReason.envDisabled": "This environment isn't deployable",
|
||||
"versions.empty": "No releases available yet.",
|
||||
"versions.emptySourceUnavailable": "No releases yet. The source app was deleted, so new releases cannot be created.",
|
||||
"versions.emptyWithCreate": "No releases yet. Create the first release before deploying.",
|
||||
"versions.groupHeader.deploy": "Deploy",
|
||||
"versions.groupHeader.promote": "Promote",
|
||||
"versions.groupHeader.rollback": "Rollback",
|
||||
"versions.groupHeader.unavailable": "Unavailable",
|
||||
"versions.moreActions": "More actions",
|
||||
"versions.optional": "Optional",
|
||||
"versions.promote": "Promote",
|
||||
"versions.promoteTo": "Promote to {{name}}",
|
||||
"versions.releaseDescriptionLabel": "Description",
|
||||
"versions.releaseDescriptionPlaceholder": "Describe this release",
|
||||
"versions.releaseHistory": "Release history",
|
||||
"versions.releaseNameLabel": "Release name",
|
||||
"versions.releaseNamePlaceholder": "Release name",
|
||||
"versions.rollbackTo": "Rollback to {{name}}",
|
||||
"versions.sourceAppUnavailable": "The source app was deleted. Existing releases are still deployable, but new releases cannot be created."
|
||||
}
|
||||
@ -277,7 +277,6 @@
|
||||
"menus.apps": "工作室",
|
||||
"menus.datasets": "知识库",
|
||||
"menus.datasetsTips": "即将到来:上传自己的长文本数据,或通过 Webhook 集成自己的数据源",
|
||||
"menus.deployments": "部署",
|
||||
"menus.explore": "探索",
|
||||
"menus.exploreMarketplace": "探索 Marketplace",
|
||||
"menus.newApp": "创建应用",
|
||||
|
||||
@ -1,384 +0,0 @@
|
||||
{
|
||||
"access.api.backendTitle": "后端服务 API",
|
||||
"access.api.description": "通过 HTTP 调用该实例。每个 API 密钥仅在一个环境中生效。",
|
||||
"access.api.developerTitle": "开发者 API",
|
||||
"access.api.disabled": "该实例的 API 接入已关闭。",
|
||||
"access.api.dismissToken": "关闭密钥",
|
||||
"access.api.empty": "请先部署到环境后再签发 API 密钥。",
|
||||
"access.api.endpoint": "请求地址",
|
||||
"access.api.envPrefix": "env:{{env}}",
|
||||
"access.api.keyList": "API Key 列表",
|
||||
"access.api.newKey": "生成新 Key",
|
||||
"access.api.newKeyForEnv": "为 {{env}} 生成",
|
||||
"access.api.newTokenDescription": "该明文密钥仅本次显示,请在离开页面前复制保存。",
|
||||
"access.api.newTokenLabel": "密钥",
|
||||
"access.api.newTokenTitle": "API Key 已创建",
|
||||
"access.api.noKeys": "尚无 API 密钥,创建一个即可调用 API。",
|
||||
"access.api.title": "API",
|
||||
"access.channels.description": "WebApp 与 CLI 入口遵循上方访问权限。",
|
||||
"access.channels.disabled": "该实例的接入渠道已关闭。",
|
||||
"access.channels.followPermission": "随权限开放",
|
||||
"access.channels.title": "接入渠道",
|
||||
"access.cli.description": "通过 CLI 访问此实例时使用统一域名。",
|
||||
"access.cli.docs": "使用说明",
|
||||
"access.cli.domain": "域名",
|
||||
"access.cli.empty": "尚未配置 CLI 接入地址。",
|
||||
"access.cli.install": "安装 CLI",
|
||||
"access.cli.title": "CLI",
|
||||
"access.copied": "已复制",
|
||||
"access.copy": "复制",
|
||||
"access.copyFailed": "复制失败",
|
||||
"access.copyToast": "已复制到剪贴板",
|
||||
"access.hide": "隐藏",
|
||||
"access.members.clearAll": "全部清除",
|
||||
"access.members.empty": "未找到匹配结果。",
|
||||
"access.members.emptySelection": "至少选择一个分组或成员后才会保存该权限。",
|
||||
"access.members.groupCount_one": "{{count}} 个分组",
|
||||
"access.members.groupCount_other": "{{count}} 个分组",
|
||||
"access.members.groups": "分组",
|
||||
"access.members.individuals": "成员",
|
||||
"access.members.memberCount_one": "{{count}} 位成员",
|
||||
"access.members.memberCount_other": "{{count}} 位成员",
|
||||
"access.members.pickPlaceholder": "选择分组或成员",
|
||||
"access.members.searchPlaceholder": "搜索分组和成员",
|
||||
"access.members.selectedLabel": "已选择",
|
||||
"access.permission.anyone": "任何人",
|
||||
"access.permission.anyoneDesc": "任何拥有链接的人,无需登录",
|
||||
"access.permission.comingSoon": "即将支持",
|
||||
"access.permission.external": "已认证的外部用户",
|
||||
"access.permission.externalDesc": "通过 SSO / OIDC 完成认证的外部用户",
|
||||
"access.permission.memberCount_one": "{{count}} 位成员",
|
||||
"access.permission.memberCount_other": "{{count}} 位成员",
|
||||
"access.permission.organization": "仅组织内成员",
|
||||
"access.permission.organizationDesc": "工作区内所有内部成员",
|
||||
"access.permission.specific": "特定成员",
|
||||
"access.permission.specificDesc": "选择指定的分组或单个成员",
|
||||
"access.permission.specificUnavailable": "特定成员暂未启用,需接入真实工作区成员与分组后再开放。",
|
||||
"access.permission.updateFailed": "更新访问策略失败。",
|
||||
"access.permissions.description": "配置该实例在每个已部署环境中的访问人员。",
|
||||
"access.permissions.title": "访问权限",
|
||||
"access.revoke": "撤销",
|
||||
"access.runAccess.description": "管理用户如何运行该应用,以及在每个环境里谁可以访问。",
|
||||
"access.runAccess.disabled": "该实例的运行时接入已关闭。",
|
||||
"access.runAccess.mcp": "MCP",
|
||||
"access.runAccess.mcpDesc": "将此实例作为 Model Context Protocol 服务器对外提供。",
|
||||
"access.runAccess.mcpEmpty": "尚未配置 MCP 端点。",
|
||||
"access.runAccess.noEnvs": "请先部署到环境后再配置访问权限。",
|
||||
"access.runAccess.openWebapp": "打开 WebApp",
|
||||
"access.runAccess.permissions": "访问权限",
|
||||
"access.runAccess.permissionsDesc": "每个环境里可以访问该实例的人员。",
|
||||
"access.runAccess.title": "访问控制(Run)",
|
||||
"access.runAccess.urlLabel": "访问地址",
|
||||
"access.runAccess.webapp": "WebApp",
|
||||
"access.runAccess.webappDesc": "面向终端用户的托管 Web 页面。",
|
||||
"access.runAccess.webappEmpty": "尚未配置 WebApp 访问地址。",
|
||||
"access.show": "显示",
|
||||
"appMode.advanced-chat": "Chatflow",
|
||||
"appMode.agent-chat": "Agent",
|
||||
"appMode.chat": "聊天助手",
|
||||
"appMode.completion": "文本生成",
|
||||
"appMode.workflow": "Workflow",
|
||||
"card.deploying": "{{count}} 个部署中",
|
||||
"card.failed": "{{count}} 个失败",
|
||||
"card.fromApp": "来自 {{name}}",
|
||||
"card.lastDeployed": "上次部署于 {{time}}",
|
||||
"card.menu.delete": "删除实例",
|
||||
"card.menu.deleteDisabled": "后端托管的部署暂不支持删除实例。",
|
||||
"card.menu.deploy": "部署到环境",
|
||||
"card.menu.viewDetail": "查看实例详情",
|
||||
"card.moreActions": "更多操作",
|
||||
"card.neverDeployed": "尚未部署",
|
||||
"card.notDeployed": "未部署",
|
||||
"card.ready": "{{count}} 个就绪",
|
||||
"card.tooltip.notDeployed": "该实例尚未部署到任何环境。",
|
||||
"common.loadFailed": "加载失败,请稍后再试。",
|
||||
"common.loading": "加载中...",
|
||||
"createModal.appPickerPlaceholder": "选择源应用",
|
||||
"createModal.appSearchEmpty": "没有匹配的应用",
|
||||
"createModal.appSearchPlaceholder": "搜索应用…",
|
||||
"createModal.cancel": "取消",
|
||||
"createModal.create": "创建",
|
||||
"createModal.createFailed": "创建应用实例失败。",
|
||||
"createModal.description": "从 Studio 选择一个源应用并创建可部署的实例。",
|
||||
"createModal.descriptionLabel": "描述",
|
||||
"createModal.descriptionPlaceholder": "描述该实例的用途",
|
||||
"createModal.loadMoreApps": "加载更多应用",
|
||||
"createModal.loadingApps": "正在加载应用…",
|
||||
"createModal.nameLabel": "实例名称",
|
||||
"createModal.namePlaceholder": "实例名称",
|
||||
"createModal.noApps": "当前工作区还没有应用,请先在 Studio 创建一个。",
|
||||
"createModal.selected": "已选择",
|
||||
"createModal.sourceApp": "源应用(必选)",
|
||||
"createModal.title": "创建应用实例",
|
||||
"deployDrawer.bindingOptionsFailed": "加载凭据选项失败。",
|
||||
"deployDrawer.bindingSelectionHint": "选择本次部署要使用的运行时凭据。",
|
||||
"deployDrawer.bindingsDisabled": "来自发布版本预览的解析结果,暂不支持在这里编辑。",
|
||||
"deployDrawer.cancel": "取消",
|
||||
"deployDrawer.defaultSelect": "选择...",
|
||||
"deployDrawer.deploy": "部署",
|
||||
"deployDrawer.deployExistingRelease": "部署已有发布版本",
|
||||
"deployDrawer.deployExistingReleaseDescription": "从发布历史中选择一个已有发布版本,部署到目标环境。",
|
||||
"deployDrawer.deployExistingReleaseTitle": "部署已有发布版本",
|
||||
"deployDrawer.deployFailed": "启动部署失败。",
|
||||
"deployDrawer.deploying": "部署中...",
|
||||
"deployDrawer.description": "选择一个发布版本,并部署到目标环境。",
|
||||
"deployDrawer.envVars": "环境变量",
|
||||
"deployDrawer.existingReleaseHint": "将直接部署该已有发布版本,不会创建新的发布版本。",
|
||||
"deployDrawer.loadingBindings": "解析中...",
|
||||
"deployDrawer.lockedHint": "已锁定至当前环境",
|
||||
"deployDrawer.missingRequiredBinding": "请选择该必填绑定使用的凭据。",
|
||||
"deployDrawer.modelCreds": "模型凭据",
|
||||
"deployDrawer.needsValidation": "(待验证)",
|
||||
"deployDrawer.newReleaseHint": "将基于当前应用 YAML 创建一个新的发布版本。",
|
||||
"deployDrawer.noBindingRequired": "无需配置",
|
||||
"deployDrawer.noCredentialCandidates": "没有可用凭据。",
|
||||
"deployDrawer.noReleaseAvailable": "请先创建发布版本,再部署该应用实例。",
|
||||
"deployDrawer.notFound": "未找到实例。",
|
||||
"deployDrawer.noteLabel": "发布备注(可选)",
|
||||
"deployDrawer.notePlaceholder": "例如:优化引导文案",
|
||||
"deployDrawer.pluginCreds": "插件凭据",
|
||||
"deployDrawer.promote": "推送",
|
||||
"deployDrawer.promoteDescription": "将更新的发布版本推送到目标环境。",
|
||||
"deployDrawer.promoteTitle": "推送发布版本",
|
||||
"deployDrawer.readOnly": "只读",
|
||||
"deployDrawer.releaseLabel": "发布版本",
|
||||
"deployDrawer.requiredBinding": "必填",
|
||||
"deployDrawer.rollback": "回滚",
|
||||
"deployDrawer.rollbackDescription": "将目标环境回滚到之前的发布版本。",
|
||||
"deployDrawer.rollbackTitle": "回滚发布版本",
|
||||
"deployDrawer.runtimeCredentials": "运行时凭据",
|
||||
"deployDrawer.secretPlaceholder": "机密值",
|
||||
"deployDrawer.selectCredential": "选择凭据",
|
||||
"deployDrawer.selectEnv": "选择一个环境",
|
||||
"deployDrawer.selectProviderCred": "选择 {{provider}} 凭据",
|
||||
"deployDrawer.selectProviderKey": "选择 {{provider}} 密钥",
|
||||
"deployDrawer.selectRelease": "选择一个发布版本",
|
||||
"deployDrawer.targetEnv": "目标环境",
|
||||
"deployDrawer.title": "部署到环境",
|
||||
"deployDrawer.valuePlaceholder": "值",
|
||||
"deployTab.cancelDeployment": "取消部署",
|
||||
"deployTab.col.actions": "操作",
|
||||
"deployTab.col.currentRelease": "当前发布版本",
|
||||
"deployTab.col.environment": "环境",
|
||||
"deployTab.col.status": "状态",
|
||||
"deployTab.col.updated": "更新时间",
|
||||
"deployTab.collapseDetails": "收起部署详情",
|
||||
"deployTab.confirmUndeploy": "取消部署",
|
||||
"deployTab.deployOtherVersion": "部署其他发布版本",
|
||||
"deployTab.deployToEnv": "部署到 {{name}}",
|
||||
"deployTab.deployToNewEnv": "部署到新环境...",
|
||||
"deployTab.empty": "暂无部署。将此实例部署到环境以开始使用。",
|
||||
"deployTab.envCount": "环境",
|
||||
"deployTab.expandDetails": "展开部署详情",
|
||||
"deployTab.moreActions": "更多操作",
|
||||
"deployTab.newDeployment": "新建部署",
|
||||
"deployTab.panel.commit": "Commit ID",
|
||||
"deployTab.panel.deploymentId": "部署 ID",
|
||||
"deployTab.panel.endpoints": "端点",
|
||||
"deployTab.panel.envVars": "环境变量",
|
||||
"deployTab.panel.error": "错误",
|
||||
"deployTab.panel.failedRelease": "失败发布版本",
|
||||
"deployTab.panel.health": "健康检查",
|
||||
"deployTab.panel.instanceInfo": "实例信息",
|
||||
"deployTab.panel.modelCreds": "模型凭据",
|
||||
"deployTab.panel.pluginCreds": "插件凭据",
|
||||
"deployTab.panel.release": "发布版本",
|
||||
"deployTab.panel.releaseCreatedAt": "发布版本创建时间",
|
||||
"deployTab.panel.releaseInfo": "发布版本信息",
|
||||
"deployTab.panel.replicas": "副本数",
|
||||
"deployTab.panel.run": "运行",
|
||||
"deployTab.panel.runtimeBindings": "运行时绑定",
|
||||
"deployTab.panel.runtimeInfo": "运行时",
|
||||
"deployTab.panel.runtimeMode": "运行模式",
|
||||
"deployTab.panel.runtimeNote": "运行时备注",
|
||||
"deployTab.panel.targetRelease": "目标发布版本",
|
||||
"deployTab.panel.unknownError": "部署失败。",
|
||||
"deployTab.promote": "推送",
|
||||
"deployTab.releaseCreatedAt": "发布版本创建于 {{time}}",
|
||||
"deployTab.retry": "重试",
|
||||
"deployTab.shortcut": "快捷",
|
||||
"deployTab.status.deployFailed": "部署失败",
|
||||
"deployTab.status.deployingRelease": "部署中({{release}})",
|
||||
"deployTab.status.runningWithFailed": "运行中(上次部署失败)",
|
||||
"deployTab.undeploy": "下线",
|
||||
"deployTab.undeployConfirmDesc": "用户访问将立即停止。该发布版本之后仍可重新部署。",
|
||||
"deployTab.undeployConfirmTitle": "从 {{name}} 取消部署?",
|
||||
"deployTab.undeployFrom": "从 {{name}} 取消部署",
|
||||
"deployTab.viewError": "查看错误",
|
||||
"deployTab.viewLogs": "查看日志",
|
||||
"deployTab.viewProgress": "查看进度",
|
||||
"detail.backToInstances": "返回应用实例",
|
||||
"detail.deployingCount": "{{count}} 个部署中",
|
||||
"detail.envCount_one": "{{count}} 个环境",
|
||||
"detail.envCount_other": "{{count}} 个环境",
|
||||
"detail.failedCount": "{{count}} 个失败",
|
||||
"detail.notFound": "未找到实例",
|
||||
"detail.openSourceApp": "打开源应用 {{name}}",
|
||||
"detail.sourceApp": "源应用",
|
||||
"detail.sourceAppDeleted": "源应用已删除",
|
||||
"detail.sourceAppLink": "源应用",
|
||||
"documentTitle.detail": "实例 · 部署",
|
||||
"documentTitle.list": "部署",
|
||||
"filter.allEnvs": "全部环境",
|
||||
"filter.notDeployed": "未部署",
|
||||
"filter.searchPlaceholder": "搜索实例",
|
||||
"health.degraded": "降级",
|
||||
"health.ready": "就绪",
|
||||
"list.empty": "未找到应用实例。",
|
||||
"mode.isolated": "独立",
|
||||
"mode.shared": "共享",
|
||||
"newInstance.comingSoon": "即将支持",
|
||||
"newInstance.fromStudio": "从 Studio 选择",
|
||||
"newInstance.importDSL": "导入 DSL",
|
||||
"newInstance.title": "创建应用实例",
|
||||
"overview.accessEndpoints": "接入端点",
|
||||
"overview.accessStatus": "接入状态",
|
||||
"overview.api": "API",
|
||||
"overview.apiKeysCount_one": "{{count}} 个 API Key",
|
||||
"overview.apiKeysCount_other": "{{count}} 个 API Key",
|
||||
"overview.appMode": "应用类型",
|
||||
"overview.availableForDeployment": "可部署",
|
||||
"overview.basicInfo": "基本信息",
|
||||
"overview.chip.behind_one": "↑ 1",
|
||||
"overview.chip.behind_other": "↑ {{count}}",
|
||||
"overview.chip.deploying": "部署中",
|
||||
"overview.chip.empty": "空",
|
||||
"overview.chip.failed": "失败",
|
||||
"overview.chip.latest": "最新",
|
||||
"overview.chip.needsReleaseFirst": "请先创建发布版本",
|
||||
"overview.chip.olderRelease": "较旧",
|
||||
"overview.chip.openInDeployTab": "查看部署进度",
|
||||
"overview.cli": "CLI",
|
||||
"overview.configureAccess": "配置接入",
|
||||
"overview.configured": "已配置",
|
||||
"overview.createRelease": "创建发布版本",
|
||||
"overview.created": "创建时间",
|
||||
"overview.deploy": "部署",
|
||||
"overview.deployedEnvironments": "已部署",
|
||||
"overview.deploymentOverview": "部署概览",
|
||||
"overview.deploymentStatus": "部署状态",
|
||||
"overview.description": "描述",
|
||||
"overview.developerApi": "开发者 API",
|
||||
"overview.disabled": "未启用",
|
||||
"overview.emptyValue": "未设置",
|
||||
"overview.enabled": "已启用",
|
||||
"overview.enabledChannels": "接入启用",
|
||||
"overview.endUserAccess": "终端用户接入",
|
||||
"overview.environments": "环境",
|
||||
"overview.hero.byName": "由 {{name}} 创建",
|
||||
"overview.hero.empty": "尚无发布版本",
|
||||
"overview.hero.emptyDescription": "把当前应用保存为发布版本后即可部署。",
|
||||
"overview.hero.propagation_one": "已部署到 {{total}} 个环境中的 {{count}} 个",
|
||||
"overview.hero.propagation_other": "已部署到 {{total}} 个环境中的 {{count}} 个",
|
||||
"overview.hero.untargeted": "尚未配置环境",
|
||||
"overview.instanceDetails": "实例详情",
|
||||
"overview.instanceId": "实例 ID",
|
||||
"overview.manageDeployments": "管理部署",
|
||||
"overview.name": "名称",
|
||||
"overview.noAccessConfig": "未配置接入方式。",
|
||||
"overview.noReleaseSourceUnavailable": "源应用已删除。已有发布版本仍可部署,但当前还没有可部署发布版本。",
|
||||
"overview.noReleaseYet": "请先创建发布版本,再部署该应用实例。",
|
||||
"overview.notConfigured": "未配置",
|
||||
"overview.previousReleases.empty": "暂无更早的发布版本。",
|
||||
"overview.previousReleases.retired": "当前未部署",
|
||||
"overview.previousReleases.title": "历史发布版本",
|
||||
"overview.previousReleases.viewAll": "查看全部",
|
||||
"overview.ready": "可部署",
|
||||
"overview.recentReleases": "近期发布版本",
|
||||
"overview.releaseDeployedTitle": "{{release}} 已部署",
|
||||
"overview.releaseReadyTitle": "{{release}} 可以部署",
|
||||
"overview.serviceMap": "服务流",
|
||||
"overview.servingRelease": "正在提供 {{release}}",
|
||||
"overview.servingReleaseDescription": "该应用实例已部署到 {{count}}/{{total}} 个环境。",
|
||||
"overview.sourceAppDeletedDescription": "历史发布版本仍可继续部署,但无法再基于已删除的源应用生成新发布版本。请切换到其他源应用后继续使用。",
|
||||
"overview.sourceAppDeletedTitle": "源应用已被删除",
|
||||
"overview.sourceAppDeletedValue": "已删除的源应用",
|
||||
"overview.sourceAppUnavailable": "不可用",
|
||||
"overview.strip.empty": "尚未配置任何环境。",
|
||||
"overview.strip.failedAlert_one": "1 个环境部署失败。",
|
||||
"overview.strip.failedAlert_other": "{{count}} 个环境部署失败。",
|
||||
"overview.strip.investigate": "查看详情",
|
||||
"overview.strip.summary_one": "{{total}} 个环境中 1 个已部署最新",
|
||||
"overview.strip.summary_other": "{{total}} 个环境中 {{count}} 个已部署最新",
|
||||
"overview.strip.title": "环境",
|
||||
"overview.switchSourceApp": "切换源应用",
|
||||
"overview.switchSourceAppDescription": "选择后续新建发布版本使用的 Studio 应用 DSL 来源。",
|
||||
"overview.switchSourceAppHint": "切换后,仅新建发布版本会使用新的源应用;历史发布版本和现有部署不受影响。",
|
||||
"overview.targetRelease": "目标发布版本",
|
||||
"overview.webapp": "WebApp",
|
||||
"settings.danger": "危险区域",
|
||||
"settings.dangerDesc": "删除应用实例会在所有环境取消部署后移除部署元数据。",
|
||||
"settings.delete": "删除实例",
|
||||
"settings.deleteConfirmDesc": "确定删除 {{name}}?此操作无法撤销。",
|
||||
"settings.deleteConfirmTitle": "删除应用实例",
|
||||
"settings.deleteFailed": "删除实例失败。",
|
||||
"settings.deleted": "实例已删除",
|
||||
"settings.description": "描述",
|
||||
"settings.descriptionHelp": "更新该应用实例的元数据。",
|
||||
"settings.general": "常规",
|
||||
"settings.name": "实例名称",
|
||||
"settings.reset": "重置",
|
||||
"settings.safeToDelete": "无活动部署,可安全删除。",
|
||||
"settings.save": "保存修改",
|
||||
"settings.undeployFirst": "请先在所有环境取消部署后再删除。",
|
||||
"settings.updateFailed": "更新实例失败。",
|
||||
"settings.updated": "实例已更新",
|
||||
"status.deployFailed": "部署失败",
|
||||
"status.deploying": "部署中",
|
||||
"status.notDeployed": "未部署",
|
||||
"status.ready": "就绪",
|
||||
"status.unknown": "未知",
|
||||
"subtitle": "在不同环境中部署和管理你的应用。",
|
||||
"tabs.deploy.description": "该实例已部署的环境及其当前发布版本。",
|
||||
"tabs.deploy.name": "部署",
|
||||
"tabs.overview.description": "部署发布版本并查看目标环境。",
|
||||
"tabs.overview.name": "概览",
|
||||
"tabs.releases.description": "此应用的所有发布版本,可将任一发布版本部署到环境。",
|
||||
"tabs.releases.name": "发布版本",
|
||||
"tabs.settings.description": "管理接入、API Key、元数据和后端托管设置。",
|
||||
"tabs.settings.name": "设置",
|
||||
"title": "应用实例",
|
||||
"versions.cancelCreate": "取消",
|
||||
"versions.col.action": "操作",
|
||||
"versions.col.author": "作者",
|
||||
"versions.col.commit": "提交",
|
||||
"versions.col.createdAt": "创建时间",
|
||||
"versions.col.deployedTo": "已部署到",
|
||||
"versions.col.release": "发布版本",
|
||||
"versions.commitTooltip": "Commit {{commit}}",
|
||||
"versions.create": "创建",
|
||||
"versions.createFailed": "创建发布版本失败。",
|
||||
"versions.createRelease": "创建发布版本",
|
||||
"versions.createReleaseDescription": "将当前工作流保存为可部署发布版本。",
|
||||
"versions.createReleaseHint": "新发布版本可部署到任意环境。",
|
||||
"versions.createSuccess": "发布版本 \"{{name}}\" 已创建。",
|
||||
"versions.creating": "创建中...",
|
||||
"versions.currentOn": "{{name}} 当前发布版本",
|
||||
"versions.deploy": "部署",
|
||||
"versions.deployTo": "部署到 {{name}}",
|
||||
"versions.deployedStatus.active": "运行中",
|
||||
"versions.deployedStatus.deploying": "部署中",
|
||||
"versions.deployedStatus.failed": "失败",
|
||||
"versions.deployingTo": "{{name}} 正在部署",
|
||||
"versions.disabledReason.current": "{{name}} 已运行此版本",
|
||||
"versions.disabledReason.deploying": "请等待当前部署完成",
|
||||
"versions.disabledReason.envDisabled": "此环境不可部署",
|
||||
"versions.empty": "暂无可用发布版本。",
|
||||
"versions.emptySourceUnavailable": "暂无发布版本。源应用已删除,无法创建新发布版本。",
|
||||
"versions.emptyWithCreate": "暂无发布版本,请先创建第一个可部署发布版本。",
|
||||
"versions.groupHeader.deploy": "部署",
|
||||
"versions.groupHeader.promote": "推送",
|
||||
"versions.groupHeader.rollback": "回滚",
|
||||
"versions.groupHeader.unavailable": "不可用",
|
||||
"versions.moreActions": "更多操作",
|
||||
"versions.optional": "可选",
|
||||
"versions.promote": "推送",
|
||||
"versions.promoteTo": "推送到 {{name}}",
|
||||
"versions.releaseDescriptionLabel": "描述",
|
||||
"versions.releaseDescriptionPlaceholder": "描述这个发布版本",
|
||||
"versions.releaseHistory": "发布历史",
|
||||
"versions.releaseNameLabel": "发布版本名称",
|
||||
"versions.releaseNamePlaceholder": "发布版本名称",
|
||||
"versions.rollbackTo": "回滚到 {{name}}",
|
||||
"versions.sourceAppUnavailable": "源应用已删除。已有发布版本仍可部署,但无法创建新发布版本。"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user