mirror of
https://github.com/langgenius/dify.git
synced 2026-07-05 04:57:10 +08:00
Compare commits
14 Commits
worktree-t
...
codex/refa
| Author | SHA1 | Date | |
|---|---|---|---|
| e9b4ce9029 | |||
| 7daa56f48b | |||
| c48ee1a6b7 | |||
| 80d2412294 | |||
| 63d1c8288f | |||
| be58e10a35 | |||
| 82e0191e05 | |||
| d24e9d41e4 | |||
| 137eb90399 | |||
| 995e8a6ec4 | |||
| 92b5c195a5 | |||
| 37de12bf47 | |||
| 68e323b6dc | |||
| 387dec9169 |
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -89,7 +89,7 @@ async function pollWithRetry(
|
||||
|
||||
function expired(): BaseError {
|
||||
return new BaseError({
|
||||
code: ErrorCode.TokenExpired,
|
||||
code: ErrorCode.ExpiredToken,
|
||||
message: 'code expired before authorization',
|
||||
})
|
||||
}
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 />
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
}
|
||||
|
||||
@ -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' } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 => (
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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),
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
@ -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' } },
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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() {
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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'>
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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']
|
||||
|
||||
19
web/features/deployments/detail/api-tokens-tab/state.ts
Normal file
19
web/features/deployments/detail/api-tokens-tab/state.ts
Normal 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),
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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"
|
||||
>
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -87,7 +87,7 @@ function DeploymentEnvironmentListSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
export function DeployTab() {
|
||||
export function InstancesTab() {
|
||||
const { t } = useTranslation('deployments')
|
||||
const environmentDeploymentsQuery = useAtomValue(deploymentEnvironmentDeploymentsQueryAtom)
|
||||
const environmentDeployments = environmentDeploymentsQuery.data
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -103,7 +103,7 @@ describe('DeployReleaseMenu', () => {
|
||||
|
||||
render(
|
||||
<DeployReleaseMenu
|
||||
releaseId={release.id}
|
||||
release={release}
|
||||
releaseRows={[release]}
|
||||
/>,
|
||||
)
|
||||
@ -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')
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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 />
|
||||
@ -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`
|
||||
}
|
||||
@ -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>
|
||||
@ -83,7 +83,7 @@ export const setDeployReleaseMenuOpenAtom = atom(null, (get, set, {
|
||||
set(deployReleaseMenuOpenReleaseIdAtom, undefined)
|
||||
})
|
||||
|
||||
export const versionsTabLocalAtoms = [
|
||||
export const releasesTabLocalAtoms = [
|
||||
releaseHistoryCurrentPageAtom,
|
||||
deployReleaseMenuOpenReleaseIdAtom,
|
||||
] as const
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user