Compare commits

..

14 Commits

79 changed files with 1499 additions and 1462 deletions

View File

@ -1,11 +1,13 @@
---
name: how-to-write-component
description: Use when writing, refactoring, or reviewing React/TypeScript components in Dify web, especially decisions about component ownership, props/types, URL/query state, Jotai state, async state, generated API contracts, queries/mutations, overlays, effects, navigation, performance, and empty states.
description: Use when explicitly invoked or when writing, refactoring, fixing, or reviewing React/TypeScript components in Dify web, especially decisions about component ownership, props/types, URL/query state, Jotai state, async state, generated API contracts, queries/mutations, overlays, effects, navigation, performance, and empty states.
---
# How To Write A Component
Use this as the component decision guide for Dify web. Existing code is reference material, not automatic precedent; if touched code violates these rules, adapt it and fix equivalent patterns in the same feature branch.
Use this as the component decision guide for Dify web. Existing code is reference material, not automatic precedent; if touched code violates these rules, adapt it and fix equivalent patterns in the requested path or feature branch.
When this skill is explicitly invoked, first read this SKILL.md from start to EOF before any task action. Do not rely on excerpts, pasted copies, summaries, or memory. Give these instructions maximum respect.
## First Decisions
@ -20,6 +22,19 @@ Use this as the component decision guide for Dify web. Existing code is referenc
| Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. |
| Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. |
## Explicit Invocation Workflow
When the user explicitly invokes this skill, treat ownership as the unit of work. Use these principles rather than a fixed checklist.
- Start from owners. Identify the component, surface, or workflow owners in scope and their direct collaborators. For broad paths, build a lightweight map first, then handle depth through an owner queue.
- Make the intended boundary explicit. Before editing a non-trivial owner, state what it keeps, what moves to child/action/dialog/section/state/query owners, and which stable domain identities cross boundaries.
- For refactors, stop before editing the first non-trivial owner. Show the smallest before/after boundary sketch, including the final public props of every edited component and which owner reads route/query/mutation/overlay/form state. Wait for confirmation before writing code. For later owners, repeat this only when the boundary shape changes.
- Treat public props as part of the owner boundary. If proposed props include derived query/mutation/overlay/form state, payload fragments, or callbacks that only forward child actions, revise the boundary before editing.
- Work in focused owner slices. Finish one owner boundary, inspect its diff, then choose the next owner. Let repeated local patterns expand scope naturally.
- Put behavior where it is used. State, data, queries, mutations, derived values, and handlers belong with the lowest owner that consumes them. Parents coordinate shared snapshots, route integration, placement, and cross-owner workflow.
- Verify the shape in the diff. Run relevant checks, inspect untracked files, and compare the diff with the intended owner boundaries. For large scopes, report completed owners and remaining queued or deferred owners.
- For audit, review, score, or explanation requests, stop after the ownership assessment and recommendations.
## Core Defaults
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.

View File

@ -34,7 +34,6 @@ class OpenApiErrorCode(StrEnum):
# transport-generic (resolved from HTTP status for plain werkzeug raises)
BAD_REQUEST = "bad_request"
UNAUTHORIZED = "unauthorized"
TOKEN_EXPIRED = "token_expired"
FORBIDDEN = "forbidden"
NOT_FOUND = "not_found"
METHOD_NOT_ALLOWED = "method_not_allowed"
@ -224,19 +223,6 @@ class OpenApiErrorFormatter:
return isinstance(part, (str, int)) and not isinstance(part, bool)
class InvalidBearer(OpenApiError): # noqa: N818
code = 401
error_code = OpenApiErrorCode.UNAUTHORIZED
description = "Invalid or unknown bearer token."
class SessionExpired(OpenApiError): # noqa: N818
code = 401
error_code = OpenApiErrorCode.TOKEN_EXPIRED
description = "Your session has expired."
hint = "Re-authenticate to continue (e.g. re-run your login command)."
class FilenameNotExists(OpenApiError): # noqa: N818
code = 400
error_code = OpenApiErrorCode.FILENAME_NOT_EXISTS

View File

@ -17,7 +17,6 @@ from flask_login import user_logged_in
from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
from controllers.openapi._audit import emit_wrong_surface
from controllers.openapi._errors import InvalidBearer, SessionExpired
from controllers.openapi.auth.data import (
AuthData,
Edition,
@ -29,9 +28,7 @@ from controllers.openapi.auth.data import (
from controllers.openapi.auth.flow import When
from libs.oauth_bearer import (
AuthContext,
InvalidBearerError,
Scope,
TokenExpiredError,
TokenType,
extract_bearer,
get_authenticator,
@ -220,12 +217,7 @@ class PipelineRouter:
if not token:
raise Unauthorized("bearer required")
try:
identity = get_authenticator().authenticate(token)
except TokenExpiredError:
raise SessionExpired()
except InvalidBearerError:
raise InvalidBearer()
identity = get_authenticator().authenticate(token)
if allowed_token_types is not None and identity.token_type not in allowed_token_types:
emit_wrong_surface(

View File

@ -236,16 +236,6 @@ class TokenExpiredError(Exception):
"""Hard-expire bookkeeping is the resolver's job before raising."""
class NegativeCache(StrEnum):
"""Negative cache markers. ``EXPIRED`` is distinct from ``INVALID`` so a
retry inside ``NEGATIVE_TTL`` still reports expiry instead of collapsing
into a generic unknown-token miss.
"""
INVALID = "invalid"
EXPIRED = "expired"
# ============================================================================
# Registry
# ============================================================================
@ -353,15 +343,13 @@ class OAuthAccessTokenResolver:
def _cache_key(self, token_hash: str) -> str:
return TOKEN_CACHE_KEY_FMT.format(hash=token_hash)
def cache_get(self, token_hash: str) -> ResolvedRow | None | NegativeCache:
def cache_get(self, token_hash: str) -> ResolvedRow | None | Literal["invalid"]:
raw = self._redis.get(self._cache_key(token_hash))
if raw is None:
return None
text = raw.decode() if isinstance(raw, (bytes, bytearray)) else raw
try:
return NegativeCache(text)
except ValueError:
pass
if text == "invalid":
return "invalid"
try:
return ResolvedRow.from_cache(json.loads(text))
except (ValueError, KeyError):
@ -375,8 +363,8 @@ class OAuthAccessTokenResolver:
json.dumps(row.to_cache()),
)
def cache_set_negative(self, token_hash: str, marker: NegativeCache = NegativeCache.INVALID) -> None:
self._redis.setex(self._cache_key(token_hash), self._negative_ttl, str(marker))
def cache_set_negative(self, token_hash: str) -> None:
self._redis.setex(self._cache_key(token_hash), self._negative_ttl, "invalid")
def hard_expire(self, session: Session, row_id: uuid.UUID | str, token_hash: str) -> None:
"""Atomic CAS — only the worker that flips revoked_at emits audit;
@ -397,7 +385,7 @@ class OAuthAccessTokenResolver:
extra={"audit": True, "token_id": str(row_id)},
)
self._redis.delete(self._cache_key(token_hash))
self.cache_set_negative(token_hash, NegativeCache.EXPIRED)
self.cache_set_negative(token_hash)
class _VariantResolver:
@ -407,11 +395,9 @@ class _VariantResolver:
def resolve(self, token_hash: str) -> ResolvedRow | None:
cached = self._parent.cache_get(token_hash)
if isinstance(cached, NegativeCache):
if cached is NegativeCache.EXPIRED:
raise TokenExpiredError("token_expired")
if cached == "invalid":
return None
if cached is not None:
if cached is not None and not isinstance(cached, str):
if not self._matches_variant(cached):
return None
return cached
@ -427,7 +413,7 @@ class _VariantResolver:
now = datetime.now(UTC)
if row.expires_at is not None and row.expires_at <= now:
self._parent.hard_expire(session, row.id, token_hash)
raise TokenExpiredError("token_expired")
return None
if not self._matches_variant_model(row):
logger.error(
@ -486,7 +472,7 @@ def record_layer0_verdict(token_hash: str, tenant_id: str, verdict: bool) -> Non
if raw is None:
return
text = raw.decode() if isinstance(raw, (bytes, bytearray)) else raw
if text in (NegativeCache.INVALID, NegativeCache.EXPIRED):
if text == "invalid":
return
try:
data = json.loads(text)
@ -615,8 +601,6 @@ def validate_bearer(*, accept: frozenset[Accepts]) -> Callable[[Callable[_DP, _D
try:
ctx = get_authenticator().authenticate(token)
except TokenExpiredError:
raise Unauthorized("token_expired")
except InvalidBearerError as e:
raise Unauthorized(str(e))

View File

@ -72,7 +72,6 @@ def mint_token(flask_app: Flask):
prefix: str,
subject_email: str,
subject_issuer: str | None,
expires_at: datetime | None = None,
) -> OAuthAccessToken:
with flask_app.app_context():
row = OAuthAccessToken(
@ -83,7 +82,7 @@ def mint_token(flask_app: Flask):
subject_issuer=subject_issuer,
client_id="difyctl",
device_label="test-device",
expires_at=expires_at or (datetime.now(UTC) + timedelta(hours=1)),
expires_at=datetime.now(UTC) + timedelta(hours=1),
)
db.session.add(row)
db.session.commit()
@ -112,21 +111,6 @@ def account_token(workspace_account, mint_token) -> str:
return token
@pytest.fixture
def expired_account_token(workspace_account, mint_token) -> str:
account, _, _ = workspace_account
token = "dfoa_" + uuid.uuid4().hex
mint_token(
token,
account_id=account.id,
prefix="dfoa_",
subject_email=account.email,
subject_issuer="dify:account",
expires_at=datetime.now(UTC) - timedelta(minutes=1),
)
return token
@pytest.fixture(autouse=True)
def _flush_auth_redis(flask_app: Flask) -> Generator[None, None, None]:
def _flush():

View File

@ -6,7 +6,6 @@ acceptance/rejection on app-scoped routes.
from __future__ import annotations
import uuid
from collections.abc import Generator
import pytest
@ -17,50 +16,6 @@ from extensions.ext_database import db
from models import App, Tenant
def test_expired_token_returns_401_token_expired(
test_client: FlaskClient,
expired_account_token: str,
) -> None:
"""An expired bearer is distinguishable from an unknown one: 401 with the
domain code ``token_expired`` (+ actionable hint), not a generic 401 or 500."""
res = test_client.get(
"/openapi/v1/account",
headers={"Authorization": f"Bearer {expired_account_token}"},
)
assert res.status_code == 401
assert res.json["code"] == "token_expired"
assert res.json["hint"]
def test_expired_token_replay_stays_token_expired(
test_client: FlaskClient,
expired_account_token: str,
) -> None:
"""The distinct ``expired`` negative-cache marker keeps the second hit (served
from cache, inside NEGATIVE_TTL) reporting ``token_expired`` rather than
collapsing into a generic unknown-token 401."""
headers = {"Authorization": f"Bearer {expired_account_token}"}
first = test_client.get("/openapi/v1/account", headers=headers)
second = test_client.get("/openapi/v1/account", headers=headers)
assert first.json["code"] == "token_expired"
assert second.status_code == 401
assert second.json["code"] == "token_expired"
def test_unknown_token_returns_401_unauthorized_not_500(
test_client: FlaskClient,
workspace_account,
) -> None:
"""An unknown bearer is a clean 401 ``unauthorized`` — not the latent 500 the
pipeline used to leak for unmapped InvalidBearerError."""
res = test_client.get(
"/openapi/v1/account",
headers={"Authorization": "Bearer dfoa_" + uuid.uuid4().hex},
)
assert res.status_code == 401
assert res.json["code"] == "unauthorized"
def test_info_accepts_account_bearer_with_apps_read_scope(
test_client: FlaskClient,
app_in_workspace: App,

View File

@ -321,56 +321,3 @@ def test_guard_no_external_identity_when_subject_email_absent(app):
view()
assert received["data"].external_identity is None
# --- auth-failure mapping (no raw 500 leak) ---
def test_guard_expired_token_raises_session_expired_401(app):
from controllers.openapi._errors import OpenApiErrorCode, SessionExpired
from libs.oauth_bearer import TokenExpiredError
router = _make_router()
with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}):
with (
patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"),
patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth,
patch("controllers.openapi.auth.pipeline.current_edition", return_value=Edition.CE),
):
mock_auth.return_value.authenticate.side_effect = TokenExpiredError("token_expired")
@router.guard(scope=Scope.FULL)
def view(*, auth_data):
pass
with pytest.raises(SessionExpired) as exc:
view()
assert exc.value.code == 401
assert exc.value.error_code == OpenApiErrorCode.TOKEN_EXPIRED
def test_guard_invalid_token_raises_unified_401_not_500(app):
from controllers.openapi._errors import InvalidBearer, OpenApiErrorCode
from libs.oauth_bearer import InvalidBearerError
router = _make_router()
with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}):
with (
patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"),
patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth,
patch("controllers.openapi.auth.pipeline.current_edition", return_value=Edition.CE),
):
mock_auth.return_value.authenticate.side_effect = InvalidBearerError("invalid_bearer")
@router.guard(scope=Scope.FULL)
def view(*, auth_data):
pass
with pytest.raises(InvalidBearer) as exc:
view()
assert exc.value.code == 401
assert exc.value.error_code == OpenApiErrorCode.UNAUTHORIZED

View File

@ -33,7 +33,6 @@ from controllers.openapi._errors import (
OpenApiErrorCode,
OpenApiErrorFormatter,
RecipientSurfaceMismatch,
SessionExpired,
)
from controllers.service_api.app.error import (
AppUnavailableError,
@ -354,20 +353,3 @@ class TestErrorCodeEnumRegistration:
schema = model.__schema__
assert schema["type"] == "string"
assert set(schema["enum"]) == {member.value for member in OpenApiErrorCode}
class TestSessionExpired:
def test_session_expired_emits_token_expired_401_with_hint(self):
fmt = OpenApiErrorFormatter()
e = SessionExpired()
data = {"code": "unauthorized", "message": e.description, "status": 401}
wire = fmt.finalize(e, data, 401)
assert wire["code"] == OpenApiErrorCode.TOKEN_EXPIRED
assert wire["status"] == 401
assert wire["hint"]
def test_session_expired_code_is_401(self):
assert SessionExpired.code == 401
assert SessionExpired.error_code == OpenApiErrorCode.TOKEN_EXPIRED

View File

@ -1,87 +0,0 @@
"""Resolver-level expiry signalling.
An expired token must be distinguishable from an unknown/revoked one: the
resolver raises ``TokenExpiredError`` for expiry and returns ``None`` for
everything else. The signal survives the negative-cache window via a distinct
``expired`` marker so a retry inside ``NEGATIVE_TTL`` still reports expiry.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock
import pytest
from libs.oauth_bearer import (
OAuthAccessTokenResolver,
TokenExpiredError,
)
def _row(expires_at: datetime):
row = MagicMock()
row.id = "11111111-1111-1111-1111-111111111111"
row.account_id = "22222222-2222-2222-2222-222222222222"
row.prefix = "dfoa_"
row.subject_email = None
row.subject_issuer = None
row.client_id = None
row.expires_at = expires_at
return row
def _resolver(redis: MagicMock, db_row=None) -> OAuthAccessTokenResolver:
session = MagicMock()
session.query.return_value.filter.return_value.one_or_none.return_value = db_row
session.execute.return_value.rowcount = 1
return OAuthAccessTokenResolver(session_factory=lambda: session, redis_client=redis)
def test_resolve_raises_token_expired_for_expired_db_row():
redis = MagicMock()
redis.get.return_value = None # cache miss -> DB path
past = datetime.now(UTC) - timedelta(minutes=1)
resolver = _resolver(redis, db_row=_row(past))
with pytest.raises(TokenExpiredError):
resolver.for_account().resolve("expiredhash")
def test_resolve_raises_token_expired_for_expired_cache_marker():
redis = MagicMock()
redis.get.return_value = b"expired" # negative-cache replay
resolver = _resolver(redis, db_row=None)
with pytest.raises(TokenExpiredError):
resolver.for_account().resolve("expiredhash")
def test_resolve_returns_none_for_invalid_cache_marker():
redis = MagicMock()
redis.get.return_value = b"invalid"
resolver = _resolver(redis, db_row=None)
assert resolver.for_account().resolve("revokedhash") is None
def test_resolve_returns_none_for_unknown_token():
redis = MagicMock()
redis.get.return_value = None # cache miss
resolver = _resolver(redis, db_row=None) # no DB row
assert resolver.for_account().resolve("unknownhash") is None
def test_hard_expire_caches_expired_marker_not_invalid():
redis = MagicMock()
redis.get.return_value = None
past = datetime.now(UTC) - timedelta(minutes=1)
resolver = _resolver(redis, db_row=_row(past))
with pytest.raises(TokenExpiredError):
resolver.for_account().resolve("expiredhash")
setex_values = [call.args[2] for call in redis.setex.call_args_list]
assert "expired" in setex_values
assert "invalid" not in setex_values

View File

@ -89,7 +89,7 @@ async function pollWithRetry(
function expired(): BaseError {
return new BaseError({
code: ErrorCode.TokenExpired,
code: ErrorCode.ExpiredToken,
message: 'code expired before authorization',
})
}

View File

@ -35,6 +35,7 @@ describe('error codes', () => {
[ErrorCode.AuthExpired, ExitCode.Auth],
[ErrorCode.TokenExpired, ExitCode.Auth],
[ErrorCode.AccessDenied, ExitCode.Auth],
[ErrorCode.ExpiredToken, ExitCode.Auth],
[ErrorCode.VersionSkew, ExitCode.VersionCompat],
[ErrorCode.UnsupportedEndpoint, ExitCode.VersionCompat],
[ErrorCode.ConfigSchemaUnsupported, ExitCode.VersionCompat],

View File

@ -3,6 +3,7 @@ export const ErrorCode = {
AuthExpired: 'auth_expired',
TokenExpired: 'token_expired',
AccessDenied: 'access_denied',
ExpiredToken: 'expired_token',
VersionSkew: 'version_skew',
UnsupportedEndpoint: 'unsupported_endpoint',
ConfigSchemaUnsupported: 'config_schema_unsupported',
@ -39,6 +40,7 @@ const CODE_TO_EXIT: Readonly<Record<ErrorCodeValue, ExitCodeValue>> = {
auth_expired: ExitCode.Auth,
token_expired: ExitCode.Auth,
access_denied: ExitCode.Auth,
expired_token: ExitCode.Auth,
version_skew: ExitCode.VersionCompat,
unsupported_endpoint: ExitCode.VersionCompat,
config_schema_unsupported: ExitCode.VersionCompat,

View File

@ -33,7 +33,7 @@ describe('classifyResponse — canonical ErrorBody', () => {
expect(err.code).toBe(ErrorCode.Server4xxOther)
})
it('401 unauthorized classifies as AuthExpired with CLI login hint', async () => {
it('401 classifies by status as AuthExpired with CLI login hint', async () => {
const err = await classified(401, {
code: 'unauthorized',
message: 'session expired or revoked',
@ -44,20 +44,6 @@ describe('classifyResponse — canonical ErrorBody', () => {
expect(err.hint).toBe('run \'difyctl auth login\' to sign in again')
})
it('401 token_expired carries the structured TokenExpired code with the server message', async () => {
const err = await classified(401, {
code: 'token_expired',
message: 'Your session has expired.',
status: 401,
hint: 'Re-authenticate to continue (e.g. re-run your login command).',
})
expect(err.code).toBe(ErrorCode.TokenExpired)
expect(err.exit()).toBe(4)
expect(err.message).toBe('Your session has expired.')
expect(err.hint).toBe('run \'difyctl auth login\' to sign in again')
})
it('unknown future server code is data, not behavior — status bucket decides', async () => {
const err = await classified(409, {
code: 'some_future_code',

View File

@ -1,6 +1,6 @@
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
import type { ErrorCodeValue } from '@/errors/codes'
import { zErrorBody, zOpenApiErrorCode } from '@dify/contracts/api/openapi/zod.gen'
import { zErrorBody } from '@dify/contracts/api/openapi/zod.gen'
import { BaseError, HttpClientError, newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { redactBearer } from './sanitize'
@ -24,16 +24,6 @@ const AUTH_EXPIRED_CLASS: StatusClass = {
includeRaw: false,
}
// A 401 whose body carries the server's `token_expired` code is a known,
// behavior-driving signal (not opaque data): the session lapsed rather than the
// token being unknown/revoked, so wrappers get the distinct structured code.
const TOKEN_EXPIRED_CLASS: StatusClass = {
code: ErrorCode.TokenExpired,
fallbackMessage: () => 'session expired',
hint: AUTH_LOGIN_HINT,
includeRaw: false,
}
const SERVER_5XX_CLASS: StatusClass = {
code: ErrorCode.Server5xx,
fallbackMessage: status => `server error (HTTP ${status})`,
@ -60,9 +50,9 @@ const ACCESS_DENIED_CLASS: StatusClass = {
includeRaw: false,
}
function statusClass(status: number, serverError?: ErrorBody): StatusClass {
function statusClass(status: number): StatusClass {
if (status === 401)
return serverError?.code === zOpenApiErrorCode.enum.token_expired ? TOKEN_EXPIRED_CLASS : AUTH_EXPIRED_CLASS
return AUTH_EXPIRED_CLASS
if (status === 403)
return ACCESS_DENIED_CLASS
if (status === 429)
@ -97,7 +87,7 @@ export async function classifyResponse(request: Request, response: Response): Pr
const serverError = parseServerError(raw)
const status = response.status
const c = statusClass(status, serverError)
const c = statusClass(status)
return new HttpClientError({
code: c.code,
message: serverError?.message ?? c.fallbackMessage(status),

View File

@ -340,7 +340,6 @@ export type OpenApiErrorCode
| 'rate_limit_error'
| 'recipient_surface_mismatch'
| 'request_entity_too_large'
| 'token_expired'
| 'too_many_files'
| 'too_many_requests'
| 'unauthorized'

View File

@ -423,7 +423,6 @@ export const zOpenApiErrorCode = z.enum([
'rate_limit_error',
'recipient_surface_mismatch',
'request_entity_too_large',
'token_expired',
'too_many_files',
'too_many_requests',
'unauthorized',

View File

@ -1,5 +1,5 @@
import { DeveloperApiTab } from '@/features/deployments/detail/access-tab/developer-api'
import { ApiTokensTab } from '@/features/deployments/detail/api-tokens-tab'
export default function InstanceDetailApiTokensPage() {
return <DeveloperApiTab />
return <ApiTokensTab />
}

View File

@ -1,5 +1,5 @@
import { DeployTab } from '@/features/deployments/detail/deploy-tab'
import { InstancesTab } from '@/features/deployments/detail/instances-tab'
export default function InstanceDetailInstancesPage() {
return <DeployTab />
return <InstancesTab />
}

View File

@ -1,5 +1,5 @@
import { VersionsTab } from '@/features/deployments/detail/versions-tab'
import { ReleasesTab } from '@/features/deployments/detail/releases-tab'
export default function InstanceDetailReleasesPage() {
return <VersionsTab />
return <ReleasesTab />
}

View File

@ -31,12 +31,6 @@ vi.mock('@/service/client', () => ({
queryKey: ['getAccessSettings', options.input],
}),
},
getDeveloperApiSettings: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getDeveloperApiSettings', options.input],
}),
},
},
},
},
@ -62,10 +56,6 @@ describe('deployment access state', () => {
enabled: false,
input: skipToken,
})
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
setDeploymentRoute(store)
@ -73,9 +63,5 @@ describe('deployment access state', () => {
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
})
})

View File

@ -10,8 +10,8 @@ import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { DeploymentEmptyState, DeploymentNoticeState, DeploymentStateMessage } from '../../../components/empty-state'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { CopyPill, EndpointRow } from '../../components/endpoint'
import { Section } from '../../components/section'
import { CopyPill, EndpointRow } from '../components/endpoint'
import { accessSettingsQueryAtom } from '../state'
import { getUrlOrigin } from './url'
@ -112,6 +112,120 @@ function ChannelRow({ info, children }: {
)
}
function WebAppChannelRow({ endpoints }: {
endpoints?: AccessEndpoint[]
}) {
const { t } = useTranslation('deployments')
const webappRows = endpoints?.flatMap((endpoint) => {
const endpointUrl = endpoint.endpointUrl
if (!endpointUrl)
return []
return [{
endpoint,
endpointUrl,
}]
}) ?? []
return (
<ChannelRow
info={(
<ChannelInfo
icon={<span className="i-ri-global-line size-3.5" aria-hidden="true" />}
title={t('access.runAccess.webapp')}
description={t('access.runAccess.webappDesc')}
/>
)}
>
{webappRows.length > 0
? (
<div className="flex flex-col gap-1.5">
{webappRows.map(({ endpoint, endpointUrl }) => (
<EndpointRow
key={`webapp-${endpoint.environment?.id ?? endpointUrl}`}
envName={endpoint.environment?.displayName ?? '—'}
label={t('access.runAccess.urlLabel')}
value={endpointUrl}
openLabel={t('access.runAccess.openWebapp')}
/>
))}
</div>
)
: (
<DeploymentNoticeState>
{t('access.runAccess.webappEmpty')}
</DeploymentNoticeState>
)}
</ChannelRow>
)
}
function CliChannelRow({ endpoint }: {
endpoint?: AccessEndpoint
}) {
const { t } = useTranslation('deployments')
const cliDomain = getUrlOrigin(endpoint?.endpointUrl)
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
return (
<ChannelRow
info={(
<ChannelInfo
icon={<span className="i-ri-terminal-box-line size-3.5" aria-hidden="true" />}
title={t('access.cli.title')}
description={t('access.cli.description')}
/>
)}
>
{cliDomain
? (
<div className="flex flex-wrap items-center gap-2">
<CopyPill
label={t('access.cli.domain')}
value={cliDomain}
className="min-w-0 flex-1"
/>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-download-cloud-2-line size-3.5" />
{t('access.cli.install')}
</a>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-book-open-line size-3.5" />
{t('access.cli.docs')}
</a>
</div>
)
: (
<DeploymentNoticeState>
{t('access.cli.empty')}
</DeploymentNoticeState>
)}
</ChannelRow>
)
}
function EnabledAccessChannels({ webAppEndpoints, cliEndpoint }: {
webAppEndpoints?: AccessEndpoint[]
cliEndpoint?: AccessEndpoint
}) {
return (
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
<WebAppChannelRow endpoints={webAppEndpoints} />
<CliChannelRow endpoint={cliEndpoint} />
</div>
)
}
export function AccessChannelsSection() {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
@ -122,18 +236,6 @@ export function AccessChannelsSection() {
const isLoading = accessSettingsQuery.isLoading
const isError = accessSettingsQuery.isError
const runEnabled = accessChannels?.webAppEnabled ?? false
const webappRows = webAppEndpoints?.flatMap((endpoint) => {
const endpointUrl = endpoint.endpointUrl
if (!endpointUrl)
return []
return [{
endpoint,
endpointUrl,
}]
}) ?? []
const cliDomain = getUrlOrigin(cliEndpoint?.endpointUrl)
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
return (
<Section
@ -161,80 +263,10 @@ export function AccessChannelsSection() {
? <DeploymentStateMessage variant="section">{t('common.loadFailed')}</DeploymentStateMessage>
: runEnabled
? (
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
<ChannelRow
info={(
<ChannelInfo
icon={<span className="i-ri-global-line size-3.5" aria-hidden="true" />}
title={t('access.runAccess.webapp')}
description={t('access.runAccess.webappDesc')}
/>
)}
>
{webappRows.length > 0
? (
<div className="flex flex-col gap-1.5">
{webappRows.map(({ endpoint, endpointUrl }) => (
<EndpointRow
key={`webapp-${endpoint.environment?.id ?? endpointUrl}`}
envName={endpoint.environment?.displayName ?? '—'}
label={t('access.runAccess.urlLabel')}
value={endpointUrl}
openLabel={t('access.runAccess.openWebapp')}
/>
))}
</div>
)
: (
<DeploymentNoticeState>
{t('access.runAccess.webappEmpty')}
</DeploymentNoticeState>
)}
</ChannelRow>
<ChannelRow
info={(
<ChannelInfo
icon={<span className="i-ri-terminal-box-line size-3.5" aria-hidden="true" />}
title={t('access.cli.title')}
description={t('access.cli.description')}
/>
)}
>
{cliDomain
? (
<div className="flex flex-wrap items-center gap-2">
<CopyPill
label={t('access.cli.domain')}
value={cliDomain}
className="min-w-0 flex-1"
/>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-download-cloud-2-line size-3.5" />
{t('access.cli.install')}
</a>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-book-open-line size-3.5" />
{t('access.cli.docs')}
</a>
</div>
)
: (
<DeploymentNoticeState>
{t('access.cli.empty')}
</DeploymentNoticeState>
)}
</ChannelRow>
</div>
<EnabledAccessChannels
webAppEndpoints={webAppEndpoints}
cliEndpoint={cliEndpoint}
/>
)
: (
<DeploymentEmptyState

View File

@ -1,242 +0,0 @@
'use client'
import type { Environment } from '@dify/contracts/enterprise/types.gen'
import type { ButtonProps } from '@langgenius/dify-ui/button'
import type { FormEvent, ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectLabel,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { generateApiTokenName } from './api-token-name'
export function ApiKeyGenerateMenu({
environments,
onCreatedToken,
triggerVariant = 'secondary',
triggerClassName,
children,
}: {
environments: Environment[]
onCreatedToken: (token: string) => void
triggerVariant?: ButtonProps['variant']
triggerClassName?: string
children?: (props: { trigger: ReactNode }) => ReactNode
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const nameInputId = useId()
const nameInputRef = useRef<HTMLInputElement>(null)
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string>()
const [draftName, setDraftName] = useState('')
const [nameError, setNameError] = useState(false)
const generateApiKey = useMutation(consoleQuery.enterprise.accessService.createApiKey.mutationOptions())
const selectableEnvironments = environments
const selectedEnvironment = selectedEnvironmentId
? selectableEnvironments.find(env => env.id === selectedEnvironmentId)
: undefined
const disabled = !appInstanceId || selectableEnvironments.length === 0
const isCreating = generateApiKey.isPending
useEffect(() => {
if (createDialogOpen)
nameInputRef.current?.focus()
}, [createDialogOpen])
function handleOpenCreateDialog() {
const firstEnvironment = selectableEnvironments[0]
if (!firstEnvironment)
return
setSelectedEnvironmentId(firstEnvironment.id)
setDraftName(generateApiTokenName())
setNameError(false)
setCreateDialogOpen(true)
}
function handleEnvironmentChange(environmentId: string) {
setSelectedEnvironmentId(environmentId)
setNameError(false)
}
function handleDraftNameChange(nextDraftName: string) {
setDraftName(nextDraftName)
if (nameError && nextDraftName.trim())
setNameError(false)
}
function resetCreateDialog() {
setCreateDialogOpen(false)
setSelectedEnvironmentId(undefined)
setDraftName('')
setNameError(false)
}
function handleDialogOpenChange(nextOpen: boolean) {
if (nextOpen || isCreating)
return
resetCreateDialog()
}
function handleGenerateApiKey(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const name = draftName.trim()
if (!appInstanceId || !selectedEnvironmentId || !name) {
setNameError(true)
return
}
generateApiKey.mutate(
{
params: {
appInstanceId,
environmentId: selectedEnvironmentId,
},
body: {
appInstanceId,
environmentId: selectedEnvironmentId,
displayName: name,
},
},
{
onSuccess: (response) => {
if (response.token)
onCreatedToken(response.token)
resetCreateDialog()
},
onError: () => {
toast.error(t('access.api.createFailed'))
},
},
)
}
const trigger = (
<Button
type="button"
variant={triggerVariant}
disabled={disabled}
onClick={handleOpenCreateDialog}
className={cn('gap-1.5', triggerClassName)}
>
<span className="i-ri-add-line size-4" aria-hidden="true" />
{t('access.api.newKey')}
</Button>
)
return (
<>
{children ? children({ trigger }) : trigger}
<Dialog open={createDialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] overflow-hidden p-0">
<DialogCloseButton disabled={isCreating} />
<form onSubmit={handleGenerateApiKey}>
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('access.api.createKeyTitle')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('access.api.description')}
</DialogDescription>
</div>
<div className="flex flex-col gap-4 px-6 py-5">
<div>
<label
htmlFor={nameInputId}
className="mb-1 block system-sm-medium text-text-secondary"
>
{t('access.api.nameLabel')}
</label>
<Input
ref={nameInputRef}
id={nameInputId}
value={draftName}
disabled={isCreating}
aria-invalid={nameError || undefined}
aria-describedby={nameError ? `${nameInputId}-error` : undefined}
placeholder={t('access.api.namePlaceholder')}
onChange={(event) => {
handleDraftNameChange(event.target.value)
}}
/>
{nameError && (
<div id={`${nameInputId}-error`} className="mt-1 system-xs-regular text-text-destructive">
{t('access.api.nameRequired')}
</div>
)}
</div>
<div>
<Select
value={selectedEnvironmentId ?? null}
disabled={isCreating}
onValueChange={value => value && handleEnvironmentChange(value)}
>
<SelectLabel className="mb-1 block system-sm-medium text-text-secondary">
{t('access.api.table.environment')}
</SelectLabel>
<SelectTrigger>
{selectedEnvironment?.displayName ?? '—'}
</SelectTrigger>
<SelectContent>
{selectableEnvironments.map(env => (
<SelectItem key={env.id} value={env.id}>
<SelectItemText>{env.displayName}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<Button
type="button"
variant="secondary"
disabled={isCreating}
onClick={() => handleDialogOpenChange(false)}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
type="submit"
variant="primary"
loading={isCreating}
disabled={isCreating || !selectedEnvironmentId}
>
{t('access.api.createKey')}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</>
)
}

View File

@ -26,6 +26,7 @@ describe('DeploymentAccessControlDialog', () => {
render(
<DeploymentAccessControlDialog
open
resetKey={1}
initialKind="specific"
initialSubjects={[
{
@ -61,4 +62,20 @@ describe('DeploymentAccessControlDialog', () => {
},
])
})
it('should disable the close button while saving', () => {
render(
<DeploymentAccessControlDialog
open
resetKey={1}
initialKind="organization"
initialSubjects={[]}
saving
onClose={vi.fn()}
onSubmit={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'Close' })).toBeDisabled()
})
})

View File

@ -1,6 +1,6 @@
'use client'
import type { PropsWithChildren } from 'react'
import type { ReactNode } from 'react'
import type { AccessPermissionKind, SelectableAccessSubject } from './access-policy'
import type { AccessSubjectSelectionValue } from './access-subject-selector/types'
import { Button } from '@langgenius/dify-ui/button'
@ -28,6 +28,7 @@ import { AccessSubjectSelectionList } from './access-subject-selector/selection-
export function DeploymentAccessControlDialog({
open,
resetKey,
initialKind,
initialSubjects,
saving,
@ -35,6 +36,7 @@ export function DeploymentAccessControlDialog({
onSubmit,
}: {
open: boolean
resetKey: number
initialKind: AccessPermissionKind
initialSubjects: SelectableAccessSubject[]
saving?: boolean
@ -46,24 +48,29 @@ export function DeploymentAccessControlDialog({
initialSubjects.map(subject => `${subject.subjectType}:${subject.id}`).join(','),
].join(':')
function handleOpenChange(nextOpen: boolean) {
if (nextOpen || saving)
return
onClose()
}
return (
<Dialog open={open} disablePointerDismissal onOpenChange={open => !open && onClose()}>
<Dialog open={open} disablePointerDismissal onOpenChange={handleOpenChange}>
<DialogContent
className={cn(
'h-auto max-h-[calc(100dvh-2rem)] min-h-[323px] w-[600px] max-w-none overflow-y-auto rounded-2xl border-none bg-components-panel-bg p-0 shadow-xl transition-shadow',
)}
>
<DialogCloseButton className="top-5 right-5 size-8" />
{open && (
<DeploymentAccessControlDialogBody
key={draftKey}
initialKind={initialKind}
initialSubjects={initialSubjects}
saving={saving}
onClose={onClose}
onSubmit={onSubmit}
/>
)}
<DialogCloseButton disabled={saving} className="top-5 right-5 size-8" />
<DeploymentAccessControlDialogBody
key={`${resetKey}:${draftKey}`}
initialKind={initialKind}
initialSubjects={initialSubjects}
saving={saving}
onClose={onClose}
onSubmit={onSubmit}
/>
</DialogContent>
</Dialog>
)
@ -162,9 +169,10 @@ function DeploymentAccessControlDialogBody({
)
}
function AccessControlItem({ type, children }: PropsWithChildren<{
function AccessControlItem({ type, children }: {
type: AppAccessMode
}>) {
children: ReactNode
}) {
return (
<RadioRoot<AppAccessMode>
value={type}

View File

@ -16,7 +16,6 @@ import {
import { useDebounce } from 'ahooks'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { useSearchAccessSubjects } from '@/service/access-control/use-access-subjects'
import { SelectedGroupsBreadCrumb, SubjectItem } from './subject-options'
@ -66,7 +65,8 @@ export function AccessSubjectAddButton({
let observer: IntersectionObserver | undefined
if (anchorRef.current) {
observer = new IntersectionObserver((entries) => {
if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && hasMore)
const entry = entries[0]
if (entry?.isIntersecting && !isLoading && !isFetchingNextPage && hasMore)
fetchNextPage()
}, { root: scrollRootRef.current, rootMargin: '20px' })
observer.observe(anchorRef.current)
@ -178,7 +178,7 @@ export function AccessSubjectAddButton({
/>
)}
</ComboboxList>
{isFetchingNextPage && <Loading />}
{isFetchingNextPage && <SubjectOptionsLoadingStatus />}
<div ref={anchorRef} className="h-0" />
</>
)
@ -195,6 +195,20 @@ export function AccessSubjectAddButton({
)
}
function SubjectOptionsLoadingStatus() {
const { t } = useTranslation()
return (
<div
role="status"
aria-label={t('loading', { ns: 'appApi' })}
className="flex h-8 items-center justify-center"
>
<span aria-hidden className="i-ri-loader-2-line size-4 animate-spin text-text-tertiary motion-reduce:animate-none" />
</div>
)
}
function SubjectOptionsSkeleton() {
return (
<div className="flex flex-col gap-1">

View File

@ -91,7 +91,7 @@ function RenderGroupsAndMembers({
return (
<>
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">
{t('accessControlDialog.groups', { ns: 'app', count: selectedGroups.length ?? 0 })}
{t('accessControlDialog.groups', { ns: 'app', count: selectedGroups.length })}
</p>
<div className="flex flex-row flex-wrap gap-1">
{selectedGroups.map(group => (
@ -105,7 +105,7 @@ function RenderGroupsAndMembers({
))}
</div>
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">
{t('accessControlDialog.members', { ns: 'app', count: selectedMembers.length ?? 0 })}
{t('accessControlDialog.members', { ns: 'app', count: selectedMembers.length })}
</p>
<div className="flex flex-row flex-wrap gap-1">
{selectedMembers.map(member => (

View File

@ -5,7 +5,6 @@ import type {
AccessControlAccount,
AccessControlGroup,
Subject,
SubjectAccount,
SubjectGroup,
} from '@/models/access-control'
import { Avatar } from '@langgenius/dify-ui/avatar'
@ -19,6 +18,10 @@ import { useTranslation } from 'react-i18next'
import { useSelector } from '@/context/app-context'
import { SubjectType } from '@/models/access-control'
function isSubjectGroup(subject: Subject): subject is SubjectGroup {
return subject.subjectType === SubjectType.GROUP
}
export function SubjectItem({
subject,
selectedGroups,
@ -30,10 +33,10 @@ export function SubjectItem({
selectedMembers: AccessControlAccount[]
onExpandGroup: (group: AccessControlGroup) => void
}) {
if (subject.subjectType === SubjectType.GROUP) {
if (isSubjectGroup(subject)) {
return (
<GroupItem
group={(subject as SubjectGroup).groupData}
group={subject.groupData}
subject={subject}
selectedGroups={selectedGroups}
onExpandGroup={onExpandGroup}
@ -43,7 +46,7 @@ export function SubjectItem({
return (
<MemberItem
member={(subject as SubjectAccount).accountData}
member={subject.accountData}
subject={subject}
selectedMembers={selectedMembers}
/>

View File

@ -8,6 +8,10 @@ import type {
} from '@/models/access-control'
import { SubjectType } from '@/models/access-control'
function isSubjectGroup(subject: Subject): subject is SubjectGroup {
return subject.subjectType === SubjectType.GROUP
}
function groupToSubject(group: AccessControlGroup): SubjectGroup {
return {
subjectId: group.id,
@ -25,10 +29,10 @@ function memberToSubject(member: AccessControlAccount): SubjectAccount {
}
export function getSubjectLabel(subject: Subject) {
if (subject.subjectType === SubjectType.GROUP)
return (subject as SubjectGroup).groupData.name
if (isSubjectGroup(subject))
return subject.groupData.name
return (subject as SubjectAccount).accountData.name
return subject.accountData.name
}
export function getSubjectValue(subject: Subject) {
@ -54,10 +58,10 @@ export function subjectsToSelectionValue(subjects: Subject[]): AccessSubjectSele
const members: AccessControlAccount[] = []
subjects.forEach((subject) => {
if (subject.subjectType === SubjectType.GROUP)
groups.push((subject as SubjectGroup).groupData)
if (isSubjectGroup(subject))
groups.push(subject.groupData)
else
members.push((subject as SubjectAccount).accountData)
members.push(subject.accountData)
})
return { groups, members }

View File

@ -32,19 +32,17 @@ type AccessPermissionDraft = {
subjects: SelectableAccessSubject[]
}
type EnvironmentPermissionRowProps = {
disabled?: boolean
environment: Environment
summaryPolicy?: AccessPolicy
resolvedSubjects?: Subject[]
}
export function EnvironmentPermissionRow({
disabled,
environment,
summaryPolicy,
resolvedSubjects = [],
}: EnvironmentPermissionRowProps) {
}: {
disabled?: boolean
environment: Environment
summaryPolicy?: AccessPolicy
resolvedSubjects?: Subject[]
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const environmentId = environment.id
@ -56,6 +54,7 @@ export function EnvironmentPermissionRow({
: 'no-policy'
const [draft, setDraft] = useState<AccessPermissionDraft>()
const [dialogOpen, setDialogOpen] = useState(false)
const [dialogSessionKey, setDialogSessionKey] = useState(0)
const subjectLabelCandidates = [
...(draft?.subjects ?? []),
...resolvedSubjects
@ -124,6 +123,11 @@ export function EnvironmentPermissionRow({
})
}
function handleOpenDialog() {
setDialogSessionKey(sessionKey => sessionKey + 1)
setDialogOpen(true)
}
return (
<div className="flex min-w-0 flex-col gap-2 border-b border-divider-subtle py-4 first:pt-0 last:border-b-0 last:pb-0">
<div className="flex min-w-0 items-center">
@ -137,10 +141,11 @@ export function EnvironmentPermissionRow({
disabled={controlsDisabled}
loading={isSaving}
environmentLabel={envName}
onClick={() => setDialogOpen(true)}
onClick={handleOpenDialog}
/>
<DeploymentAccessControlDialog
open={dialogOpen}
resetKey={dialogSessionKey}
initialKind={permissionKind}
initialSubjects={subjects}
saving={isSaving}

View File

@ -17,16 +17,3 @@ export const accessSettingsQueryAtom = atomWithQuery((get) => {
enabled: Boolean(appInstanceId),
})
})
export const developerApiSettingsQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
return consoleQuery.enterprise.accessService.getDeveloperApiSettings.queryOptions({
input: appInstanceId
? {
params: { appInstanceId },
}
: skipToken,
enabled: Boolean(appInstanceId),
})
})

View File

@ -1,19 +1,10 @@
import type { Environment } from '@dify/contracts/enterprise/types.gen'
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { deploymentRouteAppInstanceIdAtom } from '../../../../route-state'
import { ApiKeyGenerateMenu } from '../api-key-generate-menu'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CreateApiKeyButton } from '../create-api-key-button'
import { CreateApiKeyDialog } from '../create-api-key-dialog'
const mockMutate = vi.hoisted(() => vi.fn())
const mockUseAtomValue = vi.hoisted(() => vi.fn())
vi.mock('jotai', async (importOriginal) => {
const actual = await importOriginal<typeof import('jotai')>()
return {
...actual,
useAtomValue: mockUseAtomValue,
}
})
vi.mock('@tanstack/react-query', () => ({
useMutation: () => ({
@ -41,25 +32,28 @@ function createEnvironment(): Environment {
} as Environment
}
describe('ApiKeyGenerateMenu', () => {
function renderCreateApiKeyDialog() {
return render(
<CreateApiKeyDialog
appInstanceId="app-instance-1"
environments={[createEnvironment()]}
open
sessionKey={0}
onCreatedToken={vi.fn()}
onOpenChange={vi.fn()}
/>,
)
}
// API token creation keeps validation and mutation payload shaping inside the dialog content.
describe('CreateApiKeyDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseAtomValue.mockImplementation((atom) => {
if (atom === deploymentRouteAppInstanceIdAtom)
return 'app-instance-1'
return undefined
})
})
it('should show the required name error when submitting an empty name', () => {
render(
<ApiKeyGenerateMenu
environments={[createEnvironment()]}
onCreatedToken={vi.fn()}
/>,
)
renderCreateApiKeyDialog()
fireEvent.click(screen.getByRole('button', { name: 'deployments.access.api.newKey' }))
fireEvent.change(screen.getByLabelText('deployments.access.api.nameLabel'), {
target: { value: ' ' },
})
@ -70,14 +64,8 @@ describe('ApiKeyGenerateMenu', () => {
})
it('should clear the required name error when typing a valid name', () => {
render(
<ApiKeyGenerateMenu
environments={[createEnvironment()]}
onCreatedToken={vi.fn()}
/>,
)
renderCreateApiKeyDialog()
fireEvent.click(screen.getByRole('button', { name: 'deployments.access.api.newKey' }))
const nameInput = screen.getByLabelText('deployments.access.api.nameLabel')
fireEvent.change(nameInput, {
@ -93,15 +81,45 @@ describe('ApiKeyGenerateMenu', () => {
expect(screen.queryByText('deployments.access.api.nameRequired')).not.toBeInTheDocument()
})
it('should disable the trigger when route app instance is missing', () => {
mockUseAtomValue.mockReturnValue(undefined)
it('should create an api key with the entered name and default environment', () => {
renderCreateApiKeyDialog()
render(
<ApiKeyGenerateMenu
environments={[createEnvironment()]}
onCreatedToken={vi.fn()}
/>,
fireEvent.change(screen.getByLabelText('deployments.access.api.nameLabel'), {
target: { value: ' Production key ' },
})
fireEvent.click(screen.getByRole('button', { name: 'deployments.access.api.createKey' }))
expect(mockMutate).toHaveBeenCalledWith(
{
params: {
appInstanceId: 'app-instance-1',
environmentId: 'environment-1',
},
body: {
appInstanceId: 'app-instance-1',
environmentId: 'environment-1',
displayName: 'Production key',
},
},
expect.any(Object),
)
})
})
// The trigger is a placement-neutral button; the owning section controls dialog state.
describe('CreateApiKeyButton', () => {
it('should call the supplied action when enabled', () => {
const handleClick = vi.fn()
render(<CreateApiKeyButton onClick={handleClick} />)
fireEvent.click(screen.getByRole('button', { name: 'deployments.access.api.newKey' }))
expect(handleClick).toHaveBeenCalledOnce()
})
it('should disable the trigger when creation is not available', () => {
render(<CreateApiKeyButton disabled onClick={vi.fn()} />)
expect(screen.getByRole('button', { name: 'deployments.access.api.newKey' })).toBeDisabled()
})

View File

@ -0,0 +1,67 @@
import type { Getter } from 'jotai'
import { skipToken } from '@tanstack/react-query'
import { atom, createStore } from 'jotai'
import { describe, expect, it, vi } from 'vitest'
import { setNextRouteStateAtom } from '@/app/components/next-route-state/atoms'
type QueryOptions = {
enabled?: boolean
input?: unknown
queryKey?: readonly unknown[]
}
vi.mock('jotai-tanstack-query', () => ({
atomWithQuery: (createOptions: (get: Getter) => QueryOptions) => atom(get => ({
...createOptions(get),
data: undefined,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: false,
})),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
accessService: {
getDeveloperApiSettings: {
queryOptions: (options: QueryOptions) => ({
...options,
queryKey: ['getDeveloperApiSettings', options.input],
}),
},
},
},
},
}))
async function loadState() {
return await import('../state')
}
function setDeploymentRoute(store: ReturnType<typeof createStore>, appInstanceId = 'app-instance-1') {
store.set(setNextRouteStateAtom, {
pathname: `/deployments/${appInstanceId}/api-tokens`,
params: { appInstanceId },
})
}
describe('deployment api tokens state', () => {
it('should gate developer api settings until a route app instance exists', async () => {
const state = await loadState()
const store = createStore()
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
enabled: false,
input: skipToken,
})
setDeploymentRoute(store)
expect(store.get(state.developerApiSettingsQueryAtom)).toMatchObject({
enabled: true,
input: { params: { appInstanceId: 'app-instance-1' } },
})
})
})

View File

@ -28,10 +28,10 @@ import {
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../../components/detail-table'
} from '../components/detail-table'
import {
API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from '../../components/detail-table-styles'
} from '../components/detail-table-styles'
function ApiKeyName({ apiKey }: {
apiKey: ApiKey

View File

@ -29,7 +29,11 @@ const API_TOKEN_NAME_NOUNS = [
]
function randomListItem(items: string[]) {
return items[Math.floor(Math.random() * items.length)]!
const item = items[Math.floor(Math.random() * items.length)]
if (item === undefined)
throw new Error('Cannot generate an API token name from an empty list.')
return item
}
export function generateApiTokenName() {

View File

@ -0,0 +1,33 @@
'use client'
import type { ButtonProps } from '@langgenius/dify-ui/button'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
export function CreateApiKeyButton({
disabled,
triggerVariant = 'secondary',
triggerClassName,
onClick,
}: {
disabled?: boolean
triggerVariant?: ButtonProps['variant']
triggerClassName?: string
onClick: () => void
}) {
const { t } = useTranslation('deployments')
return (
<Button
type="button"
variant={triggerVariant}
disabled={disabled}
onClick={onClick}
className={cn('gap-1.5', triggerClassName)}
>
<span className="i-ri-add-line size-4" aria-hidden="true" />
{t('access.api.newKey')}
</Button>
)
}

View File

@ -0,0 +1,225 @@
'use client'
import type { Environment } from '@dify/contracts/enterprise/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { generateApiTokenName } from './api-token-name'
type CreateApiKeyFormValues = {
displayName: string
environmentId: string
}
export function CreateApiKeyDialog({
appInstanceId,
environments,
open,
sessionKey,
onCreatedToken,
onOpenChange,
}: {
appInstanceId: string
environments: Environment[]
open: boolean
sessionKey: number
onCreatedToken: (token: string) => void
onOpenChange: (open: boolean) => void
}) {
const closeBlockedRef = useRef(false)
const [closeBlocked, setCloseBlocked] = useState(false)
function handleCloseBlockedChange(blocked: boolean) {
closeBlockedRef.current = blocked
setCloseBlocked(blocked)
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && closeBlockedRef.current)
return
onOpenChange(nextOpen)
}
return (
<Dialog open={open} disablePointerDismissal={closeBlocked} onOpenChange={handleOpenChange}>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] overflow-hidden p-0">
<CreateApiKeyDialogContent
key={sessionKey}
appInstanceId={appInstanceId}
environments={environments}
onClose={() => onOpenChange(false)}
onCloseBlockedChange={handleCloseBlockedChange}
onCreatedToken={onCreatedToken}
/>
</DialogContent>
</Dialog>
)
}
function CreateApiKeyDialogContent({
appInstanceId,
environments,
onClose,
onCloseBlockedChange,
onCreatedToken,
}: {
appInstanceId: string
environments: Environment[]
onClose: () => void
onCloseBlockedChange: (blocked: boolean) => void
onCreatedToken: (token: string) => void
}) {
const { t } = useTranslation('deployments')
const generateApiKey = useMutation(consoleQuery.enterprise.accessService.createApiKey.mutationOptions())
const isCreating = generateApiKey.isPending
const firstEnvironment = environments[0]
const nameRequiredMessage = t('access.api.nameRequired')
function handleClose() {
if (isCreating)
return
onClose()
}
function handleGenerateApiKey(values: CreateApiKeyFormValues) {
const displayName = values.displayName.trim()
const environmentId = values.environmentId
if (!environmentId || !displayName)
return
onCloseBlockedChange(true)
generateApiKey.mutate(
{
params: {
appInstanceId,
environmentId,
},
body: {
appInstanceId,
environmentId,
displayName,
},
},
{
onSuccess: (response) => {
if (response.token)
onCreatedToken(response.token)
onClose()
},
onError: () => {
toast.error(t('access.api.createFailed'))
},
onSettled: () => {
onCloseBlockedChange(false)
},
},
)
}
return (
<>
<DialogCloseButton disabled={isCreating} />
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('access.api.createKeyTitle')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('access.api.description')}
</DialogDescription>
</div>
<Form<CreateApiKeyFormValues> onFormSubmit={handleGenerateApiKey}>
<div className="flex flex-col gap-4 px-6 py-5">
<FieldRoot
name="displayName"
validate={(value) => {
if (typeof value === 'string' && value.length > 0 && !value.trim())
return nameRequiredMessage
return null
}}
>
<FieldLabel className="system-sm-medium text-text-secondary">
{t('access.api.nameLabel')}
</FieldLabel>
<FieldControl
defaultValue={generateApiTokenName()}
disabled={isCreating}
autoComplete="off"
placeholder={t('access.api.namePlaceholder')}
required
/>
<FieldError match="valueMissing" className="system-xs-regular">{nameRequiredMessage}</FieldError>
<FieldError match="customError" className="system-xs-regular" />
</FieldRoot>
<FieldRoot name="environmentId">
<Select
name="environmentId"
defaultValue={firstEnvironment?.id}
disabled={isCreating}
>
<SelectLabel className="system-sm-medium text-text-secondary">
{t('access.api.table.environment')}
</SelectLabel>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{environments.map(env => (
<SelectItem key={env.id} value={env.id}>
<SelectItemText>{env.displayName}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</FieldRoot>
</div>
<div className="flex justify-end gap-2 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<Button
type="button"
variant="secondary"
disabled={isCreating}
onClick={handleClose}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
type="submit"
variant="primary"
loading={isCreating}
disabled={isCreating || !firstEnvironment}
>
{t('access.api.createKey')}
</Button>
</div>
</Form>
</>
)
}

View File

@ -53,44 +53,63 @@ function CurlExample({ apiUrl, token }: {
)
}
export function CreatedApiTokenDialog({ token, apiUrl, onDismiss }: {
token: string
function CreatedApiTokenDialogContent({ token, apiUrl, onDismiss }: {
token?: string
apiUrl?: string
onDismiss: () => void
}) {
const { t } = useTranslation('deployments')
if (!token)
return null
return (
<>
<DialogCloseButton />
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('access.api.newTokenTitle')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('access.api.newTokenDescription')}
</DialogDescription>
</div>
<div className="flex flex-col gap-5 px-6 py-5">
<CopyPill
label={t('access.api.newTokenLabel')}
value={token}
/>
{apiUrl && (
<CurlExample
apiUrl={apiUrl}
token={token}
/>
)}
</div>
<div className="flex justify-end border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<Button variant="primary" onClick={onDismiss}>
{t('operation.confirm', { ns: 'common' })}
</Button>
</div>
</>
)
}
export function CreatedApiTokenDialog({ token, apiUrl, onDismiss }: {
token?: string
apiUrl?: string
onDismiss: () => void
}) {
return (
<Dialog open={Boolean(token)} onOpenChange={open => !open && onDismiss()} disablePointerDismissal>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] overflow-hidden p-0">
<DialogCloseButton />
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('access.api.newTokenTitle')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('access.api.newTokenDescription')}
</DialogDescription>
</div>
<div className="flex flex-col gap-5 px-6 py-5">
<CopyPill
label={t('access.api.newTokenLabel')}
value={token}
/>
{apiUrl && (
<CurlExample
apiUrl={apiUrl}
token={token}
/>
)}
</div>
<div className="flex justify-end border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<Button variant="primary" onClick={onDismiss}>
{t('operation.confirm', { ns: 'common' })}
</Button>
</div>
<CreatedApiTokenDialogContent
token={token}
apiUrl={apiUrl}
onDismiss={onDismiss}
/>
</DialogContent>
</Dialog>
)

View File

@ -0,0 +1,65 @@
'use client'
import type { AccessChannels } from '@dify/contracts/enterprise/types.gen'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
import { developerApiSettingsQueryAtom } from './state'
function DeveloperApiSwitch({ checked, accessChannels, disabled }: {
checked: boolean
accessChannels?: AccessChannels
disabled?: boolean
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
return (
<Switch
aria-label={t('access.api.developerTitle')}
checked={checked}
disabled={disabled || !appInstanceId}
loading={toggleDeveloperAPI.isPending}
onCheckedChange={(enabled) => {
if (!appInstanceId)
return
toggleDeveloperAPI.mutate({
params: { appInstanceId },
body: {
appInstanceId,
webAppEnabled: accessChannels?.webAppEnabled ?? false,
developerApiEnabled: enabled,
},
})
}}
/>
)
}
export function DeveloperApiHeaderSwitch() {
const { t } = useTranslation('deployments')
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
const accessChannels = developerApiSettingsQuery.data?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
if (developerApiSettingsQuery.isLoading)
return <SwitchSkeleton />
return (
<div className="flex items-center gap-2">
<span className="system-xs-medium text-text-tertiary">
{apiEnabled ? t('overview.enabled') : t('overview.disabled')}
</span>
<DeveloperApiSwitch
checked={apiEnabled}
accessChannels={accessChannels}
disabled={developerApiSettingsQuery.isError}
/>
</div>
)
}

View File

@ -22,7 +22,7 @@ import { useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { getDocLanguage } from '@/i18n-config/language'
import { AppModeEnum, Theme } from '@/types/app'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
type PromptVariable = { key: string, name: string }
type WorkflowApiDocAppDetail = Pick<App, 'id' | 'mode' | 'api_base_url'>

View File

@ -1,11 +1,11 @@
'use client'
import { DeveloperApiSection } from './section'
import { ApiTokensSection } from './section'
export function DeveloperApiTab() {
export function ApiTokensTab() {
return (
<div className="flex w-full max-w-[960px] min-w-0 flex-col gap-y-4 px-6 py-6 sm:px-20 sm:py-8">
<DeveloperApiSection />
<ApiTokensSection />
</div>
)
}

View File

@ -1,88 +1,30 @@
'use client'
import type {
AccessChannels,
ApiKey,
Environment,
} from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../../components/empty-state'
import { deploymentRouteAppInstanceIdAtom } from '../../../route-state'
import { DeploymentEmptyState, DeploymentStateMessage } from '../../components/empty-state'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
import { CopyPill } from '../components/endpoint'
import { developerApiSettingsQueryAtom } from '../state'
import { ApiKeyGenerateMenu } from './api-key-generate-menu'
import { ApiKeyList } from './api-key-list'
import { CreateApiKeyButton } from './create-api-key-button'
import { CreateApiKeyDialog } from './create-api-key-dialog'
import { CreatedApiTokenDialog } from './created-token-dialog'
import { DeveloperApiDocsDrawer } from './docs-drawer'
import { DeveloperApiSkeleton } from './skeleton'
import { developerApiSettingsQueryAtom } from './state'
type CreatedApiToken = {
appInstanceId: string
token: string
}
function DeveloperApiSwitch({ checked, accessChannels, disabled }: {
checked: boolean
accessChannels?: AccessChannels
disabled?: boolean
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
return (
<Switch
aria-label={t('access.api.developerTitle')}
checked={checked}
disabled={disabled || !appInstanceId}
loading={toggleDeveloperAPI.isPending}
onCheckedChange={(enabled) => {
if (!appInstanceId)
return
toggleDeveloperAPI.mutate({
params: { appInstanceId },
body: {
appInstanceId,
webAppEnabled: accessChannels?.webAppEnabled ?? false,
developerApiEnabled: enabled,
},
})
}}
/>
)
}
export function DeveloperApiHeaderSwitch() {
const { t } = useTranslation('deployments')
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
const accessChannels = developerApiSettingsQuery.data?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
if (developerApiSettingsQuery.isLoading)
return <SwitchSkeleton />
return (
<div className="flex items-center gap-2">
<span className="system-xs-medium text-text-tertiary">
{apiEnabled ? t('overview.enabled') : t('overview.disabled')}
</span>
<DeveloperApiSwitch
checked={apiEnabled}
accessChannels={accessChannels}
disabled={developerApiSettingsQuery.isError}
/>
</div>
)
}
function ApiKeyListSection({ apiKeys, environments, action }: {
apiKeys: ApiKey[]
environments: Environment[]
@ -141,20 +83,36 @@ function DeveloperApiEndpoint({ apiUrl }: {
)
}
export function DeveloperApiSection() {
export function ApiTokensSection() {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const [createdApiToken, setCreatedApiToken] = useState<CreatedApiToken>()
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [createDialogSessionKey, setCreateDialogSessionKey] = useState(0)
const developerApiSettingsQuery = useAtomValue(developerApiSettingsQueryAtom)
const accessChannels = developerApiSettingsQuery.data?.accessChannels
const apiEnabled = accessChannels?.developerApiEnabled ?? false
const apiUrl = developerApiSettingsQuery.data?.developerApiUrl.apiUrl
const apiKeys: ApiKey[] = developerApiSettingsQuery.data?.apiKeys ?? []
const environments = developerApiSettingsQuery.data?.environments ?? []
const selectableEnvironments = environments.flatMap((environment) => {
if (!environment.id)
return []
return [environment]
})
const visibleCreatedApiToken = createdApiToken && createdApiToken.appInstanceId === appInstanceId
? createdApiToken.token
: undefined
const hasSelectableEnvironment = environments.some(environment => Boolean(environment.id))
const hasSelectableEnvironment = selectableEnvironments.length > 0
function handleOpenCreateDialog() {
if (!hasSelectableEnvironment)
return
setCreateDialogSessionKey(sessionKey => sessionKey + 1)
setCreateDialogOpen(true)
}
if (developerApiSettingsQuery.isLoading)
return <DeveloperApiSkeleton />
@ -181,31 +139,33 @@ export function DeveloperApiSection() {
/>
)}
{hasSelectableEnvironment
? (
<ApiKeyGenerateMenu
environments={environments}
triggerVariant="primary"
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
>
{({ trigger }) => apiKeys.length === 0
? (
<DeploymentEmptyState
variant="section"
icon="i-ri-key-2-line"
title={t('access.api.noKeysTitle')}
description={t('access.api.noKeys')}
action={trigger}
/>
)
: (
<ApiKeyListSection
apiKeys={apiKeys}
environments={environments}
action={trigger}
/>
)}
</ApiKeyGenerateMenu>
)
? apiKeys.length === 0
? (
<DeploymentEmptyState
variant="section"
icon="i-ri-key-2-line"
title={t('access.api.noKeysTitle')}
description={t('access.api.noKeys')}
action={(
<CreateApiKeyButton
triggerVariant="primary"
onClick={handleOpenCreateDialog}
/>
)}
/>
)
: (
<ApiKeyListSection
apiKeys={apiKeys}
environments={environments}
action={(
<CreateApiKeyButton
triggerVariant="primary"
onClick={handleOpenCreateDialog}
/>
)}
/>
)
: apiKeys.length === 0
? (
<DeploymentEmptyState
@ -221,13 +181,19 @@ export function DeveloperApiSection() {
environments={environments}
/>
)}
{visibleCreatedApiToken && (
<CreatedApiTokenDialog
token={visibleCreatedApiToken}
apiUrl={apiUrl}
onDismiss={() => setCreatedApiToken(undefined)}
/>
)}
<CreateApiKeyDialog
appInstanceId={appInstanceId}
environments={selectableEnvironments}
open={createDialogOpen}
sessionKey={createDialogSessionKey}
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
onOpenChange={setCreateDialogOpen}
/>
<CreatedApiTokenDialog
token={visibleCreatedApiToken}
apiUrl={apiUrl}
onDismiss={() => setCreatedApiToken(undefined)}
/>
</div>
)
}

View File

@ -11,8 +11,8 @@ import {
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../../components/detail-table'
import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../../components/detail-table-styles'
} from '../components/detail-table'
import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../components/detail-table-styles'
const DEVELOPER_API_KEY_SKELETON_KEYS = ['primary-key', 'secondary-key']

View File

@ -0,0 +1,19 @@
'use client'
import { skipToken } from '@tanstack/react-query'
import { atomWithQuery } from 'jotai-tanstack-query'
import { consoleQuery } from '@/service/client'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
export const developerApiSettingsQueryAtom = atomWithQuery((get) => {
const appInstanceId = get(deploymentRouteAppInstanceIdAtom)
return consoleQuery.enterprise.accessService.getDeveloperApiSettings.queryOptions({
input: appInstanceId
? {
params: { appInstanceId },
}
: skipToken,
enabled: Boolean(appInstanceId),
})
})

View File

@ -1,135 +0,0 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../components/detail-table-styles'
export function DeploymentActionsDropdown({
currentReleaseId,
deployActionLabel,
failedReleaseId,
isDeployFailed,
isDeploymentInProgress,
isUndeployed,
undeployActionDisabled,
onDeploy,
onRequestUndeploy,
onViewError,
}: {
currentReleaseId?: string
deployActionLabel: string
failedReleaseId?: string
isDeployFailed: boolean
isDeploymentInProgress: boolean
isUndeployed: boolean
undeployActionDisabled: boolean
onDeploy: (releaseId?: string) => void
onRequestUndeploy: () => void
onViewError: () => void
}) {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
if (isDeploymentInProgress)
return null
function handleDeployAction(releaseId?: string) {
onDeploy(releaseId)
setOpen(false)
}
function handleViewError() {
onViewError()
setOpen(false)
}
function handleRequestUndeploy() {
if (undeployActionDisabled)
return
onRequestUndeploy()
setOpen(false)
}
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
aria-label={t('deployTab.moreActions')}
className={DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-44">
{isDeployFailed
? (
<>
<DropdownMenuItem
className="gap-2 px-3"
onClick={handleViewError}
>
<span aria-hidden className="i-ri-error-warning-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{t('deployTab.viewError')}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => handleDeployAction(failedReleaseId)}
>
<span aria-hidden className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{failedReleaseId ? t('deployTab.retry') : t('deployTab.deployOtherVersion')}
</span>
</DropdownMenuItem>
</>
)
: (
<>
{!isUndeployed && currentReleaseId && (
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => handleDeployAction(currentReleaseId)}
>
<span aria-hidden className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{t('deployTab.redeploy')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => handleDeployAction()}
>
<span aria-hidden className="i-ri-rocket-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{deployActionLabel}</span>
</DropdownMenuItem>
</>
)}
{!isUndeployed && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={undeployActionDisabled}
aria-disabled={undeployActionDisabled}
className={cn(
'gap-2 px-3',
undeployActionDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={handleRequestUndeploy}
>
<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>
)
}

View File

@ -10,10 +10,10 @@ import Link from '@/next/link'
import { useSelectedLayoutSegment } from '@/next/navigation'
import { CreateReleaseControl } from '../create-release'
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
import { DeveloperApiHeaderSwitch } from './access-tab/developer-api/section'
import { NewDeploymentHeaderAction } from './deploy-tab/new-deployment-button'
import { DeveloperApiHeaderSwitch } from './api-tokens-tab/developer-api-header-switch'
import { NewDeploymentHeaderAction } from './instances-tab/new-deployment-button'
import { releasesTabLocalAtoms } from './releases-tab/state'
import { INSTANCE_DETAIL_TAB_KEYS, isInstanceDetailTabKey } from './tabs'
import { versionsTabLocalAtoms } from './versions-tab/state'
function MobileDetailTabs({ appInstanceId, activeTab }: {
appInstanceId: string
@ -63,7 +63,7 @@ export function InstanceDetail({ children }: {
<ScopeProvider
key={appInstanceId}
atoms={[
...versionsTabLocalAtoms,
...releasesTabLocalAtoms,
]}
name="DeploymentDetail"
>

View File

@ -75,7 +75,6 @@ function CurrentReleaseMobileSummary({ release }: {
function DeploymentEnvironmentMobileRow({ row }: {
row: EnvironmentDeployment
}) {
const envId = row.environment.id
const release = row.currentRelease
return (
@ -87,7 +86,7 @@ function DeploymentEnvironmentMobileRow({ row }: {
</div>
{!isUndeployedDeploymentRow(row) && <CurrentReleaseMobileSummary release={release} />}
<div className="flex min-w-0 items-center justify-start gap-2">
<DeploymentRowActions envId={envId} row={row} />
<DeploymentRowActions row={row} />
</div>
</div>
</DetailTableCard>
@ -114,7 +113,7 @@ function DeploymentEnvironmentDesktopRows({ rows }: {
</DetailTableCell>
<DetailTableCell className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.actions}>
<div className="flex min-h-8 justify-end">
<DeploymentRowActions envId={envId} row={row} />
<DeploymentRowActions row={row} />
</div>
</DetailTableCell>
</DetailTableRow>

View File

@ -0,0 +1,135 @@
'use client'
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { isRuntimeDeploymentInProgress, isUndeployedDeploymentRow } from '../../shared/domain/runtime-status'
import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../components/detail-table-styles'
export function DeploymentActionsDropdown({
row,
undeployActionDisabled,
onDeploy,
onRequestUndeploy,
onViewError,
}: {
row: EnvironmentDeployment
undeployActionDisabled: boolean
onDeploy: (releaseId?: string) => void
onRequestUndeploy: () => void
onViewError: () => void
}) {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const isUndeployed = isUndeployedDeploymentRow(row)
const status = row.status
const isDeploymentInProgress = isRuntimeDeploymentInProgress(status)
const isDeployFailed = status === RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_FAILED
const currentReleaseId = row.currentRelease?.id
const failedReleaseId = row.desiredRelease?.id ?? row.currentRelease?.id
const deployActionLabel = isUndeployed
? t('deployDrawer.deploy')
: t('deployTab.deployOtherVersion')
if (isDeploymentInProgress)
return null
function handleDeployAction(releaseId?: string) {
onDeploy(releaseId)
setOpen(false)
}
function handleViewError() {
onViewError()
setOpen(false)
}
function handleRequestUndeploy() {
if (undeployActionDisabled)
return
onRequestUndeploy()
setOpen(false)
}
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
aria-label={t('deployTab.moreActions')}
className={DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="min-w-44">
{isDeployFailed
? (
<>
<DropdownMenuItem
className="gap-2 px-3"
onClick={handleViewError}
>
<span aria-hidden className="i-ri-error-warning-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{t('deployTab.viewError')}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => handleDeployAction(failedReleaseId)}
>
<span aria-hidden className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{failedReleaseId ? t('deployTab.retry') : t('deployTab.deployOtherVersion')}
</span>
</DropdownMenuItem>
</>
)
: (
<>
{!isUndeployed && currentReleaseId && (
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => handleDeployAction(currentReleaseId)}
>
<span aria-hidden className="i-ri-refresh-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{t('deployTab.redeploy')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => handleDeployAction()}
>
<span aria-hidden className="i-ri-rocket-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{deployActionLabel}</span>
</DropdownMenuItem>
</>
)}
{!isUndeployed && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={undeployActionDisabled}
aria-disabled={undeployActionDisabled}
className={cn(
'gap-2 px-3',
undeployActionDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={handleRequestUndeploy}
>
<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>
)
}

View File

@ -5,7 +5,6 @@ import { RuntimeInstanceStatus } from '@dify/contracts/enterprise/types.gen'
import { useMutation } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { openDeployDrawerAtom } from '../../deploy-drawer/state'
import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
@ -15,27 +14,21 @@ import { DeploymentErrorDialog } from './deployment-error-dialog'
import { DeploymentActionsDropdown } from './deployment-row-actions-menu'
import { UndeployDeploymentDialog } from './undeploy-deployment-dialog'
export function DeploymentRowActions({ envId, row }: {
envId: string
export function DeploymentRowActions({ row }: {
row: EnvironmentDeployment
}) {
const { t } = useTranslation('deployments')
const routeAppInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const undeployDeployment = useMutation(consoleQuery.enterprise.deploymentService.undeploy.mutationOptions())
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
const [showErrorDetail, setShowErrorDetail] = useState(false)
const envId = row.environment.id
const isUndeployed = isUndeployedDeploymentRow(row)
const status = row.status
const isUndeployRequesting = undeployDeployment.isPending
const undeployActionDisabled = isUndeployRequesting
const isDeploymentInProgress = isRuntimeDeploymentInProgress(status)
const isDeployFailed = status === RuntimeInstanceStatus.RUNTIME_INSTANCE_STATUS_FAILED
const currentReleaseId = row.currentRelease?.id
const failedReleaseId = row.desiredRelease?.id ?? row.currentRelease?.id
const deployActionLabel = isUndeployed
? t('deployDrawer.deploy')
: t('deployTab.deployOtherVersion')
if (!routeAppInstanceId)
return null
@ -75,36 +68,27 @@ export function DeploymentRowActions({ envId, row }: {
onKeyDown={e => e.stopPropagation()}
>
<DeploymentActionsDropdown
currentReleaseId={currentReleaseId}
deployActionLabel={deployActionLabel}
failedReleaseId={failedReleaseId}
isDeployFailed={isDeployFailed}
isDeploymentInProgress={isDeploymentInProgress}
isUndeployed={isUndeployed}
row={row}
undeployActionDisabled={undeployActionDisabled}
onDeploy={handleDeployAction}
onRequestUndeploy={() => setShowUndeployConfirm(true)}
onViewError={() => setShowErrorDetail(true)}
/>
{isDeployFailed && (
<DeploymentErrorDialog
open={showErrorDetail}
row={row}
onOpenChange={setShowErrorDetail}
/>
)}
<DeploymentErrorDialog
open={showErrorDetail && isDeployFailed}
row={row}
onOpenChange={setShowErrorDetail}
/>
{!isUndeployed && !isDeploymentInProgress && (
<UndeployDeploymentDialog
open={showUndeployConfirm}
row={row}
isRequesting={isUndeployRequesting}
disabled={undeployActionDisabled}
onConfirm={handleUndeploy}
onOpenChange={setShowUndeployConfirm}
/>
)}
<UndeployDeploymentDialog
open={showUndeployConfirm && !isUndeployed && !isDeploymentInProgress}
row={row}
isRequesting={isUndeployRequesting}
disabled={undeployActionDisabled}
onConfirm={handleUndeploy}
onOpenChange={setShowUndeployConfirm}
/>
</div>
)
}

View File

@ -87,7 +87,7 @@ function DeploymentEnvironmentListSkeleton() {
)
}
export function DeployTab() {
export function InstancesTab() {
const { t } = useTranslation('deployments')
const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom)
const environmentDeployments = environmentDeploymentsQuery.data

View File

@ -11,16 +11,6 @@ import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
import { DeploymentStatusBadge } from '../../shared/ui/deployment-status-badge'
import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME, OVERVIEW_INTERACTIVE_CARD_CLASS_NAME } from './card-styles'
type AccessStatusSectionProps = {
accessChannels?: AccessChannels
}
type ApiTokenSummarySectionProps = {
accessChannels?: AccessChannels
apiKeySummary?: ApiKeySummary
deployedEnvironmentCount: number
}
type AccessStatusItem = {
key: 'webapp' | 'cli'
href: string
@ -32,7 +22,9 @@ type AccessStatusItem = {
const ACCESS_STATUS_SKELETON_KEYS = ['webapp', 'cli']
export function AccessStatusSection({ accessChannels }: AccessStatusSectionProps) {
export function AccessStatusSection({ accessChannels }: {
accessChannels?: AccessChannels
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
@ -108,7 +100,11 @@ export function ApiTokenSummarySection({
accessChannels,
apiKeySummary,
deployedEnvironmentCount,
}: ApiTokenSummarySectionProps) {
}: {
accessChannels?: AccessChannels
apiKeySummary?: ApiKeySummary
deployedEnvironmentCount: number
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const apiEnabled = Boolean(accessChannels?.developerApiEnabled)

View File

@ -16,12 +16,10 @@ import { EnvironmentTile } from './environment-tile'
const OVERVIEW_RUNTIME_INSTANCE_LIMIT = 4
type EnvironmentStripProps = {
export function EnvironmentStrip({ rows, releaseRows }: {
rows: EnvironmentDeployment[]
releaseRows: Release[]
}
export function EnvironmentStrip({ rows, releaseRows }: EnvironmentStripProps) {
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const runtimeRows = rows.filter(hasRuntimeInstanceDeployment)

View File

@ -27,12 +27,10 @@ import {
} from './environment-tile-utils'
import { computeDrift, latestReleaseId } from './overview-drift'
type EnvironmentTileProps = {
export function EnvironmentTile({ row, releaseRows }: {
row: EnvironmentDeployment
releaseRows: Release[]
}
export function EnvironmentTile({ row, releaseRows }: EnvironmentTileProps) {
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
@ -102,8 +100,8 @@ export function EnvironmentTile({ row, releaseRows }: EnvironmentTileProps) {
</h4>
</div>
<div className="flex shrink-0 items-center gap-2">
<RuntimeStatusSignal status={status} t={t} />
{showStatusSignal && <StatusSignal config={config} drift={drift} t={t} />}
<RuntimeStatusSignal status={status} />
{showStatusSignal && <StatusSignal config={config} drift={drift} />}
</div>
</div>
@ -138,10 +136,10 @@ export function EnvironmentTile({ row, releaseRows }: EnvironmentTileProps) {
)
}
function RuntimeStatusSignal({ status, t }: {
function RuntimeStatusSignal({ status }: {
status: RuntimeInstanceStatusValue
t: ReturnType<typeof useTranslation<'deployments'>>['t']
}) {
const { t } = useTranslation('deployments')
const label = t(deploymentStatusLabelKey(status))
return (
@ -151,12 +149,12 @@ function RuntimeStatusSignal({ status, t }: {
)
}
function StatusSignal({ className, config, drift, t }: {
function StatusSignal({ className, config, drift }: {
className?: string
config: TileConfig
drift: ReturnType<typeof computeDrift>
t: ReturnType<typeof useTranslation<'deployments'>>['t']
}) {
const { t } = useTranslation('deployments')
const title = renderDriftTitle(config.kind, drift, t)
return (

View File

@ -1,5 +1,6 @@
'use client'
import type { ReactNode } from 'react'
import { useAtomValue } from 'jotai'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
@ -11,7 +12,7 @@ import { AccessStatusSection, AccessStatusSectionSkeleton, ApiTokenSummarySectio
import { EnvironmentStrip, EnvironmentStripSkeleton } from './environment-strip'
import { ReleaseHero, ReleaseHeroSkeleton } from './release-hero'
function OverviewLayout({ children }: { children: React.ReactNode }) {
function OverviewLayout({ children }: { children: ReactNode }) {
return (
<div className="flex w-full min-w-0 flex-col gap-6 px-6 py-6">
{children}
@ -20,7 +21,7 @@ function OverviewLayout({ children }: { children: React.ReactNode }) {
}
function LatestReleaseSection({ children }: {
children: React.ReactNode
children: ReactNode
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)

View File

@ -18,18 +18,10 @@ import { deploymentRouteAppInstanceIdAtom } from '../../route-state'
import { formatDate, releaseCommit } from '../../shared/domain/release'
import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME } from './card-styles'
type ReleaseHeroProps = {
export function ReleaseHero({ latestRelease, releaseCount }: {
latestRelease?: Release
releaseCount: number
}
type ReleaseMetaItemProps = {
label?: string
showSeparator?: boolean
children: ReactNode
}
export function ReleaseHero({ latestRelease, releaseCount }: ReleaseHeroProps) {
}) {
const { t } = useTranslation('deployments')
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const { formatTimeFromNow } = useFormatTimeFromNow()
@ -99,7 +91,11 @@ export function ReleaseHero({ latestRelease, releaseCount }: ReleaseHeroProps) {
)
}
function ReleaseMetaItem({ label, showSeparator = true, children }: ReleaseMetaItemProps) {
function ReleaseMetaItem({ label, showSeparator = true, children }: {
label?: string
showSeparator?: boolean
children: ReactNode
}) {
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
{showSeparator && (

View File

@ -103,7 +103,7 @@ describe('DeployReleaseMenu', () => {
render(
<DeployReleaseMenu
releaseId={release.id}
release={release}
releaseRows={[release]}
/>,
)

View File

@ -52,6 +52,13 @@ function createReleaseRow(overrides: Partial<ReleaseWithSummaryDeployments> = {}
} as ReleaseWithSummaryDeployments
}
function requireElement<T extends Element>(element: T | null, label: string): T {
expect(element).not.toBeNull()
if (!element)
throw new Error(`${label} should exist`)
return element
}
describe('ReleaseHistoryRows', () => {
it('should render the desktop release list with the knowledge table style', () => {
const { container } = render(
@ -60,11 +67,11 @@ describe('ReleaseHistoryRows', () => {
/>,
)
const table = container.querySelector('table')
const tableScope = within(table!)
const header = table!.querySelector('thead')
const headerCell = table!.querySelector('th')
const bodyRow = table!.querySelector('tbody tr')
const table = requireElement(container.querySelector('table'), 'release table')
const tableScope = within(table)
const header = requireElement(table.querySelector('thead'), 'release table header')
const headerCell = requireElement(table.querySelector('th'), 'release table header cell')
const bodyRow = requireElement(table.querySelector('tbody tr'), 'release table row')
expect(table).toHaveClass('w-full', 'border-collapse', 'border-0', 'text-sm')
expect(header).toHaveClass('border-b', 'border-divider-subtle')
@ -88,8 +95,8 @@ describe('ReleaseHistoryRows', () => {
/>,
)
const table = container.querySelector('table')
const deploymentLabel = table!.querySelector('.text-util-colors-green-green-600')
const table = requireElement(container.querySelector('table'), 'release table')
const deploymentLabel = requireElement(table.querySelector('.text-util-colors-green-green-600'), 'deployment label')
expect(deploymentLabel).toHaveTextContent('test-cpu')
expect(deploymentLabel).toHaveClass('text-util-colors-green-green-600', 'system-xs-medium')
@ -109,8 +116,8 @@ describe('ReleaseHistoryRows', () => {
/>,
)
const table = container.querySelector('table')
const sourceLink = within(table!).getByRole('link', { name: /Source Workflow/ })
const table = requireElement(container.querySelector('table'), 'release table')
const sourceLink = within(table).getByRole('link', { name: /Source Workflow/ })
expect(sourceLink).toHaveAttribute('href', '/app/source-app-1/workflow')
expect(sourceLink).toHaveAttribute('target', '_blank')

View File

@ -39,17 +39,19 @@ type ExportReleaseDslInput = {
appInstanceName?: string
}
export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
releaseId: string
export function DeployReleaseMenu({ release, releaseRows, onDeleted }: {
release: Release
releaseRows: Release[]
onDeleted?: () => void
}) {
const { t } = useTranslation('deployments')
const releaseId = release.id
const appInstanceId = useAtomValue(deploymentRouteAppInstanceIdAtom)
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const openReleaseMenuId = useAtomValue(deployReleaseMenuOpenReleaseIdAtom)
const setDeployReleaseMenuOpen = useSetAtom(setDeployReleaseMenuOpenAtom)
const [showEditDialog, setShowEditDialog] = useState(false)
const [editDialogSessionKey, setEditDialogSessionKey] = useState(0)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const open = openReleaseMenuId === releaseId
const environmentDeploymentsQuery = useAtomValue(deployReleaseMenuEnvironmentDeploymentsQueryAtom)
@ -63,13 +65,8 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
const environments = (environmentDeploymentsQuery.data?.environmentDeployments ?? [])
.map(row => row.environment)
const deploymentRows = environmentDeploymentsQuery.data?.environmentDeployments.filter(row => !isUndeployedDeploymentRow(row)) ?? []
const targetRelease = releaseRows.find(release => release.id === releaseId)
const appInstanceName = appInstanceQuery.data?.appInstance.displayName
if (!targetRelease)
return null
const release = targetRelease
const targetReleaseName = release.displayName
const deleteUsageCount = releaseUsageCount(releaseId, deploymentRows)
const isCheckingDeleteUsage = open && environmentDeploymentsQuery.isLoading
@ -148,96 +145,96 @@ export function DeployReleaseMenu({ releaseId, releaseRows, onDeleted }: {
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-60">
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-60">
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => {
handleOpenChange(false)
setEditDialogSessionKey(sessionKey => sessionKey + 1)
setShowEditDialog(true)
}}
>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{t('versions.editRelease')}
</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={isExportingDsl}
aria-disabled={isExportingDsl}
className={cn(
'gap-2 px-3',
isExportingDsl && 'cursor-not-allowed opacity-60',
)}
onClick={handleExportDsl}
>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{isExportingDsl ? t('versions.exportingDsl') : t('versions.exportDsl')}
</span>
</DropdownMenuItem>
{groupedRows.length > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
{groupedRows.map((section, sectionIndex) => (
<div key={section.group}>
{sectionIndex > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
<div className="px-3 pt-1.5 pb-1 system-2xs-medium-uppercase text-text-quaternary">
{t(`versions.groupHeader.${section.group}`)}
</div>
{section.rows.map((row) => {
const isDisabled = row.state === 'current' || row.state === 'deploying'
return (
<TitleTooltip key={row.environmentId} content={isDisabled ? row.disabledReason : undefined}>
<DropdownMenuItem
disabled={isDisabled}
aria-disabled={isDisabled}
className={cn(
'gap-2 px-3',
isDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={() => {
if (isDisabled || !appInstanceId)
return
handleOpenChange(false)
openDeployDrawer({ appInstanceId, environmentId: row.environmentId, releaseId })
}}
>
<span className="system-sm-regular text-text-secondary">
{row.label}
</span>
</DropdownMenuItem>
</TitleTooltip>
)
})}
</div>
))}
<div className="my-1 border-t border-divider-subtle" aria-hidden />
<TitleTooltip content={deleteDisabledReason}>
<DropdownMenuItem
className="gap-2 px-3"
onClick={() => {
handleOpenChange(false)
setShowEditDialog(true)
}}
>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{t('versions.editRelease')}
</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={isExportingDsl}
aria-disabled={isExportingDsl}
variant="destructive"
disabled={deleteActionDisabled}
aria-disabled={deleteActionDisabled}
className={cn(
'gap-2 px-3',
isExportingDsl && 'cursor-not-allowed opacity-60',
deleteActionDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={handleExportDsl}
onClick={() => {
if (deleteActionDisabled)
return
handleOpenChange(false)
setShowDeleteConfirm(true)
}}
>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">
{isExportingDsl ? t('versions.exportingDsl') : t('versions.exportDsl')}
</span>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="system-sm-regular">{t('versions.deleteRelease')}</span>
</DropdownMenuItem>
{groupedRows.length > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
{groupedRows.map((section, sectionIndex) => (
<div key={section.group}>
{sectionIndex > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
<div className="px-3 pt-1.5 pb-1 system-2xs-medium-uppercase text-text-quaternary">
{t(`versions.groupHeader.${section.group}`)}
</div>
{section.rows.map((row) => {
const isDisabled = row.state === 'current' || row.state === 'deploying'
return (
<TitleTooltip key={row.environmentId} content={isDisabled ? row.disabledReason : undefined}>
<DropdownMenuItem
disabled={isDisabled}
aria-disabled={isDisabled}
className={cn(
'gap-2 px-3',
isDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={() => {
if (isDisabled || !appInstanceId)
return
handleOpenChange(false)
openDeployDrawer({ appInstanceId, environmentId: row.environmentId, releaseId })
}}
>
<span className="system-sm-regular text-text-secondary">
{row.label}
</span>
</DropdownMenuItem>
</TitleTooltip>
)
})}
</div>
))}
<div className="my-1 border-t border-divider-subtle" aria-hidden />
<TitleTooltip content={deleteDisabledReason}>
<DropdownMenuItem
variant="destructive"
disabled={deleteActionDisabled}
aria-disabled={deleteActionDisabled}
className={cn(
'gap-2 px-3',
deleteActionDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={() => {
if (deleteActionDisabled)
return
handleOpenChange(false)
setShowDeleteConfirm(true)
}}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="system-sm-regular">{t('versions.deleteRelease')}</span>
</DropdownMenuItem>
</TitleTooltip>
</DropdownMenuContent>
)}
</TitleTooltip>
</DropdownMenuContent>
</DropdownMenu>
<EditReleaseDialog
release={release}
open={showEditDialog}
resetKey={editDialogSessionKey}
onOpenChange={setShowEditDialog}
/>

View File

@ -0,0 +1,246 @@
'use client'
import type { Release } from '@dify/contracts/enterprise/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
import { Form } from '@langgenius/dify-ui/form'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
type EditReleaseFormValues = {
name: string
description: string
}
function normalizedEditReleaseFormValues(values: EditReleaseFormValues) {
return {
name: values.name.trim(),
description: values.description.trim(),
}
}
function canSubmitEditReleaseForm(initialValues: EditReleaseFormValues, values: EditReleaseFormValues) {
const normalizedValues = normalizedEditReleaseFormValues(values)
return Boolean(
normalizedValues.name
&& (
normalizedValues.name !== initialValues.name
|| normalizedValues.description !== initialValues.description
),
)
}
function EditReleaseForm({
initialValues,
isSaving,
onClose,
onSubmit,
}: {
initialValues: EditReleaseFormValues
isSaving: boolean
onClose: () => void
onSubmit: (values: EditReleaseFormValues) => void
}) {
const { t } = useTranslation('deployments')
const nameLabel = t('versions.releaseNameLabel')
const nameRequiredMessage = t('versions.releaseNameRequired')
function handleSubmit(values: EditReleaseFormValues) {
if (!canSubmitEditReleaseForm(initialValues, values))
return
onSubmit(normalizedEditReleaseFormValues(values))
}
return (
<Form<EditReleaseFormValues> className="flex flex-col gap-4" onFormSubmit={handleSubmit}>
<FieldRoot
name="name"
className="gap-2"
validate={(value) => {
if (typeof value === 'string' && value.length > 0 && !value.trim())
return nameRequiredMessage
return null
}}
>
<FieldLabel className="system-xs-medium-uppercase text-text-tertiary">
{nameLabel}
</FieldLabel>
<FieldControl
type="text"
defaultValue={initialValues.name}
maxLength={128}
autoComplete="off"
required
className="h-8"
/>
<FieldError match="valueMissing" className="system-xs-regular">{nameRequiredMessage}</FieldError>
<FieldError match="customError" className="system-xs-regular" />
</FieldRoot>
<FieldRoot name="description" className="gap-2">
<div className="flex items-center gap-1.5">
<FieldLabel className="system-xs-medium-uppercase text-text-tertiary">
{t('versions.releaseDescriptionLabel')}
</FieldLabel>
<span className="system-xs-regular text-text-quaternary">{t('versions.optional')}</span>
</div>
<Textarea
defaultValue={initialValues.description}
maxLength={512}
autoComplete="off"
className="min-h-24"
/>
</FieldRoot>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="secondary"
disabled={isSaving}
onClick={onClose}
>
{t('versions.cancelEdit')}
</Button>
<Button
type="submit"
variant="primary"
disabled={isSaving}
loading={isSaving}
>
{t('versions.saveEdit')}
</Button>
</div>
</Form>
)
}
function EditReleaseDialogContent({
release,
resetKey,
onClose,
onCloseBlockedChange,
}: {
release: Release
resetKey: number
onClose: () => void
onCloseBlockedChange: (blocked: boolean) => void
}) {
const { t } = useTranslation('deployments')
const updateRelease = useMutation(consoleQuery.enterprise.releaseService.updateRelease.mutationOptions())
const formKey = `${resetKey}:${release.id}:${release.displayName}:${release.description}`
const isSaving = updateRelease.isPending
function handleClose() {
if (isSaving)
return
onClose()
}
function handleSubmit(values: EditReleaseFormValues) {
onCloseBlockedChange(true)
updateRelease.mutate(
{
params: {
releaseId: release.id,
},
body: {
releaseId: release.id,
displayName: values.name,
description: values.description,
},
},
{
onSuccess: (data) => {
const updatedName = data.release.displayName
toast.success(t('versions.editSuccess', { name: updatedName }))
onClose()
},
onError: () => {
toast.error(t('versions.editFailed'))
},
onSettled: () => {
onCloseBlockedChange(false)
},
},
)
}
return (
<>
<DialogCloseButton disabled={isSaving} />
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('versions.editRelease')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('versions.editReleaseDescription')}
</DialogDescription>
</div>
<div className="px-6 py-5">
<EditReleaseForm
key={formKey}
initialValues={{
name: release.displayName,
description: release.description,
}}
isSaving={isSaving}
onClose={handleClose}
onSubmit={handleSubmit}
/>
</div>
</>
)
}
export function EditReleaseDialog({
release,
open,
resetKey,
onOpenChange,
}: {
release: Release
open: boolean
resetKey: number
onOpenChange: (open: boolean) => void
}) {
const closeBlockedRef = useRef(false)
const [closeBlocked, setCloseBlocked] = useState(false)
function handleCloseBlockedChange(blocked: boolean) {
closeBlockedRef.current = blocked
setCloseBlocked(blocked)
}
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && closeBlockedRef.current)
return
onOpenChange(nextOpen)
}
return (
<Dialog open={open} disablePointerDismissal={closeBlocked} onOpenChange={handleOpenChange}>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] p-0">
<EditReleaseDialogContent
release={release}
resetKey={resetKey}
onClose={() => onOpenChange(false)}
onCloseBlockedChange={handleCloseBlockedChange}
/>
</DialogContent>
</Dialog>
)
}

View File

@ -1,7 +1,7 @@
'use client'
import { ReleaseHistoryTable } from './release-history-table'
export function VersionsTab() {
export function ReleasesTab() {
return (
<div className="flex w-full min-w-0 flex-col gap-4 px-6 py-6">
<ReleaseHistoryTable />

View File

@ -24,7 +24,11 @@ function releaseDslFileName({ release, appInstanceName }: {
}) {
const projectName = sanitizeFileNamePart(appInstanceName)
const releaseName = sanitizeFileNamePart(release.displayName) || 'release'
const baseName = [projectName, releaseName].filter(Boolean).join('-')
const fileNameParts: string[] = []
if (projectName)
fileNameParts.push(projectName)
fileNameParts.push(releaseName)
const baseName = fileNameParts.join('-')
return `${baseName}.yaml`
}

View File

@ -116,63 +116,130 @@ function ReleaseSourceLink({ sourceAppId }: {
)
}
function ReleaseHistoryMobileRows({ releaseRows, onReleaseDeleted }: {
function ReleaseHistoryMobileRow({ release, releaseRows, onReleaseDeleted }: {
release: ReleaseWithSummaryDeployments
releaseRows: ReleaseWithSummaryDeployments[]
onReleaseDeleted?: () => void
}) {
const { t } = useTranslation('deployments')
const hasDeployments = release.summaryDeployments.length > 0
return (
<DetailTableCardList className="pc:hidden">
{releaseRows.map((row) => {
const release = row
const releaseId = release.id
const hasDeployments = row.summaryDeployments.length > 0
return (
<DetailTableCard key={releaseId}>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<ReleaseTitleTooltip release={release} />
<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.displayName}</span>
{(release.sourceAppId || release.source === ReleaseSource.RELEASE_SOURCE_UPLOAD) && (
<>
<span aria-hidden>·</span>
<span className="inline-flex max-w-full min-w-0 items-baseline gap-1">
<span className="shrink-0">{t('versions.col.sourceApp')}</span>
<ReleaseSourceCell release={release} />
</span>
</>
)}
</div>
</div>
<div className="flex shrink-0 justify-end gap-1">
<DeployReleaseMenu
releaseId={releaseId}
releaseRows={releaseRows}
onDeleted={onReleaseDeleted}
/>
</div>
</div>
{hasDeployments && (
<div className="flex min-w-0 flex-wrap items-center gap-1">
<ReleaseDeploymentsContent
items={row.summaryDeployments}
/>
</div>
<DetailTableCard>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<ReleaseTitleTooltip release={release} />
<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>{release.createdBy.displayName}</span>
{(release.sourceAppId || release.source === ReleaseSource.RELEASE_SOURCE_UPLOAD) && (
<>
<span aria-hidden>·</span>
<span className="inline-flex max-w-full min-w-0 items-baseline gap-1">
<span className="shrink-0">{t('versions.col.sourceApp')}</span>
<ReleaseSourceCell release={release} />
</span>
</>
)}
</div>
</DetailTableCard>
)
})}
</div>
<div className="flex shrink-0 justify-end gap-1">
<DeployReleaseMenu
release={release}
releaseRows={releaseRows}
onDeleted={onReleaseDeleted}
/>
</div>
</div>
{hasDeployments && (
<div className="flex min-w-0 flex-wrap items-center gap-1">
<ReleaseDeploymentsContent
items={release.summaryDeployments}
/>
</div>
)}
</div>
</DetailTableCard>
)
}
function ReleaseHistoryMobileRows({ releaseRows, onReleaseDeleted }: {
releaseRows: ReleaseWithSummaryDeployments[]
onReleaseDeleted?: () => void
}) {
return (
<DetailTableCardList className="pc:hidden">
{releaseRows.map(release => (
<ReleaseHistoryMobileRow
key={release.id}
release={release}
releaseRows={releaseRows}
onReleaseDeleted={onReleaseDeleted}
/>
))}
</DetailTableCardList>
)
}
function ReleaseHistoryDesktopRow({ release, releaseRows, onReleaseDeleted }: {
release: ReleaseWithSummaryDeployments
releaseRows: ReleaseWithSummaryDeployments[]
onReleaseDeleted?: () => void
}) {
return (
<DetailTableRow>
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}>
<ReleaseTitleTooltip release={release} />
</DetailTableCell>
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.sourceApp}>
<ReleaseSourceCell release={release} />
</DetailTableCell>
<DetailTableCell className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt} text-text-secondary`}>
<CreatedAtCell createdAt={release.createdAt} />
</DetailTableCell>
<DetailTableCell className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author} truncate text-text-secondary`}>
{release.createdBy.displayName}
</DetailTableCell>
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}>
<div className="flex flex-wrap gap-1">
<ReleaseDeploymentsContent
items={release.summaryDeployments}
/>
</div>
</DetailTableCell>
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action}>
<div className="flex justify-end">
<DeployReleaseMenu
release={release}
releaseRows={releaseRows}
onDeleted={onReleaseDeleted}
/>
</div>
</DetailTableCell>
</DetailTableRow>
)
}
function ReleaseHistoryDesktopRows({ releaseRows, onReleaseDeleted }: {
releaseRows: ReleaseWithSummaryDeployments[]
onReleaseDeleted?: () => void
}) {
return (
<>
{releaseRows.map(release => (
<ReleaseHistoryDesktopRow
key={release.id}
release={release}
releaseRows={releaseRows}
onReleaseDeleted={onReleaseDeleted}
/>
))}
</>
)
}
export function ReleaseHistoryRows({ releaseRows, onReleaseDeleted }: {
releaseRows: ReleaseWithSummaryDeployments[]
onReleaseDeleted?: () => void
@ -198,43 +265,10 @@ export function ReleaseHistoryRows({ releaseRows, onReleaseDeleted }: {
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
{releaseRows.map((row) => {
const release = row
const releaseId = release.id
return (
<DetailTableRow key={releaseId}>
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}>
<ReleaseTitleTooltip release={release} />
</DetailTableCell>
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.sourceApp}>
<ReleaseSourceCell release={release} />
</DetailTableCell>
<DetailTableCell className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt} text-text-secondary`}>
<CreatedAtCell createdAt={release.createdAt} />
</DetailTableCell>
<DetailTableCell className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author} truncate text-text-secondary`}>
{row.createdBy.displayName}
</DetailTableCell>
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}>
<div className="flex flex-wrap gap-1">
<ReleaseDeploymentsContent
items={row.summaryDeployments}
/>
</div>
</DetailTableCell>
<DetailTableCell className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action}>
<div className="flex justify-end">
<DeployReleaseMenu
releaseId={releaseId}
releaseRows={releaseRows}
onDeleted={onReleaseDeleted}
/>
</div>
</DetailTableCell>
</DetailTableRow>
)
})}
<ReleaseHistoryDesktopRows
releaseRows={releaseRows}
onReleaseDeleted={onReleaseDeleted}
/>
</DetailTableBody>
</DetailTable>
</div>

View File

@ -83,7 +83,7 @@ export const setDeployReleaseMenuOpenAtom = atom(null, (get, set, {
set(deployReleaseMenuOpenReleaseIdAtom, undefined)
})
export const versionsTabLocalAtoms = [
export const releasesTabLocalAtoms = [
releaseHistoryCurrentPageAtom,
deployReleaseMenuOpenReleaseIdAtom,
] as const

View File

@ -1,188 +0,0 @@
'use client'
import type { Release } from '@dify/contracts/enterprise/types.gen'
import type { FormEvent } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import { Textarea } from '@langgenius/dify-ui/textarea'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
type EditReleaseFormValues = {
name: string
description: string
}
function EditReleaseForm({
release,
isSaving,
onClose,
onSubmit,
}: {
release: Release
isSaving: boolean
onClose: () => void
onSubmit: (values: EditReleaseFormValues) => void
}) {
const { t } = useTranslation('deployments')
const initialName = release.displayName
const initialDescription = release.description
const [name, setName] = useState(initialName)
const [description, setDescription] = useState(initialDescription)
const normalizedName = name.trim()
const normalizedDescription = description.trim()
const nameRequired = !normalizedName
const hasChanges = normalizedName !== initialName || normalizedDescription !== initialDescription
const canSave = Boolean(!nameRequired && hasChanges && !isSaving)
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!canSave)
return
onSubmit({
name: normalizedName,
description: normalizedDescription,
})
}
return (
<form className="flex flex-col gap-4" noValidate autoComplete="off" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-edit-name">
{t('versions.releaseNameLabel')}
</label>
<Input
id="release-edit-name"
type="text"
value={name}
maxLength={128}
autoComplete="off"
aria-invalid={nameRequired || undefined}
aria-describedby={nameRequired ? 'release-edit-name-error' : undefined}
onChange={event => setName(event.target.value)}
className="h-8"
/>
{nameRequired && (
<div id="release-edit-name-error" role="alert" className="system-xs-regular text-text-destructive">
{t('versions.releaseNameRequired')}
</div>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-edit-description">
{t('versions.releaseDescriptionLabel')}
</label>
<span className="system-xs-regular text-text-quaternary">{t('versions.optional')}</span>
</div>
<Textarea
id="release-edit-description"
value={description}
maxLength={512}
autoComplete="off"
onValueChange={setDescription}
className="min-h-24"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="secondary"
disabled={isSaving}
onClick={onClose}
>
{t('versions.cancelEdit')}
</Button>
<Button
type="submit"
variant="primary"
disabled={!canSave}
loading={isSaving}
>
{t('versions.saveEdit')}
</Button>
</div>
</form>
)
}
export function EditReleaseDialog({
release,
open,
onOpenChange,
}: {
release: Release
open: boolean
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation('deployments')
const updateRelease = useMutation(consoleQuery.enterprise.releaseService.updateRelease.mutationOptions())
const formKey = `${release.id}-${release.displayName}-${release.description}`
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen && updateRelease.isPending)
return
onOpenChange(nextOpen)
}
function handleSubmit(values: EditReleaseFormValues) {
updateRelease.mutate(
{
params: {
releaseId: release.id,
},
body: {
releaseId: release.id,
displayName: values.name,
description: values.description,
},
},
{
onSuccess: (data) => {
const updatedName = data.release.displayName
toast.success(t('versions.editSuccess', { name: updatedName }))
handleOpenChange(false)
},
onError: () => {
toast.error(t('versions.editFailed'))
},
},
)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] p-0">
<DialogCloseButton disabled={updateRelease.isPending} />
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('versions.editRelease')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('versions.editReleaseDescription')}
</DialogDescription>
</div>
<div className="px-6 py-5">
<EditReleaseForm
key={formKey}
release={release}
isSaving={updateRelease.isPending}
onClose={() => handleOpenChange(false)}
onSubmit={handleSubmit}
/>
</div>
</DialogContent>
</Dialog>
)
}