Compare commits

..

189 Commits

Author SHA1 Message Date
9621922494 update 2026-05-25 11:24:25 +08:00
019d633555 update 2026-05-25 11:17:57 +08:00
b98a2cbc1a update 2026-05-25 10:35:49 +08:00
25a976e551 update 2026-05-25 10:16:59 +08:00
2e504e6057 Merge branch 'main' into 4-27-app-deploy 2026-05-25 09:29:15 +08:00
a7e848978c feat(inner-api): align runtime credentials resolve with enterprise contract
Rename the resolve endpoint to /enterprise/runtime/credentials/resolve and
switch the request/response shape to the kind-tagged contract the enterprise
AppRunner client expects. Add tool credential resolution alongside model.
2026-05-23 00:25:14 +08:00
50421e4d3f update api 2026-05-22 18:20:18 +08:00
0378ecb6ba update 2026-05-22 15:48:07 +08:00
d7028421a6 update 2026-05-22 15:41:58 +08:00
d2464ee88f update 2026-05-22 13:27:14 +08:00
d66740024b update 2026-05-22 12:21:49 +08:00
6df9890d67 update 2026-05-22 12:20:19 +08:00
b449256596 update 2026-05-22 12:13:37 +08:00
577a2f272f update 2026-05-22 12:03:34 +08:00
6020261f92 update 2026-05-22 11:45:51 +08:00
aaf50f5441 update 2026-05-22 11:00:13 +08:00
be347d27f7 update 2026-05-22 10:15:32 +08:00
4e5eec1106 tweaks 2026-05-21 11:01:29 +08:00
9904b67f85 tweaks 2026-05-21 10:38:48 +08:00
65618bad5b Merge branch 'main' into 4-27-app-deploy 2026-05-21 10:27:45 +08:00
a13602f2a0 tweaks 2026-05-21 10:25:00 +08:00
ca0c3fd7f8 Merge branch 'main' into 4-27-app-deploy 2026-05-20 15:33:44 +08:00
614ab7a656 Merge branch 'main' into 4-27-app-deploy 2026-05-20 11:48:48 +08:00
5d6a0bf244 Merge branch 'main' into 4-27-app-deploy 2026-05-20 09:05:35 +08:00
3e5bbdb30b tweaks 2026-05-20 09:04:19 +08:00
85052c855d [autofix.ci] apply automated fixes 2026-05-19 07:18:37 +00:00
e63dd178ff Merge branch 'main' into 4-27-app-deploy 2026-05-19 15:14:03 +08:00
907512311e tweaks 2026-05-19 15:13:01 +08:00
edce7717cb init create page 2026-05-18 16:23:27 +08:00
4c0ff8782b [autofix.ci] apply automated fixes 2026-05-18 08:07:51 +00:00
4a25ebbcab Merge branch 'main' into 4-27-app-deploy 2026-05-18 16:02:41 +08:00
a53053b2c8 Merge branch 'main' into 4-27-app-deploy 2026-05-18 10:12:57 +08:00
de321a072e Merge branch 'main' into 4-27-app-deploy 2026-05-15 17:12:37 +08:00
8bb7719412 update 2026-05-15 16:59:50 +08:00
1d0abf55db update 2026-05-15 16:35:37 +08:00
924fe37f65 Merge branch 'main' into 4-27-app-deploy 2026-05-15 16:07:44 +08:00
1eba8c2df2 update 2026-05-15 15:27:37 +08:00
3311654269 tweaks 2026-05-15 15:12:39 +08:00
e0755f4f1b tweaks 2026-05-15 14:38:05 +08:00
d709cc0ca4 tweaks 2026-05-15 14:09:07 +08:00
e890612181 tweaks 2026-05-15 14:01:05 +08:00
12faa2aff9 tweaks 2026-05-15 13:47:18 +08:00
142d46fd03 tweaks 2026-05-15 13:27:07 +08:00
4d48eb1939 detect app 2026-05-15 12:10:15 +08:00
e584580bcd Merge branch 'main' into 4-27-app-deploy 2026-05-15 11:15:56 +08:00
d5f9621acf update 2026-05-15 11:15:03 +08:00
ecde2cd429 update 2026-05-14 17:39:44 +08:00
4e28a2a778 Combobox 2026-05-14 16:44:28 +08:00
f02e29bd0b tweaks 2026-05-14 16:02:52 +08:00
yyh
c0c978524f fix(web): center deployment detail layout 2026-05-14 15:47:55 +08:00
c1b55890c7 Merge branch 'main' into 4-27-app-deploy 2026-05-14 14:27:34 +08:00
f608023502 new design and api 2026-05-14 11:55:54 +08:00
03ce525087 Merge branch 'main' into 4-27-app-deploy 2026-05-13 14:45:47 +08:00
35d0fab217 [autofix.ci] apply automated fixes 2026-05-12 11:32:58 +00:00
d3684f1542 hide for ce 2026-05-12 19:28:46 +08:00
86e88ef6c8 Merge branch 'main' into 4-27-app-deploy 2026-05-12 17:48:47 +08:00
86d6857e60 fix empty 2026-05-12 17:46:46 +08:00
ee9eac9d7a Fixes WTA-159 2026-05-12 10:48:01 +08:00
75bfb58cd9 tweaks 2026-05-12 09:15:07 +08:00
6d0d0763b1 tweaks 2026-05-11 21:16:28 +08:00
86fc60debf update 2026-05-11 20:56:30 +08:00
bf8a587a1d tweaks 2026-05-11 20:51:35 +08:00
0a32344504 Merge branch 'main' into 4-27-app-deploy 2026-05-11 20:30:16 +08:00
03ca8b500e tweaks 2026-05-11 20:28:40 +08:00
89a05c0665 promote and rollback 2026-05-11 20:18:02 +08:00
c053549ece merge access and settings 2026-05-11 20:05:14 +08:00
64fec96a41 update 2026-05-11 15:04:39 +08:00
de4fa9094a update 2026-05-11 15:03:36 +08:00
923964a22a Merge branch 'main' into 4-27-app-deploy 2026-05-11 15:03:10 +08:00
1627708af3 Merge branch 'main' into 4-27-app-deploy 2026-05-11 09:40:49 +08:00
ebd070efe0 tweaks 2026-05-09 17:45:37 +08:00
3fc652ee95 tweaks 2026-05-09 17:37:15 +08:00
bdd73d2846 style 2026-05-09 17:23:34 +08:00
e16988d8a9 tweaks 2026-05-09 17:14:37 +08:00
56a64601b3 tweak style 2026-05-09 17:08:14 +08:00
b07b68b531 tweak dropdown menuy 2026-05-09 16:58:29 +08:00
1cada0c49c Merge branch 'main' into 4-27-app-deploy 2026-05-09 16:08:51 +08:00
5b8f5a364c tweaks 2026-05-08 21:46:33 +08:00
0c216af1eb tweaks 2026-05-08 21:44:04 +08:00
06f4ba64c3 tweaks 2026-05-08 21:00:49 +08:00
b4e70c4287 update 2026-05-08 19:03:05 +08:00
10ae4afb29 tweaks 2026-05-08 18:59:30 +08:00
3b72f4e6f5 remove copy for api button 2026-05-08 18:46:11 +08:00
e665e802ec remove rollback modal 2026-05-08 18:12:45 +08:00
6d83bade00 tweaks 2026-05-08 17:59:26 +08:00
0184424bdc tweaks 2026-05-08 17:49:33 +08:00
1b30e67c92 tweaks 2026-05-08 17:45:50 +08:00
ceec4b4962 tweaks 2026-05-08 17:24:18 +08:00
10e567ebc6 tweaks 2026-05-08 17:08:55 +08:00
aaa8978f59 tweaks 2026-05-08 17:06:44 +08:00
279ab4f332 tweaks 2026-05-08 17:00:32 +08:00
debe0cec4b tweaks 2026-05-08 16:55:00 +08:00
7a957b18e8 tweaks 2026-05-08 16:49:51 +08:00
a223869a23 tweaks 2026-05-08 16:42:18 +08:00
e7e6ccd11a tweaks 2026-05-08 16:29:38 +08:00
605dca6431 tweaks 2026-05-08 16:17:46 +08:00
ac56e38a2a tweaks 2026-05-08 16:00:06 +08:00
80ac0f4ce3 tweaks 2026-05-08 15:38:02 +08:00
ab7650d568 tweaks 2026-05-08 15:32:53 +08:00
7cad11c856 tweaks 2026-05-08 15:24:15 +08:00
4e62b048bd update 2026-05-08 14:59:00 +08:00
a2698a1c00 tweaks 2026-05-08 14:54:49 +08:00
2212384a35 Merge branch 'main' into 4-27-app-deploy 2026-05-08 11:43:07 +08:00
6a62403931 Merge branch 'main' into 4-27-app-deploy 2026-05-08 09:40:21 +08:00
19a76cb49e tweaks 2026-05-07 22:52:19 +08:00
b1822a06d2 update 2026-05-07 22:24:57 +08:00
2cda3e4181 tweaks 2026-05-07 21:51:45 +08:00
de7795aa80 tweaks 2026-05-07 21:36:30 +08:00
0a477fc767 tweaks 2026-05-07 20:25:57 +08:00
b2e92499bf keepPreviousData 2026-05-07 20:25:18 +08:00
b272ac6a02 tweaks 2026-05-07 20:22:30 +08:00
ae7c534331 tweaks 2026-05-07 20:16:02 +08:00
cfb1e0217f tweaks 2026-05-07 20:08:54 +08:00
3f36471ec0 tweaks 2026-05-07 19:23:49 +08:00
ea6e7a9ed0 tweaks 2026-05-07 19:14:04 +08:00
04124edd70 tweaks 2026-05-07 18:39:28 +08:00
64fc1e8281 update 2026-05-07 18:26:49 +08:00
fe51c9fbdf update 2026-05-07 18:15:05 +08:00
23ffbd2532 tweaks 2026-05-07 18:09:52 +08:00
b70c9d7835 tweaks 2026-05-07 17:43:32 +08:00
f5a262817d tweaks 2026-05-07 17:23:00 +08:00
b1773ed11f Merge branch 'main' into 4-27-app-deploy 2026-05-07 12:36:06 +08:00
45c5e290e2 update 2026-05-06 17:36:43 +08:00
56b27611a8 Merge branch 'main' into 4-27-app-deploy 2026-05-06 17:35:36 +08:00
0a028faae6 Merge branch 'main' into 4-27-app-deploy 2026-05-06 16:51:23 +08:00
21ec746bdb update 2026-05-06 14:45:02 +08:00
abe2248de8 tweaks 2026-05-06 13:36:04 +08:00
825aec5845 tweaks 2026-05-06 13:21:40 +08:00
24635dd0c1 create release as dialog 2026-05-06 12:57:48 +08:00
8604e72216 Merge branch 'main' into 4-27-app-deploy 2026-05-06 12:39:02 +08:00
72cbf0ae62 fix 2026-05-06 10:27:25 +08:00
58675e967f Merge branch 'main' into 4-27-app-deploy 2026-05-06 08:59:40 +08:00
141d936e91 feat(app-deploy): wire release deployment UI 2026-05-04 15:34:22 +08:00
b305e8b65d fix(deployments): clean up runtime binding display
Show runtime bindings as a single summary to avoid duplicated overlapping text in deployment details.\n\nEnable build-push workflow runs for the 4-27-app-deploy branch so app deploy images can be built from this branch.
2026-05-04 11:48:46 +08:00
253888f758 feat(inner-api): resolve runtime credentials 2026-05-04 10:58:43 +08:00
aa1430aa16 fix type 2026-04-30 19:53:47 +08:00
a500b2810c tweaks 2026-04-30 19:51:15 +08:00
416ee7a21d Merge branch 'main' into 4-27-app-deploy 2026-04-30 19:50:32 +08:00
60d5187bcf tweaks 2026-04-30 17:32:36 +08:00
19d445452c update 2026-04-30 17:27:44 +08:00
b42addac44 update 2026-04-30 17:26:56 +08:00
8bf40af379 update 2026-04-30 17:24:35 +08:00
2a265a4526 update 2026-04-30 17:23:56 +08:00
17f4c89d11 Merge branch 'main' into 4-27-app-deploy 2026-04-30 17:23:44 +08:00
c631cb086a Merge branch 'main' into 4-27-app-deploy 2026-04-30 15:49:16 +08:00
396c349cdd add skill 2026-04-30 15:48:11 +08:00
48a96739d4 update contract 2026-04-30 15:46:31 +08:00
e424a2ad9a tweak name 2026-04-30 15:39:40 +08:00
e62e7951cd use generated contract 2026-04-30 15:32:19 +08:00
eb60ddc35f tweaks 2026-04-30 14:59:59 +08:00
2d6788fc43 tweaks 2026-04-30 14:48:58 +08:00
42d0b63891 tweaks 2026-04-30 14:41:39 +08:00
63c0921936 tweaks 2026-04-30 14:33:55 +08:00
53f224a2c5 tweaks 2026-04-30 12:33:30 +08:00
66d24a23ac tweaks 2026-04-30 11:42:21 +08:00
2459b88114 /console/api/enterprise/deployment-environment-options 2026-04-30 11:34:16 +08:00
2c34f9849d tweaks 2026-04-30 11:18:56 +08:00
7f2d094cf3 tweaks 2026-04-30 10:44:03 +08:00
d02c80e220 tweaks 2026-04-30 10:13:37 +08:00
3c77a8fab9 switch back to query 2026-04-30 09:56:50 +08:00
f530efeda3 tweaks 2026-04-29 23:30:48 +08:00
663818f411 tweaks 2026-04-29 23:26:07 +08:00
e8ec7c7ff5 tweaks 2026-04-29 17:53:16 +08:00
1ea16409d4 tweaks 2026-04-29 17:43:34 +08:00
96bc73e47d tweaks 2026-04-29 17:38:40 +08:00
f7cf0c050e tweaks 2026-04-29 17:16:40 +08:00
e56d820ac4 tweaks 2026-04-29 17:15:46 +08:00
f7014fd156 tweaks 2026-04-29 17:08:52 +08:00
79591ca7bd fix state 2026-04-29 16:50:23 +08:00
64bacd1e5f tweaks 2026-04-29 16:39:42 +08:00
aff33d079b Merge branch 'main' into 4-27-app-deploy 2026-04-29 16:13:50 +08:00
930688c559 tweaks 2026-04-29 16:12:44 +08:00
da6fd82b6f update to the new apis 2026-04-29 15:50:47 +08:00
71b04fd48f tweaks 2026-04-29 13:53:14 +08:00
1aea4e00a4 tweaks 2026-04-29 13:25:41 +08:00
6fa77397a4 tweaks 2026-04-29 13:09:38 +08:00
4437c001dd [autofix.ci] apply automated fixes 2026-04-29 05:03:34 +00:00
ac95f32856 tweaks 2026-04-29 13:00:52 +08:00
55f4249864 tweaks 2026-04-29 12:58:01 +08:00
e5fa2c9aad Merge branch 'main' into 4-27-app-deploy 2026-04-29 12:45:41 +08:00
7d93d9a4c5 Merge branch 'main' into 4-27-app-deploy 2026-04-29 09:40:30 +08:00
50af69a53e fix app source 2026-04-28 14:58:22 +08:00
af6aac3094 tweaks 2026-04-28 14:50:41 +08:00
111483c73a use api for deployments 2026-04-28 14:36:06 +08:00
bea78ade6e use real api 2026-04-28 12:28:44 +08:00
fb4c111aec Merge branch 'main' into 4-27-app-deploy 2026-04-28 10:48:31 +08:00
444c846480 Merge branch 'main' into 4-27-app-deploy 2026-04-27 16:39:13 +08:00
3540a06f72 chore: add enterprise support for dev proxy 2026-04-27 15:21:25 +08:00
46e7b5a85a feat: init 2026-04-27 15:20:14 +08:00
106 changed files with 14771 additions and 141 deletions

View File

@ -1,6 +1,6 @@
---
name: how-to-write-component
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around abstraction choices, props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
---
# How To Write A Component
@ -12,6 +12,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature.
- Prefer local code and purpose-named helpers over catch-all utility modules; inline cheap derived values when that is clearer.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance.
## Ownership
@ -19,6 +20,8 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing.
- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children.
- Pass stable domain identity across boundaries; avoid forwarding derived presentation state when the receiver can derive it from its own data source. A component that owns a visual surface should also own the data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
- Loading states for visual surfaces should use skeleton placeholders scoped to the content that is actually loading, with shape, density, and dimensions close to the final UI. Avoid generic loading text or centered spinners for page sections, cards, lists, tables, forms, and drawers; reserve spinners for small inline busy indicators such as an in-progress status icon.
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow.
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
@ -29,9 +32,9 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them.
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially IDs like `appInstanceId`. Normalize framework or route params at the boundary.
- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially persistent IDs and route params. Normalize framework or route params at the boundary.
- Keep fallback and invariant checks at the lowest component that already handles that state; avoid defensive fallbacks that mask impossible states.
## Queries And Mutations
@ -48,12 +51,13 @@ Use this as the decision guide for React/TypeScript component structure. Existin
## Component Boundaries
- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner.
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Do not expand a child component's responsibility just because its data is useful nearby; keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer.
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
- Avoid shallow wrappers, layout-only render-prop wrappers, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
## You Might Not Need An Effect

View File

@ -9,6 +9,7 @@ on:
- "release/e-*"
- "hotfix/**"
- "feat/hitl-backend"
- "4-27-app-deploy"
tags:
- "*"
@ -68,7 +69,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
@ -78,13 +79,13 @@ jobs:
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env[matrix.image_name_env] }}
- name: Build Docker image
id: build
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: ${{ matrix.build_context }}
@ -124,10 +125,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Validate Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
push: false
context: ${{ matrix.build_context }}
@ -156,14 +157,14 @@ jobs:
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env[matrix.image_name_env] }}
tags: |

View File

@ -53,7 +53,7 @@ jobs:
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
- name: Build Docker Image
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
push: false
@ -77,10 +77,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build Docker Image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
push: false
context: ${{ matrix.context }}

View File

@ -18,7 +18,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
days-before-issue-stale: 15
days-before-issue-close: 3

View File

@ -158,7 +158,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@787c5a0ce96a9a6cfb050ea0c8f4c05f2447c251 # v1.0.133
uses: anthropics/claude-code-action@1dc994ee7a008f0ecc866d9ac23ef036b7229f84 # v1.0.127
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -16,6 +16,7 @@ api = ExternalApi(
inner_api_ns = Namespace("inner_api", description="Internal API operations", path="/")
from . import mail as _mail
from . import runtime_credentials as _runtime_credentials
from .app import dsl as _app_dsl
from .plugin import plugin as _plugin
from .workspace import workspace as _workspace
@ -26,6 +27,7 @@ __all__ = [
"_app_dsl",
"_mail",
"_plugin",
"_runtime_credentials",
"_workspace",
"api",
"bp",

View File

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

View File

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

View File

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

View File

@ -161,6 +161,7 @@ class PluginManagerModel(FeatureResponseModel):
class SystemFeatureModel(FeatureResponseModel):
app_dsl_version: str = ""
enable_app_deploy: bool = False
sso_enforced_for_signin: bool = False
sso_enforced_for_signin_protocol: str = ""
enable_marketplace: bool = False
@ -251,6 +252,7 @@ class FeatureService:
cls._fulfill_system_params_from_env(system_features)
if dify_config.ENTERPRISE_ENABLED:
system_features.enable_app_deploy = True
system_features.branding.enabled = True
system_features.webapp_auth.enabled = True
system_features.enable_change_email = False

View File

@ -291,6 +291,7 @@ class TestFeatureService:
assert isinstance(result, SystemFeatureModel)
# Verify enterprise features
assert result.enable_app_deploy is True
assert result.branding.enabled is True
assert result.webapp_auth.enabled is True
assert result.enable_change_email is False
@ -377,6 +378,7 @@ class TestFeatureService:
# Ensure that data required for frontend rendering remains accessible.
# Branding should match the mock data
assert result.enable_app_deploy is True
assert result.branding.enabled is True
assert result.branding.application_title == "Test Enterprise"
assert result.branding.login_page_logo == "https://example.com/logo.png"
@ -424,6 +426,7 @@ class TestFeatureService:
assert isinstance(result, SystemFeatureModel)
# Verify basic configuration
assert result.enable_app_deploy is False
assert result.branding.enabled is False
assert result.webapp_auth.enabled is False
assert result.enable_change_email is True
@ -625,6 +628,7 @@ class TestFeatureService:
assert isinstance(result, SystemFeatureModel)
# Verify enterprise features are disabled
assert result.enable_app_deploy is False
assert result.branding.enabled is False
assert result.webapp_auth.enabled is False
assert result.enable_change_email is True

View File

@ -0,0 +1,206 @@
"""Unit tests for runtime credential inner API."""
import inspect
from unittest.mock import MagicMock, patch
from flask import Flask
from controllers.inner_api.runtime_credentials import (
EnterpriseRuntimeCredentialsResolve,
InnerRuntimeCredentialsResolvePayload,
)
def test_runtime_credentials_payload_accepts_items():
payload = InnerRuntimeCredentialsResolvePayload.model_validate(
{
"tenant_id": "tenant-1",
"credentials": [
{
"credential_id": "credential-1",
"provider": "langgenius/openai/openai",
"kind": "model",
}
],
}
)
assert payload.tenant_id == "tenant-1"
assert payload.credentials[0].provider == "langgenius/openai/openai"
assert payload.credentials[0].kind == "model"
@patch("controllers.inner_api.runtime_credentials.encrypter.decrypt_token")
@patch("controllers.inner_api.runtime_credentials.db")
@patch("controllers.inner_api.runtime_credentials.Session")
@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager")
def test_runtime_model_credentials_resolve_returns_decrypted_values(
mock_provider_manager_factory,
mock_session_cls,
mock_db,
mock_decrypt_token,
app: Flask,
):
provider_configuration = MagicMock()
provider_configuration.provider.provider_credential_schema.credential_form_schemas = []
provider_configuration.extract_secret_variables.return_value = ["openai_api_key"]
provider_configuration._get_provider_names.return_value = ["langgenius/openai/openai", "openai"]
provider_configurations = MagicMock()
provider_configurations.get.return_value = provider_configuration
provider_manager = MagicMock()
provider_manager.get_configurations.return_value = provider_configurations
mock_provider_manager_factory.return_value = provider_manager
credential = MagicMock()
credential.encrypted_config = '{"openai_api_key":"encrypted","api_base":"https://api.openai.com/v1"}'
session = MagicMock()
session.__enter__.return_value = session
session.__exit__.return_value = False
session.execute.return_value.scalar_one_or_none.return_value = credential
mock_session_cls.return_value = session
mock_db.engine = MagicMock()
mock_decrypt_token.return_value = "sk-test"
handler = EnterpriseRuntimeCredentialsResolve()
unwrapped = inspect.unwrap(handler.post)
with app.test_request_context():
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
mock_ns.payload = {
"tenant_id": "tenant-1",
"credentials": [
{
"credential_id": "credential-1",
"provider": "langgenius/openai/openai",
"kind": "model",
}
],
}
body, status_code = unwrapped(handler)
assert status_code == 200
assert body["credentials"][0]["kind"] == "model"
assert body["credentials"][0]["values"]["openai_api_key"] == "sk-test"
assert body["credentials"][0]["values"]["api_base"] == "https://api.openai.com/v1"
mock_decrypt_token.assert_called_once_with(tenant_id="tenant-1", token="encrypted")
@patch("controllers.inner_api.runtime_credentials.create_plugin_provider_manager")
def test_runtime_model_credentials_resolve_rejects_unknown_provider(mock_provider_manager_factory, app: Flask):
provider_configurations = MagicMock()
provider_configurations.get.return_value = None
provider_manager = MagicMock()
provider_manager.get_configurations.return_value = provider_configurations
mock_provider_manager_factory.return_value = provider_manager
handler = EnterpriseRuntimeCredentialsResolve()
unwrapped = inspect.unwrap(handler.post)
with app.test_request_context():
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
mock_ns.payload = {
"tenant_id": "tenant-1",
"credentials": [{"credential_id": "credential-1", "provider": "missing", "kind": "model"}],
}
body, status_code = unwrapped(handler)
assert status_code == 404
assert "provider" in body["message"]
@patch("controllers.inner_api.runtime_credentials.create_provider_encrypter")
@patch("controllers.inner_api.runtime_credentials.ToolProviderCredentialsCache")
@patch("controllers.inner_api.runtime_credentials.db")
@patch("controllers.inner_api.runtime_credentials.Session")
@patch("controllers.inner_api.runtime_credentials.ToolManager")
def test_runtime_tool_credentials_resolve_returns_decrypted_values(
mock_tool_manager,
mock_session_cls,
mock_db,
mock_cache_cls,
mock_create_encrypter,
app: Flask,
):
provider_controller = MagicMock()
provider_controller.get_credentials_schema_by_type.return_value = []
mock_tool_manager.get_builtin_provider.return_value = provider_controller
builtin_provider = MagicMock()
builtin_provider.id = "credential-1"
session = MagicMock()
session.__enter__.return_value = session
session.__exit__.return_value = False
session.execute.return_value.scalar_one_or_none.return_value = builtin_provider
mock_session_cls.return_value = session
mock_db.engine = MagicMock()
provider_encrypter = MagicMock()
provider_encrypter.decrypt.return_value = {"tavily_api_key": "tvly-secret"}
mock_create_encrypter.return_value = (provider_encrypter, MagicMock())
handler = EnterpriseRuntimeCredentialsResolve()
unwrapped = inspect.unwrap(handler.post)
with app.test_request_context():
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
mock_ns.payload = {
"tenant_id": "tenant-1",
"credentials": [
{
"credential_id": "credential-1",
"provider": "langgenius/tavily/tavily",
"kind": "tool",
}
],
}
body, status_code = unwrapped(handler)
assert status_code == 200
assert body["credentials"][0]["kind"] == "tool"
assert body["credentials"][0]["provider"] == "langgenius/tavily/tavily"
assert body["credentials"][0]["values"]["tavily_api_key"] == "tvly-secret"
@patch("controllers.inner_api.runtime_credentials.db")
@patch("controllers.inner_api.runtime_credentials.Session")
@patch("controllers.inner_api.runtime_credentials.ToolManager")
def test_runtime_tool_credentials_resolve_rejects_unknown_credential(
mock_tool_manager,
mock_session_cls,
mock_db,
app: Flask,
):
mock_tool_manager.get_builtin_provider.return_value = MagicMock()
session = MagicMock()
session.__enter__.return_value = session
session.__exit__.return_value = False
session.execute.return_value.scalar_one_or_none.return_value = None
mock_session_cls.return_value = session
mock_db.engine = MagicMock()
handler = EnterpriseRuntimeCredentialsResolve()
unwrapped = inspect.unwrap(handler.post)
with app.test_request_context():
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
mock_ns.payload = {
"tenant_id": "tenant-1",
"credentials": [{"credential_id": "missing", "provider": "langgenius/tavily/tavily", "kind": "tool"}],
}
body, status_code = unwrapped(handler)
assert status_code == 404
assert "credential" in body["message"]
def test_runtime_credentials_resolve_rejects_unknown_kind(app: Flask):
handler = EnterpriseRuntimeCredentialsResolve()
unwrapped = inspect.unwrap(handler.post)
with app.test_request_context():
with patch("controllers.inner_api.runtime_credentials.inner_api_ns") as mock_ns:
mock_ns.payload = {
"tenant_id": "tenant-1",
"credentials": [{"credential_id": "credential-1", "provider": "x", "kind": "secret"}],
}
body, status_code = unwrapped(handler)
assert status_code == 400
assert "kind" in body["message"]

View File

@ -5255,11 +5255,6 @@
"count": 1
}
},
"web/types/feature.ts": {
"erasable-syntax-only/enums": {
"count": 3
}
},
"web/types/lamejs.d.ts": {
"ts/no-explicit-any": {
"count": 3

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -34,9 +34,17 @@ const stripConsoleApiPrefix = (routePath: string) => {
const stripSchemaNamePrefix = (schemaName: string) => {
return schemaName
.replace(/^dify\.enterprise\.api\.enterprise\./, '')
.replace(/^dify\.enterprise\.api\.appdeploy\./, '')
.replace(/^pagination\./, '')
}
const contractTagSegment = (tag?: string) => {
if (tag === 'EnterpriseAppDeployConsole')
return 'AppDeploy'
return tag || 'default'
}
const contractNameSegments = (operation: ContractOperation) => {
const operationId = operation.operationId || operation.id
const tag = operation.tags?.[0]
@ -48,7 +56,7 @@ const contractNameSegments = (operation: ContractOperation) => {
}
const contractPathSegments = (operation: ContractOperation) => {
return [operation.tags?.[0] || 'default', ...contractNameSegments(operation)]
return [contractTagSegment(operation.tags?.[0]), ...contractNameSegments(operation)]
}
const normalizeEnterpriseOpenApi = () => {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,15 @@
import type { ReactNode } from 'react'
import { InstanceDetail } from '@/features/deployments/detail'
export default async function InstanceDetailLayout({ children, params }: {
children: ReactNode
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
return (
<InstanceDetail appInstanceId={appInstanceId}>
{children}
</InstanceDetail>
)
}

View File

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

View File

@ -0,0 +1,8 @@
import { redirect } from '@/next/navigation'
export default async function InstanceDetailPage({ params }: {
params: Promise<{ appInstanceId: string }>
}) {
const { appInstanceId } = await params
redirect(`/deployments/${appInstanceId}/overview`)
}

View File

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

View File

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

View File

@ -0,0 +1,12 @@
'use client'
import { useTranslation } from 'react-i18next'
import { CreateDeploymentGuide } from '@/features/deployments/create-guide'
import useDocumentTitle from '@/hooks/use-document-title'
export default function CreateDeploymentPage() {
const { t } = useTranslation('deployments')
useDocumentTitle(t('documentTitle.create'))
return <CreateDeploymentGuide />
}

View File

@ -0,0 +1,10 @@
'use client'
import { useTranslation } from 'react-i18next'
import { DeploymentsList } from '@/features/deployments/list'
import useDocumentTitle from '@/hooks/use-document-title'
export default function DeploymentsPage() {
const { t } = useTranslation('deployments')
useDocumentTitle(t('documentTitle.list'))
return <DeploymentsList />
}

View File

@ -6,7 +6,7 @@ import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { GotoAnything } from '@/app/components/goto-anything'
import Header from '@/app/components/header'
import { Header } from '@/app/components/header'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import { AppContextProvider } from '@/context/app-context-provider'

View File

@ -1,5 +1,6 @@
import { render, screen, waitFor } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import RoleRouteGuard from './role-route-guard'
const mockReplace = vi.fn()
@ -34,6 +35,16 @@ const setAppContext = (overrides: Partial<AppContextMock> = {}) => {
})
}
const renderRoleRouteGuard = (systemFeatures: { enable_app_deploy?: boolean } = {}) =>
renderWithSystemFeatures(
(
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
),
{ systemFeatures },
)
describe('RoleRouteGuard', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -46,11 +57,7 @@ describe('RoleRouteGuard', () => {
isLoadingCurrentWorkspace: true,
})
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
renderRoleRouteGuard()
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByText('content')).not.toBeInTheDocument()
@ -62,11 +69,7 @@ describe('RoleRouteGuard', () => {
isCurrentWorkspaceDatasetOperator: true,
})
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
renderRoleRouteGuard()
expect(screen.queryByText('content')).not.toBeInTheDocument()
await waitFor(() => {
@ -80,11 +83,7 @@ describe('RoleRouteGuard', () => {
isCurrentWorkspaceDatasetOperator: true,
})
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
renderRoleRouteGuard()
expect(screen.getByText('content')).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
@ -96,14 +95,30 @@ describe('RoleRouteGuard', () => {
isLoadingCurrentWorkspace: true,
})
render((
<RoleRouteGuard>
<div>content</div>
</RoleRouteGuard>
))
renderRoleRouteGuard()
expect(screen.getByText('content')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
it('should redirect deployments routes when app deploy is disabled', async () => {
mockPathname = '/deployments'
renderRoleRouteGuard({ enable_app_deploy: false })
expect(screen.queryByText('content')).not.toBeInTheDocument()
await waitFor(() => {
expect(mockReplace).toHaveBeenCalledWith('/apps')
})
})
it('should allow deployments routes when app deploy is enabled', () => {
mockPathname = '/deployments/app-1/overview'
renderRoleRouteGuard({ enable_app_deploy: true })
expect(screen.getByText('content')).toBeInTheDocument()
expect(mockReplace).not.toHaveBeenCalled()
})
})

View File

@ -1,10 +1,12 @@
'use client'
import type { ReactNode } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { usePathname, useRouter } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
@ -12,15 +14,19 @@ const isPathUnderRoute = (pathname: string, route: string) => pathname === route
export default function RoleRouteGuard({ children }: { children: ReactNode }) {
const { isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const pathname = usePathname()
const router = useRouter()
const shouldGuardRoute = datasetOperatorRedirectRoutes.some(route => isPathUnderRoute(pathname, route))
const shouldRedirect = shouldGuardRoute && !isLoadingCurrentWorkspace && isCurrentWorkspaceDatasetOperator
const shouldRedirectDatasetOperator = shouldGuardRoute && !isLoadingCurrentWorkspace && isCurrentWorkspaceDatasetOperator
const shouldRedirectAppDeploy = isPathUnderRoute(pathname, '/deployments') && !systemFeatures.enable_app_deploy
const shouldRedirect = shouldRedirectDatasetOperator || shouldRedirectAppDeploy
const redirectPath = shouldRedirectAppDeploy ? '/apps' : '/datasets'
useEffect(() => {
if (shouldRedirect)
router.replace('/datasets')
}, [shouldRedirect, router])
router.replace(redirectPath)
}, [redirectPath, shouldRedirect, router])
// Block rendering only for guarded routes to avoid permission flicker.
if (shouldGuardRoute && isLoadingCurrentWorkspace)

View File

@ -2,7 +2,7 @@ import type { ReactElement } from 'react'
import { fireEvent, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import Header from '../index'
import { Header } from '../index'
function createMockComponent(testId: string) {
return () => <div data-testid={testId} />
@ -44,6 +44,10 @@ vi.mock('@/app/components/header/tools-nav', () => ({
default: createMockComponent('tools-nav'),
}))
vi.mock('@/features/deployments/nav', () => ({
DeploymentsNav: createMockComponent('deployments-nav'),
}))
vi.mock('@/app/components/header/plan-badge', () => ({
PlanBadge: ({ onClick, plan }: { onClick?: () => void, plan?: string }) => (
<button data-testid="plan-badge" onClick={onClick} data-plan={plan} />
@ -66,6 +70,7 @@ let mockPlanType = 'sandbox'
let mockBrandingEnabled = false
let mockBrandingTitle: string | null = null
let mockBrandingLogo: string | null = null
let mockEnableAppDeploy = false
const mockSetShowPricingModal = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
@ -103,6 +108,7 @@ const renderHeader = (ui: ReactElement = <Header />) =>
application_title: mockBrandingTitle ?? '',
workspace_logo: mockBrandingLogo ?? '',
},
enable_app_deploy: mockEnableAppDeploy,
},
})
@ -117,6 +123,7 @@ describe('Header', () => {
mockBrandingEnabled = false
mockBrandingTitle = null
mockBrandingLogo = null
mockEnableAppDeploy = false
})
it('should render header with main nav components', () => {
@ -214,6 +221,24 @@ describe('Header', () => {
expect(screen.getByTestId('app-nav')).toBeInTheDocument()
})
it('should hide deployments nav when app deploy is disabled', () => {
mockIsWorkspaceEditor = true
mockEnableAppDeploy = false
renderHeader()
expect(screen.queryByTestId('deployments-nav')).not.toBeInTheDocument()
})
it('should show deployments nav for editors when app deploy is enabled', () => {
mockIsWorkspaceEditor = true
mockEnableAppDeploy = true
renderHeader()
expect(screen.getByTestId('deployments-nav')).toBeInTheDocument()
})
it('should hide dataset nav when neither editor nor dataset operator', () => {
mockIsWorkspaceEditor = false
mockIsDatasetOperator = false

View File

@ -1,6 +1,5 @@
'use client'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import WorkplaceSelector from '@/app/components/header/account-dropdown/workplace-selector'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
@ -8,6 +7,7 @@ import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { WorkspaceProvider } from '@/context/workspace-context-provider'
import { DeploymentsNav } from '@/features/deployments/nav'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Link from '@/next/link'
import { systemFeaturesQueryOptions } from '@/service/system-features'
@ -28,7 +28,7 @@ const navClassName = `
cursor-pointer
`
const Header = () => {
export function Header() {
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
@ -37,29 +37,33 @@ const Header = () => {
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isFreePlan = plan.type === Plan.sandbox
const isBrandingEnabled = systemFeatures.branding.enabled
const handlePlanClick = useCallback(() => {
const canUseAppDeploy = isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy
function handlePlanClick() {
if (isFreePlan)
setShowPricingModal()
else
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.BILLING })
}, [isFreePlan, setShowAccountSettingModal, setShowPricingModal])
}
const renderLogo = () => (
<h1>
<Link href="/apps" className="flex h-8 shrink-0 items-center justify-center overflow-hidden px-0.5 indent-[-9999px] whitespace-nowrap">
{isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? (
<img
src={systemFeatures.branding.workspace_logo}
className="block h-[22px] w-auto object-contain"
alt="logo"
/>
)
: <DifyLogo />}
</Link>
</h1>
)
function renderLogo() {
return (
<h1>
<Link href="/apps" className="flex h-8 shrink-0 items-center justify-center overflow-hidden px-0.5 indent-[-9999px] whitespace-nowrap">
{isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'}
{systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? (
<img
src={systemFeatures.branding.workspace_logo}
className="block h-[22px] w-auto object-contain"
alt="logo"
/>
)
: <DifyLogo />}
</Link>
</h1>
)
}
if (isMobile) {
return (
@ -73,18 +77,17 @@ const Header = () => {
</WorkspaceProvider>
{enableBilling ? <PlanBadge allowHover sandboxAsUpgrade plan={plan.type} onClick={handlePlanClick} /> : <LicenseNav />}
</div>
<div className="flex items-center">
<div className="mr-2">
<PluginsNav />
</div>
<div className="flex items-center gap-2">
<PluginsNav />
<AccountDropdown />
</div>
</div>
<div className="my-1 flex items-center justify-center space-x-1">
<div className="my-1 flex items-center justify-center gap-1">
{!isCurrentWorkspaceDatasetOperator && <ExploreNav className={navClassName} />}
{!isCurrentWorkspaceDatasetOperator && <AppNav />}
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
{!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
{canUseAppDeploy && <DeploymentsNav />}
</div>
</div>
)
@ -100,20 +103,18 @@ const Header = () => {
</WorkspaceProvider>
{enableBilling ? <PlanBadge allowHover sandboxAsUpgrade plan={plan.type} onClick={handlePlanClick} /> : <LicenseNav />}
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-2">
{!isCurrentWorkspaceDatasetOperator && <ExploreNav className={navClassName} />}
{!isCurrentWorkspaceDatasetOperator && <AppNav />}
{(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator) && <DatasetNav />}
{!isCurrentWorkspaceDatasetOperator && <ToolsNav className={navClassName} />}
{canUseAppDeploy && <DeploymentsNav />}
</div>
<div className="flex min-w-0 flex-1 items-center justify-end pr-3 pl-2 min-[1280px]:pl-3">
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 pr-3 pl-2 min-[1280px]:pl-3">
<EnvNav />
<div className="mr-2">
<PluginsNav />
</div>
<PluginsNav />
<AccountDropdown />
</div>
</div>
)
}
export default Header

View File

@ -0,0 +1,37 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDeploymentIdempotencyKey } from '../idempotency'
describe('createDeploymentIdempotencyKey', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('should use crypto random UUID when available', () => {
// Arrange
const randomUUID = vi.fn(() => 'f850a011-4679-40b4-a084-1ecf4c0b94d0')
vi.stubGlobal('crypto', { randomUUID })
// Act
const key = createDeploymentIdempotencyKey()
// Assert
expect(key).toBe('f850a011-4679-40b4-a084-1ecf4c0b94d0')
expect(randomUUID).toHaveBeenCalledTimes(1)
})
it('should generate a bounded fallback key without crypto random UUID', () => {
// Arrange
vi.stubGlobal('crypto', {})
vi.spyOn(Date, 'now').mockReturnValue(1710000000000)
vi.spyOn(Math, 'random').mockReturnValue(0.123456789)
// Act
const key = createDeploymentIdempotencyKey()
// Assert
expect(key).toBe('deployment-ltk9ukg0-4fzzzxjy')
expect(key.length).toBeGreaterThanOrEqual(1)
expect(key.length).toBeLessThanOrEqual(128)
})
})

View File

@ -0,0 +1,113 @@
import type { Release } from '@dify/contracts/enterprise/types.gen'
import { describe, expect, it } from 'vitest'
import { releaseDeploymentAction } from '../release-action'
function release(overrides: Release): Release {
return overrides
}
function currentRelease(overrides: Release): Release {
return overrides
}
describe('releaseDeploymentAction', () => {
describe('deploy actions', () => {
it('should return deploy when the target environment has no current release', () => {
// Arrange
const releases = [
release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
]
// Act
const action = releaseDeploymentAction({
targetRelease: releases[0],
releaseRows: releases,
})
// Assert
expect(action).toBe('deploy')
})
it('should return deployExistingRelease when a preset release is deployed to a new environment', () => {
// Arrange
const releases = [
release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
]
// Act
const action = releaseDeploymentAction({
targetRelease: releases[0],
releaseRows: releases,
isExistingRelease: true,
})
// Assert
expect(action).toBe('deployExistingRelease')
})
})
describe('release direction', () => {
it('should return promote when the target release is newer than the current release', () => {
// Arrange
const releases = [
release({ id: 'release-3', createdAt: '2026-01-03T00:00:00Z' }),
release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
]
// Act
const action = releaseDeploymentAction({
targetRelease: releases[0],
currentRelease: currentRelease({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
releaseRows: releases,
isExistingRelease: true,
})
// Assert
expect(action).toBe('promote')
})
it('should return rollback when the target release is older than the current release', () => {
// Arrange
const releases = [
release({ id: 'release-3', createdAt: '2026-01-03T00:00:00Z' }),
release({ id: 'release-2', createdAt: '2026-01-02T00:00:00Z' }),
]
// Act
const action = releaseDeploymentAction({
targetRelease: releases[1],
currentRelease: currentRelease({ id: 'release-3', createdAt: '2026-01-03T00:00:00Z' }),
releaseRows: releases,
isExistingRelease: true,
})
// Assert
expect(action).toBe('rollback')
})
it('should fall back to release list order when release timestamps are unavailable', () => {
// Arrange
const releases = [
release({ id: 'release-3' }),
release({ id: 'release-2' }),
release({ id: 'release-1' }),
]
// Act
const rollbackAction = releaseDeploymentAction({
targetRelease: releases[2],
currentRelease: currentRelease({ id: 'release-2' }),
releaseRows: releases,
})
const promoteAction = releaseDeploymentAction({
targetRelease: releases[0],
currentRelease: currentRelease({ id: 'release-2' }),
releaseRows: releases,
})
// Assert
expect(rollbackAction).toBe('rollback')
expect(promoteAction).toBe('promote')
})
})
})

View File

@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import {
DEPLOYMENT_STATUS_POLLING_INTERVAL,
deploymentStatus,
deploymentStatusPollingInterval,
isUndeployedDeploymentRow,
RUNTIME_INSTANCE_STATUS_DEPLOYING,
RUNTIME_INSTANCE_STATUS_DRIFTED,
RUNTIME_INSTANCE_STATUS_FAILED,
RUNTIME_INSTANCE_STATUS_INVALID,
RUNTIME_INSTANCE_STATUS_READY,
RUNTIME_INSTANCE_STATUS_UNDEPLOYED,
} from '../runtime-status'
describe('deploymentStatus', () => {
it('should map backend runtime instance statuses to frontend statuses', () => {
// Arrange & Act & Assert
expect(deploymentStatus({ status: RUNTIME_INSTANCE_STATUS_UNDEPLOYED })).toBe('not_deployed')
expect(deploymentStatus({ status: RUNTIME_INSTANCE_STATUS_DEPLOYING })).toBe('deploying')
expect(deploymentStatus({ status: RUNTIME_INSTANCE_STATUS_READY })).toBe('ready')
expect(deploymentStatus({ status: RUNTIME_INSTANCE_STATUS_FAILED })).toBe('deploy_failed')
expect(deploymentStatus({ status: RUNTIME_INSTANCE_STATUS_DRIFTED })).toBe('drifted')
expect(deploymentStatus({ status: RUNTIME_INSTANCE_STATUS_INVALID })).toBe('invalid')
})
it('should map backend proto enum strings to frontend statuses', () => {
// Arrange & Act & Assert
expect(deploymentStatus({ status: 'RUNTIME_INSTANCE_STATUS_UNDEPLOYED' })).toBe('not_deployed')
expect(deploymentStatus({ status: 'RUNTIME_INSTANCE_STATUS_DEPLOYING' })).toBe('deploying')
expect(deploymentStatus({ status: 'RUNTIME_INSTANCE_STATUS_READY' })).toBe('ready')
expect(deploymentStatus({ status: 'RUNTIME_INSTANCE_STATUS_FAILED' })).toBe('deploy_failed')
expect(deploymentStatus({ status: 'RUNTIME_INSTANCE_STATUS_DRIFTED' })).toBe('drifted')
expect(deploymentStatus({ status: 'RUNTIME_INSTANCE_STATUS_INVALID' })).toBe('invalid')
})
it('should return unknown for unspecified or unsupported runtime instance statuses', () => {
// Arrange & Act & Assert
expect(deploymentStatus()).toBe('unknown')
expect(deploymentStatus({ status: 0 })).toBe('unknown')
expect(deploymentStatus({ status: 99 })).toBe('unknown')
})
})
describe('isUndeployedDeploymentRow', () => {
it('should use the runtime instance undeployed status when it is present', () => {
// Arrange & Act & Assert
expect(isUndeployedDeploymentRow({
status: RUNTIME_INSTANCE_STATUS_UNDEPLOYED,
currentRelease: { id: 'release-1' },
desiredRelease: { id: 'release-1' },
currentDeployment: { id: 'deployment-1' },
})).toBe(true)
})
it('should keep the empty-row fallback for rows without status values', () => {
// Arrange & Act & Assert
expect(isUndeployedDeploymentRow({})).toBe(true)
})
})
describe('deploymentStatusPollingInterval', () => {
it('should poll only while at least one runtime instance is deploying', () => {
// Arrange & Act & Assert
expect(deploymentStatusPollingInterval({
data: [
{ status: RUNTIME_INSTANCE_STATUS_READY },
{ status: RUNTIME_INSTANCE_STATUS_DEPLOYING },
],
})).toBe(DEPLOYMENT_STATUS_POLLING_INTERVAL)
expect(deploymentStatusPollingInterval({
data: [
{ status: RUNTIME_INSTANCE_STATUS_UNDEPLOYED },
{ status: RUNTIME_INSTANCE_STATUS_READY },
],
})).toBe(false)
})
})

View File

@ -0,0 +1,7 @@
import { AppModeEnum } from '@/types/app'
const appModeValues = new Set<string>(Object.values(AppModeEnum))
export function toAppMode(mode?: string): AppModeEnum {
return appModeValues.has(mode ?? '') ? (mode as AppModeEnum) : AppModeEnum.WORKFLOW
}

View File

@ -0,0 +1,339 @@
'use client'
import type { App } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxItem,
ComboboxItemText,
ComboboxList,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { keepPreviousData, useInfiniteQuery, useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import Input from '@/app/components/base/input'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
const SOURCE_APP_PAGE_SIZE = 20
const SOURCE_APP_PICKER_SKELETON_KEYS = ['first-source-app', 'second-source-app', 'third-source-app']
function sourceAppSearchText(app: App) {
return `${app.name} ${app.id} ${app.mode}`.toLowerCase()
}
function SourceAppTrigger({ open, app }: {
open: boolean
app?: App
}) {
const { t } = useTranslation('deployments')
return (
<span
className={cn(
'group flex cursor-pointer items-center gap-2 rounded-lg bg-components-input-bg-normal p-2 pl-3 hover:bg-state-base-hover-alt',
open && 'bg-state-base-hover-alt',
app && 'py-1.5 pl-1.5',
)}
>
{app && (
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
)}
<span
title={app?.name}
className={cn(
'min-w-0 grow truncate',
app
? 'system-sm-medium text-components-input-text-filled'
: 'system-sm-regular text-components-input-text-placeholder',
)}
>
{app?.name ?? t('createModal.appPickerPlaceholder')}
</span>
<span
className={cn(
'i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
open && 'text-text-secondary',
)}
aria-hidden="true"
/>
</span>
)
}
function SourceAppOption({ app }: {
app: App
}) {
const { t } = useTranslation('deployments')
const modeLabel = t(`appMode.${app.mode}`, { defaultValue: app.mode })
return (
<ComboboxItem
value={app}
className="mx-0 grid-cols-[minmax(0,1fr)_auto] gap-3 py-1 pr-3 pl-2"
>
<ComboboxItemText className="flex min-w-0 items-center gap-3 px-0">
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<span title={`${app.name} (${app.id})`} className="flex min-w-0 grow items-center gap-1 truncate system-sm-medium text-components-input-text-filled">
<span className="truncate">{app.name}</span>
<span className="shrink-0 text-text-tertiary">
(
{app.id.slice(0, 8)}
)
</span>
</span>
</ComboboxItemText>
<span className="shrink-0 system-2xs-medium-uppercase text-text-tertiary">{modeLabel}</span>
</ComboboxItem>
)
}
function SourceAppPickerSkeleton() {
return (
<div className="flex flex-col gap-2 px-3 py-3">
{SOURCE_APP_PICKER_SKELETON_KEYS.map(key => (
<SkeletonRow key={key} className="h-7 gap-3">
<SkeletonRectangle className="my-0 size-5 animate-pulse rounded-md" />
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
</SkeletonRow>
))}
</div>
)
}
export function SourceAppPicker({ value, onChange }: {
value?: App
onChange: (app: App) => void
}) {
const { t } = useTranslation('deployments')
const [isShow, setIsShow] = useState(false)
const [searchText, setSearchText] = useState('')
const {
data,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery({
...consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
page: Number(pageParam),
limit: SOURCE_APP_PAGE_SIZE,
name: searchText,
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
})
const apps = data?.pages.flatMap(page => page.data) ?? []
return (
<Combobox<App>
items={apps}
open={isShow}
inputValue={searchText}
onOpenChange={setIsShow}
onInputValueChange={setSearchText}
onValueChange={(app) => {
if (!app)
return
onChange(app)
setIsShow(false)
}}
itemToStringLabel={app => app?.name ?? ''}
itemToStringValue={app => app?.id ?? ''}
filter={(app, query) => sourceAppSearchText(app).includes(query.toLowerCase())}
disabled={false}
>
<ComboboxTrigger
aria-label={t('createModal.sourceApp')}
icon={false}
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-open:bg-transparent"
>
<SourceAppTrigger open={isShow} app={value} />
</ComboboxTrigger>
<ComboboxContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="relative flex max-h-100 min-h-20 w-89 flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span className="i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<ComboboxInput
aria-label={t('createModal.appSearchPlaceholder')}
placeholder={t('createModal.appSearchPlaceholder')}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1">
{(isLoading || isFetchingNextPage) && apps.length === 0 && <SourceAppPickerSkeleton />}
<ComboboxList className="max-h-none p-0">
{(app: App) => (
<SourceAppOption key={app.id} app={app} />
)}
</ComboboxList>
{!(isLoading || isFetchingNextPage) && (
<ComboboxEmpty>
{t('createModal.appSearchEmpty')}
</ComboboxEmpty>
)}
{hasNextPage && (
<div className="flex justify-center px-3 py-2">
<Button
type="button"
size="small"
disabled={isFetchingNextPage}
onClick={() => {
void fetchNextPage()
}}
>
{isFetchingNextPage ? t('common.loading') : t('createModal.loadMoreApps')}
</Button>
</div>
)}
</div>
</div>
</ComboboxContent>
</Combobox>
)
}
function CreateInstanceForm({ onClose }: {
onClose: () => void
}) {
const { t } = useTranslation('deployments')
const router = useRouter()
const createInstance = useMutation(consoleQuery.enterprise.appInstanceService.createAppInstance.mutationOptions())
const canCreate = !createInstance.isPending
const handleCreate = async (form: HTMLFormElement) => {
if (!canCreate)
return
const formData = new FormData(form)
const name = String(formData.get('name') ?? '').trim()
const description = String(formData.get('description') ?? '').trim()
if (!name)
return
try {
const result = await createInstance.mutateAsync({
body: {
name: name.trim(),
description: description.trim() || undefined,
},
})
const appInstanceId = result.appInstance?.id
if (!appInstanceId)
throw new Error('Create app instance did not return an app instance.')
onClose()
router.push(`/deployments/${appInstanceId}/overview`)
}
catch {
toast.error(t('createModal.createFailed'))
}
}
return (
<form
className="flex flex-col gap-5"
onSubmit={(event) => {
event.preventDefault()
void handleCreate(event.currentTarget)
}}
>
<div>
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('createModal.title')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('createModal.description')}
</DialogDescription>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="instance-name">
{t('createModal.nameLabel')}
</label>
<Input
id="instance-name"
name="name"
type="text"
placeholder={t('createModal.namePlaceholder')}
required
className="h-8"
/>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="instance-desc">
{t('createModal.descriptionLabel')}
</label>
<textarea
id="instance-desc"
name="description"
placeholder={t('createModal.descriptionPlaceholder')}
className="min-h-20 w-full appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={onClose}>
{t('createModal.cancel')}
</Button>
<Button type="submit" variant="primary" disabled={!canCreate}>
{t('createModal.create')}
</Button>
</div>
</form>
)
}
export function CreateInstanceModal({ open, onOpenChange }: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
>
<DialogContent className="w-130 max-w-[90vw]">
<DialogCloseButton />
{open && <CreateInstanceForm onClose={() => onOpenChange(false)} />}
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,44 @@
'use client'
import { Dialog, DialogCloseButton, DialogContent } from '@langgenius/dify-ui/dialog'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import {
closeDeployDrawerAtom,
deployDrawerAppInstanceIdAtom,
deployDrawerEnvironmentIdAtom,
deployDrawerOpenAtom,
deployDrawerReleaseIdAtom,
} from '../store'
import { DeployForm } from './deploy-drawer/form'
export function DeployDrawer() {
const { t } = useTranslation('deployments')
const open = useAtomValue(deployDrawerOpenAtom)
const drawerAppInstanceId = useAtomValue(deployDrawerAppInstanceIdAtom)
const drawerEnvironmentId = useAtomValue(deployDrawerEnvironmentIdAtom)
const drawerReleaseId = useAtomValue(deployDrawerReleaseIdAtom)
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
const formKey = `${drawerAppInstanceId ?? 'none'}-${drawerEnvironmentId ?? 'any'}-${drawerReleaseId ?? 'new'}-${open ? '1' : '0'}`
return (
<Dialog
open={open}
onOpenChange={next => !next && closeDeployDrawer()}
>
<DialogContent className="w-140 max-w-[90vw]">
<DialogCloseButton />
{!drawerAppInstanceId
? <div className="p-4 text-text-tertiary">{t('deployDrawer.notFound')}</div>
: (
<DeployForm
key={formKey}
appInstanceId={drawerAppInstanceId}
lockedEnvId={drawerEnvironmentId}
presetReleaseId={drawerReleaseId}
/>
)}
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,495 @@
'use client'
import type {
CredentialSelectionInput,
CredentialSlot,
Environment,
EnvironmentDeployment,
Release,
} from '@dify/contracts/enterprise/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { DEPLOYMENT_PAGE_SIZE } from '../../data'
import { environmentBackend, environmentId, environmentMode, environmentName } from '../../environment'
import { createDeploymentIdempotencyKey } from '../../idempotency'
import { releaseCommit, releaseLabel } from '../../release'
import { releaseDeploymentAction } from '../../release-action'
import { isUndeployedDeploymentRow } from '../../runtime-status'
import { closeDeployDrawerAtom } from '../../store'
import {
DeploymentSelect,
EnvironmentRow,
Field,
} from './select'
type DeployFormProps = {
appInstanceId: string
lockedEnvId?: string
presetReleaseId?: string
}
type DeployReadyFormProps = DeployFormProps & {
environments: EnvironmentOption[]
releases: Release[]
defaultReleaseId?: string
runtimeRows: EnvironmentDeployment[]
}
type EnvironmentOption = Environment & { id: string }
const DEPLOY_FORM_FIELD_SKELETON_KEYS = ['environment', 'release']
type BindingSelections = Record<string, string>
type BindingSelectOption = {
value: string
label: string
}
type BindingOptionsPanelProps = {
slots: CredentialSlot[]
selections: BindingSelections
isLoading: boolean
hasError: boolean
onChange: (slot: string, value: string) => void
}
function credentialSlotKey(slot: CredentialSlot) {
return [slot.providerId ?? '', slot.category ?? ''].join(':')
}
function bindingCandidateOptions(slot: CredentialSlot): BindingSelectOption[] {
return (slot.candidates ?? [])
.filter(candidate => candidate.credentialId)
.map(candidate => ({
value: candidate.credentialId!,
label: [
candidate.displayName,
candidate.providerId,
].filter(Boolean).join(' · ') || candidate.credentialId!,
}))
}
function hasMissingRequiredBinding(_slot: CredentialSlot, selectedValue?: string) {
return !selectedValue
}
function selectedDeploymentCredentials(
slots: CredentialSlot[],
selections: BindingSelections,
): CredentialSelectionInput[] {
return slots
.map((slot): CredentialSelectionInput | undefined => {
const slotKey = credentialSlotKey(slot)
const selectedValue = selections[slotKey]
if (!slotKey || !selectedValue)
return undefined
return {
providerId: slot.providerId,
category: slot.category,
credentialId: selectedValue,
}
})
.filter((binding): binding is CredentialSelectionInput => Boolean(binding))
}
function selectedBindingSelections(slots: CredentialSlot[], manualBindings: BindingSelections): BindingSelections {
const next: BindingSelections = {}
for (const slot of slots) {
const slotKey = credentialSlotKey(slot)
const candidates = bindingCandidateOptions(slot)
const existing = manualBindings[slotKey]
if (existing && candidates.some(candidate => candidate.value === existing))
next[slotKey] = existing
else if (candidates.length === 1 && candidates[0])
next[slotKey] = candidates[0].value
}
return next
}
function BindingOptionsPanel({
slots,
selections,
isLoading,
hasError,
onChange,
}: BindingOptionsPanelProps) {
const { t } = useTranslation('deployments')
if (isLoading) {
return (
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
</div>
)
}
if (hasError) {
return (
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4 system-sm-regular text-text-destructive">
{t('deployDrawer.bindingOptionsFailed')}
</div>
)
}
return (
<div className="overflow-hidden rounded-xl border border-divider-subtle bg-background-default-subtle">
<div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('deployDrawer.runtimeCredentials')}</div>
<span className="system-xs-regular text-text-quaternary">{t('deployDrawer.bindingSelectionHint')}</span>
</div>
{slots.length === 0
? (
<div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{t('deployDrawer.noBindingRequired')}
</div>
)
: slots.map((slot) => {
const slotKey = credentialSlotKey(slot)
const candidates = bindingCandidateOptions(slot)
const selectedValue = selections[slotKey] ?? ''
const missing = hasMissingRequiredBinding(slot, selectedValue)
const slotName = slot.providerId || slotKey
return (
<div key={slotKey} className="flex flex-col gap-2 border-t border-divider-subtle px-3 py-3">
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(220px,0.9fr)] sm:items-start">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate system-sm-medium text-text-secondary" title={slotName}>
{slotName}
</span>
<span className="shrink-0 rounded-md bg-background-default px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{t('deployDrawer.requiredBinding')}
</span>
</div>
<span className="font-mono system-xs-regular break-all text-text-quaternary" title={slotKey}>
{slotKey}
</span>
</div>
{candidates.length === 0
? (
<div className="rounded-lg border border-divider-subtle bg-background-default px-2 py-1.5 system-sm-regular text-text-quaternary">
{t('deployDrawer.noCredentialCandidates')}
</div>
)
: (
<DeploymentSelect
value={selectedValue}
onChange={value => onChange(slotKey, value)}
options={candidates}
placeholder={t('deployDrawer.selectCredential')}
/>
)}
</div>
{missing && (
<div className="system-xs-regular text-text-destructive">
{t('deployDrawer.missingRequiredBinding')}
</div>
)}
</div>
)
})}
</div>
)
}
function DeployFormSkeleton() {
return (
<div className="flex flex-col gap-5">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-5 w-44 animate-pulse" />
<SkeletonRectangle className="h-3 w-72 animate-pulse" />
</SkeletonContainer>
{DEPLOY_FORM_FIELD_SKELETON_KEYS.map(key => (
<SkeletonContainer key={key} className="gap-2">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRectangle className="my-0 h-9 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
))}
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
</div>
<SkeletonRow className="justify-end">
<SkeletonRectangle className="my-0 h-8 w-18 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-8 w-22 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
)
}
function DeployReadyForm({
appInstanceId,
environments,
releases,
defaultReleaseId,
lockedEnvId,
presetReleaseId,
runtimeRows,
}: DeployReadyFormProps) {
const { t } = useTranslation('deployments')
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
const startDeploy = useMutation(consoleQuery.enterprise.deploymentService.deploy.mutationOptions())
const presetRelease = presetReleaseId ? releases.find(r => r.id === presetReleaseId) : undefined
const displayedRelease: Release | undefined = presetRelease ?? (presetReleaseId ? { id: presetReleaseId } : undefined)
const isExistingRelease = Boolean(presetReleaseId)
const [selectedEnvId, setSelectedEnvId] = useState<string>(
() => lockedEnvId ?? environments[0]?.id ?? '',
)
const selectedEnvironmentId = selectedEnvId || lockedEnvId || environments[0]?.id || ''
const selectedEnvironment = environments.find(env => env.id === selectedEnvironmentId)
const [selectedReleaseId, setSelectedReleaseId] = useState<string>(
() => displayedRelease?.id ?? defaultReleaseId ?? '',
)
const selectedRelease = releases.find(release => release.id === selectedReleaseId)
const targetReleaseId = displayedRelease?.id ?? selectedRelease?.id ?? selectedReleaseId
const targetRelease = displayedRelease ?? selectedRelease ?? (targetReleaseId ? { id: targetReleaseId } : undefined)
const deploymentRows = runtimeRows.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row))
const selectedDeploymentRow = deploymentRows.find(row => environmentId(row.environment) === selectedEnvironmentId)
const action = releaseDeploymentAction({
targetRelease,
currentRelease: selectedDeploymentRow?.currentRelease,
releaseRows: releases,
isExistingRelease,
})
const bindingOptions = useQuery(consoleQuery.enterprise.releaseService.listReleaseCredentialCandidates.queryOptions({
input: {
params: {
releaseId: targetReleaseId || '',
},
},
enabled: Boolean(appInstanceId && targetReleaseId),
}))
const bindingSlots = bindingOptions.data?.slots?.filter(slot => credentialSlotKey(slot)) ?? []
const [manualBindings, setManualBindings] = useState<BindingSelections>({})
const selectedBindings = selectedBindingSelections(bindingSlots, manualBindings)
const deploymentCredentials = selectedDeploymentCredentials(bindingSlots, selectedBindings)
const bindingOptionsLoading = Boolean(targetReleaseId && (bindingOptions.isLoading || bindingOptions.isFetching))
const bindingOptionsReady = Boolean(targetReleaseId && bindingOptions.data && !bindingOptionsLoading && !bindingOptions.isError)
const requiredBindingsReady = bindingSlots.every(slot => !hasMissingRequiredBinding(slot, selectedBindings[credentialSlotKey(slot)]))
const isSubmitting = startDeploy.isPending
const canDeploy = Boolean(
selectedEnvironmentId
&& selectedEnvironment
&& targetReleaseId
&& bindingOptionsReady
&& requiredBindingsReady
&& !isSubmitting,
)
const lockedEnv = lockedEnvId ? environments.find(e => e.id === lockedEnvId) : undefined
const actionTitle = action === 'rollback'
? t('deployDrawer.rollbackTitle')
: action === 'promote'
? t('deployDrawer.promoteTitle')
: action === 'deployExistingRelease'
? t('deployDrawer.deployExistingReleaseTitle')
: t('deployDrawer.title')
const actionDescription = action === 'rollback'
? t('deployDrawer.rollbackDescription')
: action === 'promote'
? t('deployDrawer.promoteDescription')
: action === 'deployExistingRelease'
? t('deployDrawer.deployExistingReleaseDescription')
: t('deployDrawer.description')
const submitLabel = isSubmitting
? t('deployDrawer.deploying')
: action === 'rollback'
? t('deployDrawer.rollback')
: action === 'promote'
? t('deployDrawer.promote')
: action === 'deployExistingRelease'
? t('deployDrawer.deployExistingRelease')
: t('deployDrawer.deploy')
const handleDeploy = () => {
if (!canDeploy || !targetReleaseId)
return
const idempotencyKey = createDeploymentIdempotencyKey()
startDeploy.mutate(
{
params: {
appInstanceId,
environmentId: selectedEnvironmentId,
},
body: {
appInstanceId,
environmentId: selectedEnvironmentId,
releaseId: targetReleaseId,
credentials: deploymentCredentials,
idempotencyKey,
},
},
{
onSuccess: () => {
closeDeployDrawer()
},
onError: () => {
toast.error(t('deployDrawer.deployFailed'))
},
},
)
}
return (
<div className="flex flex-col gap-5">
<div>
<DialogTitle className="title-xl-semi-bold text-text-primary">
{actionTitle}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{actionDescription}
</DialogDescription>
</div>
<Field label={t('deployDrawer.releaseLabel')}>
{isExistingRelease && displayedRelease
? (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-mono system-sm-semibold text-text-primary">{releaseLabel(displayedRelease)}</span>
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">{releaseCommit(displayedRelease)}</span>
</div>
<span className="shrink-0 system-xs-regular text-text-quaternary">{displayedRelease.createdAt}</span>
</div>
<span className="system-xs-regular text-text-tertiary">
{t('deployDrawer.existingReleaseHint')}
</span>
</div>
)
: releases.length === 0
? (
<div className="rounded-lg border border-dashed border-components-panel-border bg-components-panel-bg-blur px-3 py-3 system-sm-regular text-text-tertiary">
{t('deployDrawer.noReleaseAvailable')}
</div>
)
: (
<DeploymentSelect
value={selectedReleaseId}
onChange={setSelectedReleaseId}
options={releases.filter(release => release.id).map(release => ({
value: release.id!,
label: `${releaseLabel(release)} · ${releaseCommit(release)}`,
}))}
placeholder={t('deployDrawer.selectRelease')}
/>
)}
</Field>
<Field
label={t('deployDrawer.targetEnv')}
hint={lockedEnvId ? t('deployDrawer.lockedHint') : undefined}
>
{lockedEnv
? <EnvironmentRow env={lockedEnv} />
: (
<DeploymentSelect
value={selectedEnvironmentId}
onChange={setSelectedEnvId}
options={environments.filter(env => env.id).map(env => ({
value: env.id!,
label: `${environmentName(env)} · ${t(environmentMode(env) === 'isolated' ? 'mode.isolated' : 'mode.shared')} · ${environmentBackend(env).toUpperCase()}`,
}))}
placeholder={t('deployDrawer.selectEnv')}
/>
)}
</Field>
{targetReleaseId && (
<BindingOptionsPanel
slots={bindingSlots}
selections={selectedBindings}
isLoading={bindingOptionsLoading}
hasError={bindingOptions.isError}
onChange={(slot, value) => setManualBindings(prev => ({ ...prev, [slot]: value }))}
/>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={closeDeployDrawer}>
{t('deployDrawer.cancel')}
</Button>
<Button variant="primary" disabled={!canDeploy} onClick={handleDeploy}>
{submitLabel}
</Button>
</div>
</div>
)
}
export function DeployForm({
appInstanceId,
lockedEnvId,
presetReleaseId,
}: DeployFormProps) {
const { t } = useTranslation('deployments')
const releaseHistoryQuery = useQuery(consoleQuery.enterprise.releaseService.listReleases.queryOptions({
input: {
params: { appInstanceId },
query: {
pageNumber: 1,
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
},
},
}))
const runtimeInstancesQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
}))
if (releaseHistoryQuery.isLoading || runtimeInstancesQuery.isLoading) {
return <DeployFormSkeleton />
}
if (releaseHistoryQuery.isError || runtimeInstancesQuery.isError) {
return (
<div className="p-4 system-sm-regular text-text-destructive">
{t('common.loadFailed')}
</div>
)
}
const environments = runtimeInstancesQuery.data?.data
?.map(row => row.environment)
.filter((environment): environment is EnvironmentOption => Boolean(environment?.id)) ?? []
const releases = releaseHistoryQuery.data?.data?.filter(release => release.id) ?? []
const defaultReleaseId = releases[0]?.id
const runtimeRows = runtimeInstancesQuery.data?.data ?? []
const formKey = `${appInstanceId}-${lockedEnvId ?? 'any'}-${presetReleaseId ?? 'new'}-${defaultReleaseId ?? 'none'}`
return (
<DeployReadyForm
key={formKey}
appInstanceId={appInstanceId}
environments={environments}
releases={releases}
defaultReleaseId={defaultReleaseId}
lockedEnvId={lockedEnvId}
presetReleaseId={presetReleaseId}
runtimeRows={runtimeRows}
/>
)
}

View File

@ -0,0 +1,94 @@
'use client'
import type { Environment } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { useTranslation } from 'react-i18next'
import { environmentHealth, environmentMode, environmentName } from '../../environment'
import { HealthBadge, ModeBadge } from '../status-badge'
type EnvironmentOption = Environment & {
disabled?: boolean
}
export function Field({ label, hint, children }: {
label: string
hint?: string
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="system-xs-medium-uppercase text-text-tertiary">{label}</div>
{hint && <span className="system-xs-regular text-text-quaternary">{hint}</span>}
</div>
{children}
</div>
)
}
type SelectOption = {
value: string
label: string
disabled?: boolean
disabledReason?: string
}
type SelectProps = {
value: string
onChange: (value: string) => void
options: SelectOption[]
placeholder?: string
}
export function DeploymentSelect({ value, onChange, options, placeholder }: SelectProps) {
const { t } = useTranslation('deployments')
const selectedOption = options.find(option => option.value === value)
return (
<Select
value={value || null}
onValueChange={(next) => {
if (!next)
return
onChange(next)
}}
disabled={options.length === 0}
>
<SelectTrigger
className={cn(
'h-8 min-w-0 border border-components-input-border-active px-2 text-left system-sm-medium',
!selectedOption && 'text-text-quaternary',
)}
>
{selectedOption?.label ?? placeholder ?? t('deployDrawer.defaultSelect')}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{options.map(opt => (
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.disabled}
title={opt.disabled ? opt.disabledReason : undefined}
>
<SelectItemText>{opt.label}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
export function EnvironmentRow({ env }: { env: EnvironmentOption }) {
return (
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
<div className="flex items-center gap-2">
<span className="system-sm-semibold text-text-primary">{environmentName(env)}</span>
<ModeBadge mode={environmentMode(env)} />
<HealthBadge health={environmentHealth(env)} />
</div>
<span className="system-xs-regular text-text-tertiary uppercase">{environmentMode(env)}</span>
</div>
)
}

View File

@ -0,0 +1,74 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
type DeployStatus = 'ready' | 'deploying' | 'deploy_failed' | 'drifted' | 'invalid' | 'not_deployed' | 'unknown'
type EnvironmentMode = 'shared' | 'isolated'
type EnvironmentHealth = 'ready' | 'degraded'
const statusStyles: Record<DeployStatus, string> = {
ready: 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700',
deploying: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
deploy_failed: 'border-util-colors-red-red-200 bg-util-colors-red-red-50 text-util-colors-red-red-700',
drifted: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
invalid: 'border-util-colors-red-red-200 bg-util-colors-red-red-50 text-util-colors-red-red-700',
not_deployed: 'border-divider-subtle bg-background-default-subtle text-text-tertiary',
unknown: 'border-divider-subtle bg-background-default-subtle text-text-tertiary',
}
const statusKey = {
ready: 'status.ready',
deploying: 'status.deploying',
deploy_failed: 'status.deployFailed',
drifted: 'status.drifted',
invalid: 'status.invalid',
not_deployed: 'status.notDeployed',
unknown: 'status.unknown',
} as const satisfies Record<DeployStatus, string>
const baseBadge = 'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 system-xs-medium whitespace-nowrap'
export function StatusBadge({ status, className }: {
status: DeployStatus
className?: string
}) {
const { t } = useTranslation('deployments')
return (
<span className={cn(baseBadge, statusStyles[status], className)}>
{status === 'deploying' && (
<span className="size-1.5 animate-pulse rounded-full bg-current" />
)}
{t(statusKey[status])}
</span>
)
}
export function ModeBadge({ mode, className }: {
mode: EnvironmentMode
className?: string
}) {
const { t } = useTranslation('deployments')
const style = mode === 'shared'
? 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700'
: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700'
return (
<span className={cn(baseBadge, style, className)}>
{t(mode === 'shared' ? 'mode.shared' : 'mode.isolated')}
</span>
)
}
export function HealthBadge({ health, className }: {
health: EnvironmentHealth
className?: string
}) {
const { t } = useTranslation('deployments')
const style = health === 'ready'
? 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700'
: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700'
return (
<span className={cn(baseBadge, style, className)}>
{t(health === 'ready' ? 'health.ready' : 'health.degraded')}
</span>
)
}

View File

@ -0,0 +1,278 @@
import type { CredentialSlot, Environment } from '@dify/contracts/enterprise/types.gen'
import { Buffer } from 'node:buffer'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CreateDeploymentGuide } from '../index'
type QueryOptions = {
queryKey?: string[]
enabled?: boolean
}
type QueryResult = {
data?: unknown
isLoading: boolean
isFetching: boolean
isError: boolean
}
type MutationOptions = {
mutationFn: (variables: unknown) => Promise<unknown>
}
const mocks = vi.hoisted(() => ({
createInitialDeploymentFromDsl: vi.fn(),
createInitialDeploymentFromSourceApp: vi.fn(),
getDeploymentOptionsFromDslQueryOptions: vi.fn(),
push: vi.fn(),
toastError: vi.fn(),
}))
vi.mock('@tanstack/react-query', () => ({
keepPreviousData: Symbol('keepPreviousData'),
useInfiniteQuery: () => ({
data: {
pages: [
{
data: [],
has_more: false,
page: 1,
},
],
},
isLoading: false,
isFetching: false,
}),
useMutation: (options: MutationOptions) => ({
isPending: false,
mutateAsync: options.mutationFn,
}),
useQuery: (options: QueryOptions) => {
if (options.enabled === false)
return queryResult()
switch (options.queryKey?.[0]) {
case 'deployable-environments':
return queryResult({ data: { data: [environment()] } })
case 'dsl-deployment-options':
return queryResult({
data: {
options: {
dslDigest: 'dsl-digest-1',
credentialSlots: [credentialSlot()],
},
},
})
case 'source-deployment-options':
default:
return queryResult()
}
},
}))
vi.mock('@/next/link', () => ({
default: ({ children, href, className, 'aria-label': ariaLabel }: {
'children': React.ReactNode
'href': string
'className'?: string
'aria-label'?: string
}) => (
<a href={href} className={className} aria-label={ariaLabel}>{children}</a>
),
}))
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mocks.push,
}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: (...args: unknown[]) => mocks.toastError(...args),
},
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
list: {
infiniteOptions: (options: unknown) => ({
queryKey: ['apps'],
...options as Record<string, unknown>,
}),
},
},
enterprise: {
environmentService: {
listDeployableEnvironments: {
queryOptions: (options: unknown) => ({
queryKey: ['deployable-environments'],
...options as Record<string, unknown>,
}),
},
},
releaseService: {
getDeploymentOptionsFromDsl: {
queryOptions: (options: unknown) => {
mocks.getDeploymentOptionsFromDslQueryOptions(options)
return {
queryKey: ['dsl-deployment-options'],
...options as Record<string, unknown>,
}
},
},
getDeploymentOptionsFromSourceApp: {
queryOptions: (options: unknown) => ({
queryKey: ['source-deployment-options'],
...options as Record<string, unknown>,
}),
},
},
deploymentService: {
createInitialDeploymentFromDsl: {
mutationOptions: () => ({
mutationFn: mocks.createInitialDeploymentFromDsl,
}),
},
createInitialDeploymentFromSourceApp: {
mutationOptions: () => ({
mutationFn: mocks.createInitialDeploymentFromSourceApp,
}),
},
},
},
},
}))
function queryResult(overrides: Partial<QueryResult> = {}): QueryResult {
return {
data: undefined,
isLoading: false,
isFetching: false,
isError: false,
...overrides,
}
}
function environment(overrides: Partial<Environment> = {}): Environment {
return {
id: 'env-1',
name: 'Production',
mode: 'ENVIRONMENT_MODE_ISOLATED',
backend: 'RUNTIME_BACKEND_K8S',
status: 'ENVIRONMENT_STATUS_READY',
...overrides,
}
}
function credentialSlot(overrides: Partial<CredentialSlot> = {}): CredentialSlot {
return {
providerId: 'openai',
category: 'PLUGIN_CATEGORY_MODEL',
candidates: [
{
credentialId: 'cred-1',
providerId: 'openai',
displayName: 'OpenAI key',
},
],
...overrides,
}
}
function getFileInput(container: HTMLElement) {
const fileInput = container.querySelector('input[type="file"]')
if (!(fileInput instanceof HTMLInputElement))
throw new Error('DSL file input was not rendered.')
return fileInput
}
describe('CreateDeploymentGuide', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.createInitialDeploymentFromDsl.mockResolvedValue({
appInstance: {
id: 'instance-1',
},
})
})
// The DSL path should call the enterprise initial deployment endpoint instead of the old placeholder flow.
describe('DSL import deployment', () => {
it('should create an initial deployment from the uploaded DSL', async () => {
// Arrange
const dslContent = `app:
icon: 🤖
name: testchat
`
const { container } = render(<CreateDeploymentGuide />)
// Act
fireEvent.click(screen.getByRole('button', { name: /createGuide\.methods\.importDsl\.title/ }))
fireEvent.change(getFileInput(container), {
target: {
files: [
new File([dslContent], 'demo.yml', { type: 'text/yaml' }),
],
},
})
await waitFor(() => {
expect(screen.getByText('demo.yml')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /createGuide\.actions\.next/ })).toBeEnabled()
})
fireEvent.click(screen.getByRole('button', { name: /createGuide\.actions\.next/ }))
expect(screen.getByRole('textbox', { name: /createGuide\.release\.instanceName/ })).toHaveAttribute('placeholder', 'testchat')
fireEvent.click(screen.getByRole('button', { name: /createGuide\.actions\.next/ }))
await waitFor(() => {
expect(screen.getAllByText('Production').length).toBeGreaterThan(0)
expect(screen.getByRole('button', { name: /createGuide\.actions\.deploy/ })).toBeEnabled()
})
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /createGuide\.actions\.deploy/ }))
})
// Assert
await waitFor(() => {
const expectedEncodedDsl = Buffer.from(dslContent, 'utf8').toString('base64')
expect(mocks.getDeploymentOptionsFromDslQueryOptions).toHaveBeenCalledWith({
input: {
body: {
dsl: expectedEncodedDsl,
},
},
enabled: true,
})
expect(mocks.createInitialDeploymentFromDsl).toHaveBeenCalledWith({
body: {
dsl: expectedEncodedDsl,
environmentId: 'env-1',
appInstanceName: 'testchat',
appInstanceDescription: undefined,
releaseName: 'deployments.createGuide.release.defaultName',
releaseDescription: undefined,
credentials: [
{
providerId: 'openai',
category: 'PLUGIN_CATEGORY_MODEL',
credentialId: 'cred-1',
},
],
idempotencyKey: expect.stringMatching(/^.{1,128}$/),
expectedDslDigest: 'dsl-digest-1',
},
})
expect(mocks.createInitialDeploymentFromSourceApp).not.toHaveBeenCalled()
expect(mocks.push).toHaveBeenCalledWith('/deployments/instance-1/overview')
})
})
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,12 @@
import type { Pagination } from '@dify/contracts/enterprise/types.gen'
export const DEPLOYMENT_PAGE_SIZE = 100
export const RELEASE_HISTORY_PAGE_SIZE = 20
export const SOURCE_APPS_PAGE_SIZE = 100
export function getNextPageParamFromPagination(pagination?: Pagination) {
const currentPage = pagination?.currentPage ?? 1
const totalPages = pagination?.totalPages ?? 1
return currentPage < totalPages ? currentPage + 1 : undefined
}

View File

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

View File

@ -0,0 +1,115 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
type SectionProps = {
title: string
description?: string
action?: ReactNode
children: ReactNode
layout?: 'block' | 'row'
tone?: 'default' | 'destructive'
showDivider?: boolean
}
export function SectionState({ children }: {
children: ReactNode
}) {
return (
<div className="flex min-h-24 items-center justify-center border-y border-dashed border-divider-subtle px-4 py-6 text-center system-sm-regular text-text-tertiary">
{children}
</div>
)
}
export function DetailListState({ children }: {
children: ReactNode
}) {
return (
<div className="flex min-h-36 items-center justify-center border-y border-dashed border-divider-subtle px-4 py-12 text-center system-sm-regular text-text-tertiary">
{children}
</div>
)
}
export function Section({
title,
description,
action,
children,
layout = 'block',
tone = 'default',
showDivider = true,
}: SectionProps) {
const titleClassName = cn(
'system-sm-semibold',
tone === 'destructive'
? 'text-util-colors-red-red-700'
: layout === 'row'
? 'text-text-secondary'
: 'text-text-primary',
)
const descriptionClassName = cn(
'mt-1 body-xs-regular',
tone === 'destructive' ? 'text-util-colors-red-red-600' : 'text-text-tertiary',
)
if (layout === 'row') {
return (
<section className={cn('py-4 first:pt-0 last:pb-0', showDivider && 'border-b border-divider-subtle last:border-b-0')}>
<div className="flex flex-col gap-3 sm:flex-row sm:gap-x-6">
<div className="flex min-w-0 shrink-0 flex-col sm:w-40 sm:pt-1">
<div className={titleClassName}>
{title}
</div>
{description && (
<p className={descriptionClassName}>
{description}
</p>
)}
</div>
<div className="min-w-0 grow">
{action
? (
<div className="flex min-w-0 items-start gap-3">
<div className="min-w-0 grow">
{children}
</div>
<div className="shrink-0">
{action}
</div>
</div>
)
: children}
</div>
</div>
</section>
)
}
return (
<section className={cn('py-6 first:pt-0 last:pb-0', showDivider && 'border-b border-divider-subtle last:border-b-0')}>
<div className="mb-3 flex items-start justify-between gap-4">
<div className="min-w-0">
<div className={titleClassName}>
{title}
</div>
{description && (
<p className={cn(descriptionClassName, 'max-w-150')}>
{description}
</p>
)}
</div>
{Boolean(action) && (
<div className="shrink-0">
{action}
</div>
)}
</div>
<div className="min-w-0">
{children}
</div>
</section>
)
}

View File

@ -0,0 +1,140 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { deploymentStatusPollingInterval } from '../runtime-status'
import { openDeployDrawerAtom } from '../store'
import {
DetailListState,
} from './common'
import { DeploymentEnvironmentList } from './deploy-tab/deployment-environment-list'
import {
DetailTable,
DetailTableBody,
DetailTableCard,
DetailTableCardList,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from './table'
import {
DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from './table-styles'
export function NewDeploymentButton({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
return (
<Button
size="medium"
variant="primary"
className="gap-1.5"
onClick={() => openDeployDrawer({ appInstanceId })}
>
{t('deployTab.newDeployment')}
</Button>
)
}
const DEPLOYMENT_TABLE_ROW_SKELETON_KEYS = ['production', 'staging']
function DeploymentEnvironmentListSkeleton() {
const { t } = useTranslation('deployments')
return (
<>
<DetailTableCardList className="pc:hidden">
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
<DetailTableCard key={key}>
<div className="flex flex-col gap-3 p-4">
<div className="flex min-w-0 flex-col gap-1.5">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="my-0 h-4 w-18 animate-pulse rounded-md" />
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<SkeletonRectangle className="h-2.5 w-24 animate-pulse" />
<SkeletonRow className="gap-2">
<SkeletonRectangle className="h-3 w-16 animate-pulse" />
<SkeletonRectangle className="h-2.5 w-18 animate-pulse" />
</SkeletonRow>
</div>
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</DetailTableCard>
))}
</DetailTableCardList>
<div className="hidden pc:block">
<DetailTable>
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('deployTab.col.environment')}</DetailTableHead>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.status}>{t('deployTab.col.status')}</DetailTableHead>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.currentRelease}>{t('deployTab.col.currentRelease')}</DetailTableHead>
<DetailTableHead className={`${DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.actions} text-right`}>{t('deployTab.col.actions')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
{DEPLOYMENT_TABLE_ROW_SKELETON_KEYS.map(key => (
<DetailTableRow key={key}>
<DetailTableCell>
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
</DetailTableCell>
<DetailTableCell>
<SkeletonRectangle className="my-0 h-4 w-18 animate-pulse rounded-md" />
</DetailTableCell>
<DetailTableCell>
<SkeletonRow className="gap-2">
<SkeletonRectangle className="h-3 w-16 animate-pulse" />
<SkeletonRectangle className="h-2.5 w-18 animate-pulse" />
</SkeletonRow>
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</DetailTableCell>
</DetailTableRow>
))}
</DetailTableBody>
</DetailTable>
</div>
</>
)
}
export function DeployTab({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
refetchInterval: query => deploymentStatusPollingInterval(query.state.data),
}))
const environmentDeployments = environmentDeploymentsQuery.data
const rows = environmentDeployments?.data?.filter(row => row.environment?.id) ?? []
const isLoading = environmentDeploymentsQuery.isLoading
const hasError = environmentDeploymentsQuery.isError
return (
<div className="flex w-full min-w-0 flex-col gap-4 px-6 py-6">
{isLoading
? <DeploymentEnvironmentListSkeleton />
: hasError
? <DetailListState>{t('common.loadFailed')}</DetailListState>
: rows.length === 0
? <DetailListState>{t('deployTab.empty')}</DetailListState>
: (
<DeploymentEnvironmentList appInstanceId={appInstanceId} rows={rows} />
)}
</div>
)
}

View File

@ -0,0 +1,328 @@
'use client'
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useMutation } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import {
environmentId,
environmentName,
} from '../../environment'
import { createDeploymentIdempotencyKey } from '../../idempotency'
import { releaseCommit, releaseLabel } from '../../release'
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import {
DetailTable,
DetailTableBody,
DetailTableCard,
DetailTableCardList,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../table'
import {
DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES,
DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME,
} from '../table-styles'
import { DeploymentStatusSummary } from './deployment-status-summary'
function EnvironmentSummary({ environment }: {
environment: EnvironmentDeployment['environment']
}) {
return (
<span className="block truncate text-text-primary">
{environmentName(environment)}
</span>
)
}
function CurrentReleaseSummary({ release }: {
release: EnvironmentDeployment['currentRelease']
}) {
if (!release?.id && !release?.name)
return <span className="text-text-quaternary"></span>
const commit = releaseCommit(release)
return (
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 items-baseline gap-1.5">
<span className="truncate text-text-primary">
{releaseLabel(release)}
</span>
{commit !== '—' && (
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">
{commit}
</span>
)}
</div>
</div>
)
}
function DeploymentRowActions({ appInstanceId, envId, row }: {
appInstanceId: string
envId: string
row: EnvironmentDeployment
}) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const undeployDeployment = useMutation(consoleQuery.enterprise.deploymentService.undeploy.mutationOptions())
const isUndeployed = isUndeployedDeploymentRow(row)
const status = deploymentStatus(row)
const [showUndeployConfirm, setShowUndeployConfirm] = useState(false)
const [actionsOpen, setActionsOpen] = useState(false)
const [isUndeploying, setIsUndeploying] = useState(false)
const undeployInFlightRef = useRef(false)
const isUndeployRequesting = undeployDeployment.isPending || isUndeploying
const undeployActionDisabled = isUndeployRequesting || !envId
const isDeploying = status === 'deploying'
const deployActionLabel = isUndeployed
? t('deployDrawer.deploy')
: status === 'deploy_failed'
? t('deployTab.viewError')
: t('deployTab.deployOtherVersion')
function handleDeployAction() {
openDeployDrawer({ appInstanceId, environmentId: envId })
setActionsOpen(false)
}
function handleUndeploy() {
if (!envId || undeployInFlightRef.current)
return
undeployInFlightRef.current = true
setIsUndeploying(true)
undeployDeployment.mutate(
{
params: { appInstanceId, environmentId: envId },
body: {
appInstanceId,
environmentId: envId,
idempotencyKey: createDeploymentIdempotencyKey(),
},
},
{
onSettled: () => {
undeployInFlightRef.current = false
setIsUndeploying(false)
setShowUndeployConfirm(false)
},
},
)
}
return (
<div
className="flex shrink-0 items-center"
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
>
{!isDeploying && (
<DropdownMenu modal={false} open={actionsOpen} onOpenChange={setActionsOpen}>
<DropdownMenuTrigger
aria-label={t('deployTab.moreActions')}
className={DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
{actionsOpen && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-44">
<DropdownMenuItem
className="gap-2 px-3"
onClick={handleDeployAction}
>
<span aria-hidden className="i-ri-rocket-line size-4 shrink-0 text-text-tertiary" />
<span className="system-sm-regular text-text-secondary">{deployActionLabel}</span>
</DropdownMenuItem>
{!isUndeployed && (
<>
<div className="my-1 border-t border-divider-subtle" aria-hidden />
<DropdownMenuItem
disabled={undeployActionDisabled}
aria-disabled={undeployActionDisabled}
className={cn(
'gap-2 px-3 text-util-colors-red-red-600',
undeployActionDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={() => {
if (undeployActionDisabled)
return
setActionsOpen(false)
setShowUndeployConfirm(true)
}}
>
<span aria-hidden className="i-ri-logout-box-line size-4 shrink-0" />
<span className="system-sm-regular">{t('deployTab.undeploy')}</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
)}
</DropdownMenu>
)}
{!isUndeployed && !isDeploying && (
<AlertDialog
open={showUndeployConfirm}
onOpenChange={(open) => {
if (isUndeployRequesting)
return
setShowUndeployConfirm(open)
}}
>
<AlertDialogContent className="w-130">
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('deployTab.undeployConfirmTitle', { name: environmentName(row.environment) })}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-tertiary">
{t('deployTab.undeployConfirmDesc')}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary" disabled={isUndeployRequesting}>
{t('deployDrawer.cancel')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={isUndeployRequesting}
disabled={undeployActionDisabled}
onClick={handleUndeploy}
>
{t('deployTab.confirmUndeploy')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
)}
</div>
)
}
function CurrentReleaseMobileSummary({ release }: {
release: EnvironmentDeployment['currentRelease']
}) {
const { t } = useTranslation('deployments')
if (!release?.id && !release?.name)
return null
return (
<div className="flex min-w-0 flex-col gap-1">
<span className="system-2xs-medium-uppercase text-text-tertiary">
{t('deployTab.col.currentRelease')}
</span>
<CurrentReleaseSummary release={release} />
</div>
)
}
function DeploymentEnvironmentMobileRow({ appInstanceId, row }: {
appInstanceId: string
row: EnvironmentDeployment
}) {
const envId = environmentId(row.environment)
const release = row.currentRelease
return (
<DetailTableCard>
<div className="flex flex-col gap-3 p-4 text-left">
<div className="flex min-w-0 flex-col gap-1">
<EnvironmentSummary environment={row.environment} />
<DeploymentStatusSummary row={row} />
</div>
{!isUndeployedDeploymentRow(row) && <CurrentReleaseMobileSummary release={release} />}
<div className="flex min-w-0 items-center justify-start gap-2">
<DeploymentRowActions appInstanceId={appInstanceId} envId={envId} row={row} />
</div>
</div>
</DetailTableCard>
)
}
function DeploymentEnvironmentDesktopRows({ appInstanceId, rows }: {
appInstanceId: string
rows: EnvironmentDeployment[]
}) {
return (
<>
{rows.map((row) => {
const envId = environmentId(row.environment)
return (
<DetailTableRow key={envId}>
<DetailTableCell>
<EnvironmentSummary environment={row.environment} />
</DetailTableCell>
<DetailTableCell>
<DeploymentStatusSummary row={row} />
</DetailTableCell>
<DetailTableCell>
<CurrentReleaseSummary release={row.currentRelease} />
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<DeploymentRowActions appInstanceId={appInstanceId} envId={envId} row={row} />
</div>
</DetailTableCell>
</DetailTableRow>
)
})}
</>
)
}
export function DeploymentEnvironmentList({ appInstanceId, rows }: {
appInstanceId: string
rows: EnvironmentDeployment[]
}) {
const { t } = useTranslation('deployments')
return (
<>
<DetailTableCardList className="pc:hidden">
{rows.map(row => (
<DeploymentEnvironmentMobileRow
key={environmentId(row.environment)}
appInstanceId={appInstanceId}
row={row}
/>
))}
</DetailTableCardList>
<div className="hidden pc:block">
<DetailTable>
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('deployTab.col.environment')}</DetailTableHead>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.status}>{t('deployTab.col.status')}</DetailTableHead>
<DetailTableHead className={DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.currentRelease}>{t('deployTab.col.currentRelease')}</DetailTableHead>
<DetailTableHead className={`${DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES.actions} text-right`}>{t('deployTab.col.actions')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
<DeploymentEnvironmentDesktopRows appInstanceId={appInstanceId} rows={rows} />
</DetailTableBody>
</DetailTable>
</div>
</>
)
}

View File

@ -0,0 +1,106 @@
'use client'
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { releaseLabel } from '../../release'
import {
deploymentStatus,
isUndeployedDeploymentRow,
} from '../../runtime-status'
const StatusIconSlot = ({ children }: { children: ReactNode }) => {
return (
<span className="flex size-3 shrink-0 items-center justify-center">
{children}
</span>
)
}
export function DeploymentStatusSummary({ row }: {
row: EnvironmentDeployment
}) {
const { t } = useTranslation('deployments')
if (isUndeployedDeploymentRow(row)) {
return (
<span className="inline-flex items-center gap-1.5 text-text-tertiary">
<StatusIconSlot>
<span className="size-1.5 rounded-full bg-text-quaternary" />
</StatusIconSlot>
{t('status.notDeployed')}
</span>
)
}
const status = deploymentStatus(row)
if (status === 'deploying') {
const targetRelease = row.desiredRelease ?? row.currentRelease
const hasTargetRelease = !!(targetRelease?.name || targetRelease?.id)
return (
<span className="inline-flex items-center gap-1.5 text-util-colors-blue-blue-700">
<StatusIconSlot>
<span className="i-ri-loader-4-line size-2 animate-spin" />
</StatusIconSlot>
{hasTargetRelease
? t('deployTab.status.deployingRelease', { release: releaseLabel(targetRelease) })
: t('status.deploying')}
</span>
)
}
if (status === 'deploy_failed') {
const hasRunningRelease = !!row.currentRelease?.id
return (
<span className="inline-flex items-center gap-1.5 text-util-colors-red-red-700">
<StatusIconSlot>
<span className="i-ri-alert-line size-3" />
</StatusIconSlot>
{t(hasRunningRelease ? 'deployTab.status.runningWithFailed' : 'deployTab.status.deployFailed')}
</span>
)
}
if (status === 'drifted') {
const hasRunningRelease = !!row.currentRelease?.id
return (
<span className="inline-flex items-center gap-1.5 text-util-colors-warning-warning-700">
<StatusIconSlot>
<span className="i-ri-error-warning-line size-3" />
</StatusIconSlot>
{t(hasRunningRelease ? 'deployTab.status.runningOutOfSync' : 'status.drifted')}
</span>
)
}
if (status === 'invalid') {
return (
<span className="inline-flex items-center gap-1.5 text-util-colors-red-red-700">
<StatusIconSlot>
<span className="i-ri-error-warning-line size-3" />
</StatusIconSlot>
{t('status.invalid')}
</span>
)
}
if (status === 'unknown') {
return (
<span className="inline-flex items-center gap-1.5 text-text-tertiary">
<StatusIconSlot>
<span className="i-ri-question-line size-3" />
</StatusIconSlot>
{t('status.unknown')}
</span>
)
}
return (
<span className="inline-flex items-center gap-1.5 text-util-colors-green-green-700">
<StatusIconSlot>
<span className="size-1.5 rounded-full bg-util-colors-green-green-500" />
</StatusIconSlot>
{t('status.ready')}
</span>
)
}

View File

@ -0,0 +1,265 @@
'use client'
import type { ComponentProps, PropsWithoutRef } from 'react'
import type { InstanceDetailTabKey } from './tabs'
import type { NavIcon } from '@/app/components/app-sidebar/nav-link'
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery } from '@tanstack/react-query'
import { useHover, useKeyPress, useLocalStorageState } from 'ahooks'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { getAppModeLabel } from '@/app/components/app-sidebar/app-info/app-mode-labels'
import NavLink from '@/app/components/app-sidebar/nav-link'
import ToggleButton from '@/app/components/app-sidebar/toggle-button'
import AppIcon from '@/app/components/base/app-icon'
import Divider from '@/app/components/base/divider'
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { consoleQuery } from '@/service/client'
import { toAppMode } from '../app-mode'
type TabDef = {
key: InstanceDetailTabKey
icon: NavIcon
selectedIcon: NavIcon
}
type DeploymentSidebarMode = 'expand' | 'collapse'
const DEPLOYMENT_SIDEBAR_MODE_KEY = 'deployment-sidebar-collapse-or-expand'
type TailwindNavIconProps = PropsWithoutRef<ComponentProps<'svg'>> & {
title?: string
titleId?: string
}
function OverviewIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-dashboard-2-line', className)} />
}
function OverviewSelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-dashboard-2-fill', className)} />
}
function DeployIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-rocket-line', className)} />
}
function DeploySelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-rocket-fill', className)} />
}
function VersionsIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-stack-line', className)} />
}
function VersionsSelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-stack-fill', className)} />
}
function AccessIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-shield-user-line', className)} />
}
function AccessSelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-shield-user-fill', className)} />
}
function ApiIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-code-s-slash-line', className)} />
}
function ApiSelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-code-s-slash-fill', className)} />
}
function SettingsIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-settings-3-line', className)} />
}
function SettingsSelectedIcon({ className }: TailwindNavIconProps) {
return <span aria-hidden className={cn('i-ri-settings-3-fill', className)} />
}
const TABS: TabDef[] = [
{ key: 'overview', icon: OverviewIcon, selectedIcon: OverviewSelectedIcon },
{ key: 'deploy', icon: DeployIcon, selectedIcon: DeploySelectedIcon },
{ key: 'releases', icon: VersionsIcon, selectedIcon: VersionsSelectedIcon },
{ key: 'access', icon: AccessIcon, selectedIcon: AccessSelectedIcon },
{ key: 'api', icon: ApiIcon, selectedIcon: ApiSelectedIcon },
{ key: 'settings', icon: SettingsIcon, selectedIcon: SettingsSelectedIcon },
]
function isShortcutFromInputArea(target: EventTarget | null) {
if (!(target instanceof HTMLElement))
return false
return target.tagName === 'INPUT'
|| target.tagName === 'TEXTAREA'
|| target.isContentEditable
}
function useDeploymentSidebarMode(isMobile: boolean) {
const [persistedMode, setPersistedMode] = useLocalStorageState<DeploymentSidebarMode>(
DEPLOYMENT_SIDEBAR_MODE_KEY,
{ defaultValue: 'expand' },
)
const sidebarMode = isMobile ? 'collapse' : persistedMode ?? 'expand'
function toggleSidebarMode() {
setPersistedMode(sidebarMode === 'expand' ? 'collapse' : 'expand')
}
return {
sidebarMode,
toggleSidebarMode,
}
}
type DeploymentSidebarProps = {
appInstanceId: string
}
function DeploymentSidebarInstanceInfo({ appInstanceId, expand }: {
appInstanceId: string
expand: boolean
}) {
const { t } = useTranslation('deployments')
const { t: tCommon } = useTranslation()
const overviewQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: {
params: { appInstanceId },
},
}))
const app = overviewQuery.data?.appInstance
const isLoading = !app?.id && overviewQuery.isLoading
const isUnavailable = !app?.id || overviewQuery.isError
const instanceName = app?.name ?? appInstanceId
const appModeLabel = app?.id ? getAppModeLabel(toAppMode(), tCommon) : ''
return (
<div className={cn('shrink-0', expand ? 'p-2' : 'p-1')}>
<div className={cn('flex flex-col gap-2 rounded-lg', expand ? 'p-1' : 'items-center p-1')}>
{isLoading
? (
<>
<SkeletonRectangle className={cn('my-0 animate-pulse rounded-lg', expand ? 'size-10' : 'size-8')} />
{expand && (
<SkeletonContainer className="w-full gap-1">
<SkeletonRectangle className="my-0 h-5 w-32 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-20 animate-pulse" />
</SkeletonContainer>
)}
</>
)
: isUnavailable
? (
<>
<div className="flex size-8 items-center justify-center rounded-lg bg-components-icon-bg-orange-solid text-text-primary-on-surface">
<span className="i-ri-rocket-line size-4" />
</div>
{expand && (
<div className="flex flex-col items-start gap-1">
<div className="truncate system-md-semibold whitespace-nowrap text-text-secondary">
{t('detail.notFound')}
</div>
<div className="max-w-full truncate font-mono system-2xs-regular text-text-tertiary" title={appInstanceId}>
{appInstanceId}
</div>
</div>
)}
</>
)
: (
<>
<div className="flex items-center gap-1">
<AppIcon
size={expand ? 'large' : 'medium'}
iconType="emoji"
icon=""
background={null}
/>
</div>
{expand && (
<div className="flex flex-col items-start gap-1">
<div className="flex w-full">
<div className="truncate system-md-semibold whitespace-nowrap text-text-secondary" title={instanceName}>
{instanceName}
</div>
</div>
<div className="flex max-w-full items-center gap-1.5 system-2xs-medium-uppercase text-text-tertiary">
<span className="shrink-0 whitespace-nowrap">{appModeLabel}</span>
</div>
{app.description && (
<div
className="line-clamp-2 system-xs-regular text-text-tertiary"
title={app.description}
>
{app.description}
</div>
)}
</div>
)}
</>
)}
</div>
</div>
)
}
export function DeploymentSidebar({ appInstanceId }: DeploymentSidebarProps) {
const { t } = useTranslation('deployments')
const sidebarRef = useRef<HTMLDivElement>(null)
const isHoveringSidebar = useHover(sidebarRef)
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { sidebarMode, toggleSidebarMode } = useDeploymentSidebarMode(isMobile)
const expand = sidebarMode === 'expand'
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
if (isShortcutFromInputArea(e.target))
return
e.preventDefault()
toggleSidebarMode()
}, { exactMatch: true, useCapture: true })
return (
<aside
ref={sidebarRef}
className={cn(
'flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all',
expand ? 'w-54' : 'w-14',
)}
>
<DeploymentSidebarInstanceInfo appInstanceId={appInstanceId} expand={expand} />
<div className="relative px-4 py-2">
<Divider
type="horizontal"
bgStyle={expand ? 'gradient' : 'solid'}
className={cn(
'my-0 h-px',
expand
? 'bg-linear-to-r from-divider-subtle to-background-gradient-mask-transparent'
: 'bg-divider-subtle',
)}
/>
{!isMobile && isHoveringSidebar && (
<ToggleButton
className="absolute -top-1 -right-3 z-20"
expand={expand}
handleToggle={toggleSidebarMode}
/>
)}
</div>
<nav
className={cn(
'flex grow flex-col gap-y-0.5',
expand ? 'px-3 py-2' : 'p-3',
)}
>
{TABS.map(tab => (
<NavLink
key={tab.key}
mode={sidebarMode}
iconMap={{ selected: tab.selectedIcon, normal: tab.icon }}
name={t(`tabs.${tab.key}.name`)}
href={`/deployments/${appInstanceId}/${tab.key}`}
/>
))}
</nav>
</aside>
)
}

View File

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

View File

@ -0,0 +1,56 @@
'use client'
import type { ReactNode } from 'react'
import type { InstanceDetailTabKey } from './tabs'
import { useTranslation } from 'react-i18next'
import useDocumentTitle from '@/hooks/use-document-title'
import { useSelectedLayoutSegment } from '@/next/navigation'
import { DeployDrawer } from '../components/deploy-drawer'
import { NewDeploymentButton } from './deploy-tab'
import { DeploymentSidebar } from './deployment-sidebar'
import { DeveloperApiHeaderActions } from './settings-tab/access/developer-api-section'
import { isInstanceDetailTabKey } from './tabs'
import { CreateReleaseControl } from './versions-tab/create-release-control'
export function InstanceDetail({ appInstanceId, children }: {
appInstanceId: string
children: ReactNode
}) {
const { t } = useTranslation('deployments')
const selectedSegment = useSelectedLayoutSegment()
const selectedTab = selectedSegment ?? undefined
const activeTab: InstanceDetailTabKey = isInstanceDetailTabKey(selectedTab) ? selectedTab : 'overview'
useDocumentTitle(t('documentTitle.detail'))
return (
<>
<div className="relative flex h-full min-w-0 overflow-hidden rounded-t-2xl shadow-xs">
<DeploymentSidebar appInstanceId={appInstanceId} />
<div className="min-w-0 grow overflow-hidden bg-components-panel-bg">
<div className="h-full min-w-0 overflow-y-auto">
<div className="flex w-full flex-col gap-y-0.5 px-6 pt-3 pb-2">
<div className="flex min-w-0 items-start justify-between gap-4">
<div className="min-w-0">
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
<div className="system-sm-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
</div>
{(activeTab === 'api' || activeTab === 'releases' || activeTab === 'deploy') && (
<div className="shrink-0 pt-1.5">
{activeTab === 'api'
? <DeveloperApiHeaderActions appInstanceId={appInstanceId} />
: activeTab === 'deploy'
? <NewDeploymentButton appInstanceId={appInstanceId} />
: <CreateReleaseControl appInstanceId={appInstanceId} size="medium" />}
</div>
)}
</div>
</div>
{children}
</div>
</div>
</div>
<DeployDrawer />
</>
)
}

View File

@ -0,0 +1,139 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { SectionState } from './common'
import { AccessStatusSection, AccessStatusSectionSkeleton } from './overview-tab/access-status-section'
import { EnvironmentStrip, EnvironmentStripSkeleton } from './overview-tab/environment-strip'
import { computeOverviewStats } from './overview-tab/overview-drift'
import { ReleaseHero, ReleaseHeroSkeleton } from './overview-tab/release-hero'
const OVERVIEW_RELEASE_WINDOW = 20
function OverviewLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex w-full min-w-0 flex-col gap-6 px-6 py-6">
{children}
</div>
)
}
function ReleaseOverviewSection({ appInstanceId, children }: {
appInstanceId: string
children: React.ReactNode
}) {
const { t } = useTranslation('deployments')
return (
<section className="flex min-w-0 flex-col gap-3">
<div className="flex min-w-0 items-baseline justify-between gap-3">
<h3 className="system-sm-semibold text-text-primary">
{t('overview.recentReleases')}
</h3>
<Link
href={`/deployments/${appInstanceId}/releases`}
className="inline-flex shrink-0 items-center gap-1 system-xs-medium text-text-tertiary transition-colors hover:text-text-secondary"
>
{t('overview.previousReleases.viewAll')}
<span aria-hidden className="i-ri-arrow-right-line size-3.5" />
</Link>
</div>
<div className="flex min-w-0 flex-col gap-3">
{children}
</div>
</section>
)
}
function OverviewLoadingSkeleton({ appInstanceId }: {
appInstanceId: string
}) {
return (
<OverviewLayout>
<div className="flex min-w-0 flex-col gap-6">
<ReleaseOverviewSection appInstanceId={appInstanceId}>
<ReleaseHeroSkeleton />
</ReleaseOverviewSection>
<EnvironmentStripSkeleton />
<AccessStatusSectionSkeleton />
</div>
</OverviewLayout>
)
}
export function OverviewTab({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const input = { params: { appInstanceId } }
const instanceQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({ input }))
const runtimeInstancesQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({ input }))
const releasesQuery = useQuery(consoleQuery.enterprise.releaseService.listReleases.queryOptions({
input: {
params: { appInstanceId },
query: { pageNumber: 1, resultsPerPage: OVERVIEW_RELEASE_WINDOW },
},
}))
const accessChannelsQuery = useQuery(consoleQuery.enterprise.accessService.getAccessChannels.queryOptions({ input }))
const instance = instanceQuery.data?.appInstance
if (instanceQuery.isLoading)
return <OverviewLoadingSkeleton appInstanceId={appInstanceId} />
if (instanceQuery.isError) {
return (
<OverviewLayout>
<SectionState>{t('common.loadFailed')}</SectionState>
</OverviewLayout>
)
}
if (!instance?.id) {
return (
<OverviewLayout>
<SectionState>{t('detail.notFound')}</SectionState>
</OverviewLayout>
)
}
if (releasesQuery.isLoading)
return <OverviewLoadingSkeleton appInstanceId={appInstanceId} />
if (releasesQuery.isError) {
return (
<OverviewLayout>
<SectionState>{t('common.loadFailed')}</SectionState>
</OverviewLayout>
)
}
const releaseRows = releasesQuery.data?.data ?? []
const runtimeRows = runtimeInstancesQuery.data?.data?.filter(row => row.environment?.id) ?? []
const latestRelease = releaseRows[0]
const stats = computeOverviewStats(runtimeRows, releaseRows)
const accessChannels = accessChannelsQuery.data?.accessChannels
return (
<OverviewLayout>
<div className="flex min-w-0 flex-col gap-6">
<ReleaseOverviewSection appInstanceId={appInstanceId}>
<ReleaseHero
appInstanceId={appInstanceId}
latestRelease={latestRelease}
stats={stats}
/>
</ReleaseOverviewSection>
<EnvironmentStrip
appInstanceId={appInstanceId}
rows={runtimeRows}
releaseRows={releaseRows}
isLoading={runtimeInstancesQuery.isLoading}
isError={runtimeInstancesQuery.isError}
/>
<AccessStatusSection appInstanceId={appInstanceId} accessChannels={accessChannels} />
</div>
</OverviewLayout>
)
}

View File

@ -0,0 +1,184 @@
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
import { describe, expect, it } from 'vitest'
import {
RUNTIME_INSTANCE_STATUS_DEPLOYING,
RUNTIME_INSTANCE_STATUS_FAILED,
RUNTIME_INSTANCE_STATUS_READY,
} from '../../../runtime-status'
import { computeDrift, computeOverviewStats, latestReleaseId } from '../overview-drift'
function row(overrides: EnvironmentDeployment): EnvironmentDeployment {
return overrides
}
function release(overrides: Release): Release {
return overrides
}
describe('computeDrift', () => {
it('should return undeployed when the runtime row signals undeployed', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1', name: 'prod' },
})
// Act
const result = computeDrift(runtime, [])
// Assert
expect(result).toEqual({ kind: 'undeployed' })
})
it('should return undeployed when there is no id, current release, or detail', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1', name: 'prod' },
})
// Act
const result = computeDrift(runtime, [release({ id: 'r-1' })])
// Assert
expect(result).toEqual({ kind: 'undeployed' })
})
it('should return unknown when the deployed release has no id', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1' },
status: RUNTIME_INSTANCE_STATUS_READY,
currentDeployment: { id: 'deployment-1' },
})
// Act
const result = computeDrift(runtime, [release({ id: 'r-1' })])
// Assert
expect(result).toEqual({ kind: 'unknown' })
})
it('should return unknown when the deployed release is not in the loaded window', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1' },
status: RUNTIME_INSTANCE_STATUS_READY,
currentDeployment: { id: 'deployment-1' },
currentRelease: { id: 'r-older' },
})
// Act
const result = computeDrift(runtime, [release({ id: 'r-3' }), release({ id: 'r-2' })])
// Assert
expect(result).toEqual({ kind: 'unknown' })
})
it('should return up-to-date when the deployed release is the newest in the window', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1' },
status: RUNTIME_INSTANCE_STATUS_READY,
currentDeployment: { id: 'deployment-1' },
currentRelease: { id: 'r-3' },
})
// Act
const result = computeDrift(runtime, [
release({ id: 'r-3' }),
release({ id: 'r-2' }),
release({ id: 'r-1' }),
])
// Assert
expect(result).toEqual({ kind: 'up-to-date' })
})
it('should return behind with the index distance when the deployed release is older', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1' },
status: RUNTIME_INSTANCE_STATUS_READY,
currentDeployment: { id: 'deployment-1' },
currentRelease: { id: 'r-1' },
})
// Act
const result = computeDrift(runtime, [
release({ id: 'r-3' }),
release({ id: 'r-2' }),
release({ id: 'r-1' }),
])
// Assert
expect(result).toEqual({ kind: 'behind', steps: 2 })
})
it('should return unknown when the release window is empty', () => {
// Arrange
const runtime = row({
environment: { id: 'env-1' },
status: RUNTIME_INSTANCE_STATUS_READY,
currentDeployment: { id: 'deployment-1' },
currentRelease: { id: 'r-1' },
})
// Act
const result = computeDrift(runtime, [])
// Assert
expect(result).toEqual({ kind: 'unknown' })
})
})
describe('latestReleaseId', () => {
it('should return the first release id', () => {
expect(latestReleaseId([release({ id: 'r-3' }), release({ id: 'r-2' })])).toBe('r-3')
})
it('should return undefined when the list is empty', () => {
expect(latestReleaseId([])).toBeUndefined()
})
it('should return undefined when the first release has no id', () => {
expect(latestReleaseId([release({})])).toBeUndefined()
})
})
describe('computeOverviewStats', () => {
const releases = [release({ id: 'r-3' }), release({ id: 'r-2' }), release({ id: 'r-1' })]
it('should classify each row into a single bucket', () => {
// Arrange
const rows: EnvironmentDeployment[] = [
row({ currentDeployment: { id: 'deployment-1' }, environment: { id: 'env-1' }, status: RUNTIME_INSTANCE_STATUS_READY, currentRelease: { id: 'r-3' } }),
row({ currentDeployment: { id: 'deployment-2' }, environment: { id: 'env-2' }, status: RUNTIME_INSTANCE_STATUS_READY, currentRelease: { id: 'r-1' } }),
row({ currentDeployment: { id: 'deployment-3' }, environment: { id: 'env-3' }, status: RUNTIME_INSTANCE_STATUS_DEPLOYING, currentRelease: { id: 'r-3' } }),
row({ currentDeployment: { id: 'deployment-4' }, environment: { id: 'env-4' }, status: RUNTIME_INSTANCE_STATUS_FAILED, currentRelease: { id: 'r-2' } }),
row({ environment: { id: 'env-5' } }),
]
// Act
const stats = computeOverviewStats(rows, releases)
// Assert
expect(stats).toEqual({ total: 5, ready: 1, behind: 1, failed: 1, deploying: 1, undeployed: 1 })
})
it('should not count failed envs as behind even when on an older release', () => {
// Arrange
const rows: EnvironmentDeployment[] = [
row({ currentDeployment: { id: 'deployment-1' }, environment: { id: 'env-1' }, status: RUNTIME_INSTANCE_STATUS_FAILED, currentRelease: { id: 'r-1' } }),
]
// Act
const stats = computeOverviewStats(rows, releases)
// Assert
expect(stats.failed).toBe(1)
expect(stats.behind).toBe(0)
})
it('should return all zeros for an empty grid', () => {
expect(computeOverviewStats([], releases)).toEqual({ total: 0, ready: 0, behind: 0, failed: 0, deploying: 0, undeployed: 0 })
})
})

View File

@ -0,0 +1,149 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OverviewTab } from '../../overview-tab'
type QueryOptions = {
queryKey?: string[]
}
type QueryResult = {
data?: unknown
isLoading: boolean
isError: boolean
}
const mockUseQuery = vi.fn<(options: QueryOptions) => QueryResult>()
vi.mock('@tanstack/react-query', () => ({
useQuery: (options: QueryOptions) => mockUseQuery(options),
}))
vi.mock('@/next/link', () => ({
default: ({ children, href, className }: { children: React.ReactNode, href: string, className?: string }) => (
<a href={href} className={className}>{children}</a>
),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
appInstanceService: {
getAppInstance: {
queryOptions: () => ({ queryKey: ['app-instance'] }),
},
},
deploymentService: {
listEnvironmentDeployments: {
queryOptions: () => ({ queryKey: ['runtime-instances'] }),
},
},
releaseService: {
listReleases: {
queryOptions: () => ({ queryKey: ['release-history'] }),
},
},
accessService: {
getAccessChannels: {
queryOptions: () => ({ queryKey: ['access-channels'] }),
},
},
},
},
}))
function queryResult(overrides: Partial<QueryResult> = {}): QueryResult {
return {
data: undefined,
isLoading: false,
isError: false,
...overrides,
}
}
function completeOverviewData() {
return {
appInstance: { id: 'instance-1' },
}
}
function releaseHistoryData() {
return {
data: [],
pagination: { totalCount: 0 },
}
}
function runtimeInstancesData() {
return {
data: [],
}
}
function expectCompleteLoadingSkeleton(container: HTMLElement) {
expect(screen.getByRole('heading', { name: 'deployments.overview.recentReleases' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'deployments.overview.strip.title' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'deployments.overview.accessStatus' })).toBeInTheDocument()
expect(container.querySelectorAll('[data-slot="deployment-overview-release-hero-skeleton"]')).toHaveLength(1)
expect(container.querySelectorAll('[data-slot="deployment-overview-environment-tile-skeleton"]')).toHaveLength(4)
expect(container.querySelectorAll('[data-slot="deployment-overview-access-card-skeleton"]')).toHaveLength(3)
}
describe('OverviewTab loading states', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Initial page loading should reserve space for every section in the updated overview.
describe('Initial loading', () => {
it('should render the complete overview skeleton when the instance overview is loading', () => {
// Arrange
mockUseQuery.mockImplementation((options: QueryOptions) => {
switch (options.queryKey?.[0]) {
case 'app-instance':
return queryResult({ isLoading: true })
case 'runtime-instances':
return queryResult({ data: runtimeInstancesData() })
case 'release-history':
return queryResult({ data: releaseHistoryData() })
case 'access-channels':
return queryResult({ data: { accessChannels: { webAppEnabled: false, developerApiEnabled: false } } })
default:
return queryResult()
}
})
// Act
const { container } = render(<OverviewTab appInstanceId="instance-1" />)
// Assert
expectCompleteLoadingSkeleton(container)
})
})
// Release loading happens after the instance resolves and should still align with the full page layout.
describe('Release loading', () => {
it('should render the complete overview skeleton when release history is loading', () => {
// Arrange
mockUseQuery.mockImplementation((options: QueryOptions) => {
switch (options.queryKey?.[0]) {
case 'app-instance':
return queryResult({ data: completeOverviewData() })
case 'runtime-instances':
return queryResult({ data: runtimeInstancesData() })
case 'release-history':
return queryResult({ isLoading: true })
case 'access-channels':
return queryResult({ data: { accessChannels: { webAppEnabled: false, developerApiEnabled: false } } })
default:
return queryResult()
}
})
// Act
const { container } = render(<OverviewTab appInstanceId="instance-1" />)
// Assert
expectCompleteLoadingSkeleton(container)
})
})
})

View File

@ -0,0 +1,140 @@
'use client'
import type { AccessChannels } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import Link from '@/next/link'
type AccessStatusSectionProps = {
appInstanceId: string
accessChannels?: AccessChannels
apiKeyCount?: number
}
type AccessStatusItem = {
key: 'webapp' | 'cli' | 'api'
href: string
icon: string
label: string
enabled: boolean
meta?: string
}
const ACCESS_STATUS_SKELETON_KEYS = ['webapp', 'cli', 'api']
export function AccessStatusSection({ appInstanceId, accessChannels, apiKeyCount }: AccessStatusSectionProps) {
const { t } = useTranslation('deployments')
const items: AccessStatusItem[] = [
{
key: 'webapp',
href: `/deployments/${appInstanceId}/access`,
icon: 'i-ri-global-line',
label: t('card.access.webApp'),
enabled: Boolean(accessChannels?.webAppEnabled),
},
{
key: 'cli',
href: `/deployments/${appInstanceId}/access`,
icon: 'i-ri-terminal-box-line',
label: t('card.access.cli'),
enabled: Boolean(accessChannels?.webAppEnabled),
},
{
key: 'api',
href: `/deployments/${appInstanceId}/api`,
icon: 'i-ri-code-s-slash-line',
label: t('card.access.api'),
enabled: Boolean(accessChannels?.developerApiEnabled),
meta: accessChannels?.developerApiEnabled && apiKeyCount != null
? t('overview.apiKeysCount', { count: apiKeyCount })
: undefined,
},
]
return (
<section className="flex min-w-0 flex-col gap-3">
<h3 className="system-sm-semibold text-text-primary">
{t('overview.accessStatus')}
</h3>
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,220px),1fr))] gap-3">
{items.map(item => (
<Link
key={item.key}
href={item.href}
className={cn(
'group flex min-w-0 items-start gap-3 rounded-xl border p-4 transition-colors',
item.enabled
? 'border-components-panel-border bg-components-panel-bg hover:bg-components-panel-on-panel-item-bg-hover'
: 'border-divider-subtle bg-background-default-subtle hover:bg-state-base-hover',
)}
>
<span
aria-hidden
className={cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
item.enabled
? 'bg-util-colors-green-green-50 text-util-colors-green-green-700'
: 'bg-background-section-burn text-text-tertiary',
)}
>
<span className={cn('size-4.5', item.icon)} />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate system-sm-medium text-text-primary">
{item.label}
</span>
<span
className={cn(
'inline-flex h-5 shrink-0 items-center rounded-md px-1.5 text-xs',
item.enabled
? 'bg-util-colors-green-green-50 text-util-colors-green-green-700'
: 'bg-background-section-burn text-text-tertiary',
)}
>
{item.enabled ? t('overview.enabled') : t('overview.disabled')}
</span>
</span>
<span className="truncate text-xs text-text-tertiary">
{item.meta || t('overview.notConfigured')}
</span>
</span>
</Link>
))}
</div>
</section>
)
}
export function AccessStatusSectionSkeleton() {
const { t } = useTranslation('deployments')
return (
<section className="flex min-w-0 flex-col gap-3">
<h3 className="system-sm-semibold text-text-primary">
{t('overview.accessStatus')}
</h3>
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,220px),1fr))] gap-3">
{ACCESS_STATUS_SKELETON_KEYS.map(key => (
<div
key={key}
data-slot="deployment-overview-access-card-skeleton"
className="flex min-w-0 items-start gap-3 rounded-xl border border-components-panel-border bg-components-panel-bg p-4"
>
<SkeletonRectangle className="my-0 size-9 shrink-0 animate-pulse rounded-lg" />
<span className="flex min-w-0 flex-1 flex-col gap-2">
<span className="flex min-w-0 items-center justify-between gap-3">
<SkeletonRectangle className="my-0 h-3.5 w-20 animate-pulse" />
<SkeletonRectangle className="my-0 h-5 w-12 shrink-0 animate-pulse rounded-md" />
</span>
<SkeletonRectangle className="my-0 h-3 w-4/5 animate-pulse" />
</span>
</div>
))}
</div>
</section>
)
}

View File

@ -0,0 +1,105 @@
'use client'
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { environmentId } from '../../environment'
import { isUndeployedDeploymentRow } from '../../runtime-status'
import { SectionState } from '../common'
import { EnvironmentTile } from './environment-tile'
type EnvironmentStripProps = {
appInstanceId: string
rows: EnvironmentDeployment[]
releaseRows: Release[]
isLoading: boolean
isError: boolean
}
export function EnvironmentStrip({ appInstanceId, rows, releaseRows, isLoading, isError }: EnvironmentStripProps) {
const { t } = useTranslation('deployments')
const deployedRows = rows.filter(row => !isUndeployedDeploymentRow(row))
return (
<section className="flex flex-col gap-3">
<h3 className="system-sm-semibold text-text-primary">{t('overview.strip.title')}</h3>
{isLoading
? <CardSkeletons />
: isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: rows.length === 0
? <SectionState>{t('overview.strip.empty')}</SectionState>
: deployedRows.length === 0
? <SectionState>{t('overview.strip.emptyDeployed')}</SectionState>
: (
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,260px),1fr))] gap-3">
{deployedRows.map(row => (
<EnvironmentTile
key={environmentId(row.environment)}
appInstanceId={appInstanceId}
row={row}
releaseRows={releaseRows}
/>
))}
</div>
)}
</section>
)
}
const SKELETON_KEYS = ['a', 'b', 'c', 'd']
function CardSkeletons() {
return (
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,260px),1fr))] gap-3">
{SKELETON_KEYS.map(key => (
<EnvironmentTileSkeleton key={key} />
))}
</div>
)
}
function EnvironmentTileSkeleton() {
return (
<article
data-slot="deployment-overview-environment-tile-skeleton"
className="relative flex min-h-30 min-w-0 flex-col overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg p-3.5 shadow-xs"
>
<span aria-hidden className="absolute inset-y-0 left-0 w-1 bg-text-quaternary opacity-30" />
<div className="flex min-w-0 items-start justify-between gap-3 pl-1.5">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<SkeletonRectangle className="my-0 size-7 shrink-0 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-3.5 w-28 animate-pulse" />
</div>
<SkeletonRow className="my-0 h-5 shrink-0 gap-1.5 rounded-md bg-text-quaternary px-1.5 opacity-20">
<span className="size-1.5 shrink-0 rounded-full bg-text-quaternary" />
<span className="h-2.5 w-8 rounded-xs bg-text-quaternary" />
</SkeletonRow>
</div>
<div className="mt-5 flex min-w-0 items-end justify-between gap-3 pl-1.5">
<div className="min-w-0">
<SkeletonRectangle className="my-0 h-2.5 w-20 animate-pulse" />
<div className="mt-2 flex min-w-0 items-baseline gap-2">
<SkeletonRectangle className="my-0 h-5 w-18 animate-pulse" />
<SkeletonRectangle className="my-0 h-5 w-16 animate-pulse rounded-md" />
</div>
</div>
<SkeletonRectangle className="my-0 h-7 w-22 shrink-0 animate-pulse rounded-md" />
</div>
</article>
)
}
export function EnvironmentStripSkeleton() {
const { t } = useTranslation('deployments')
return (
<section className="flex flex-col gap-3">
<h3 className="system-sm-semibold text-text-primary">{t('overview.strip.title')}</h3>
<CardSkeletons />
</section>
)
}

View File

@ -0,0 +1,256 @@
'use client'
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { useRouter } from '@/next/navigation'
import { environmentId, environmentName } from '../../environment'
import { releaseCommit, releaseLabel } from '../../release'
import { deploymentStatus } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import { computeDrift, latestReleaseId } from './overview-drift'
type EnvironmentTileProps = {
appInstanceId: string
row: EnvironmentDeployment
releaseRows: Release[]
}
type TileKind = 'empty' | 'latest' | 'behind' | 'older' | 'deploying' | 'failed'
type TileConfig = {
kind: TileKind
accentClass: string
dotClass: string
badgeClass: string
iconClass: string
actionClass: string
showRelease: boolean
intent: 'drawer' | 'navigate' | 'disabled'
releaseId?: string
}
export function EnvironmentTile({ appInstanceId, row, releaseRows }: EnvironmentTileProps) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const router = useRouter()
const envId = environmentId(row.environment)
const drift = computeDrift(row, releaseRows)
const status = deploymentStatus(row)
const latestId = latestReleaseId(releaseRows)
const hasAnyRelease = releaseRows.length > 0
const currentReleaseId = row.currentRelease?.id
const config = resolveConfig({ drift, status, hasAnyRelease, latestId, currentReleaseId })
const isDisabled = config.intent === 'disabled'
const release = row.currentRelease
const showRelease = config.showRelease && Boolean(release?.id)
const commit = releaseCommit(release)
const tooltip = isDisabled
? t('overview.chip.needsReleaseFirst')
: config.intent === 'navigate'
? t('overview.chip.openInDeployTab')
: undefined
function handleAction() {
if (config.intent === 'disabled')
return
if (config.intent === 'navigate') {
router.push(`/deployments/${appInstanceId}/deploy`)
return
}
openDeployDrawer({ appInstanceId, environmentId: envId, releaseId: config.releaseId })
}
return (
<article
data-slot="deployment-overview-environment-tile"
className="group relative flex min-h-30 min-w-0 flex-col overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg p-3.5 shadow-xs transition-colors hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg-hover"
>
<span aria-hidden className={cn('absolute inset-y-0 left-0 w-1', config.accentClass)} />
<div className="flex min-w-0 items-start justify-between gap-3 pl-1.5">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<span aria-hidden className={cn('flex size-7 shrink-0 items-center justify-center rounded-lg', config.iconClass)}>
<span className="i-ri-server-line size-3.5" />
</span>
<div className="min-w-0 flex-1">
<h4 className="truncate system-sm-semibold text-text-primary">
{environmentName(row.environment)}
</h4>
</div>
</div>
<span className={cn('inline-flex h-5 shrink-0 items-center gap-1.5 rounded-md px-1.5 text-xs', config.badgeClass)}>
<span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', config.dotClass)} />
<span>{renderStatus(config.kind, drift, t)}</span>
</span>
</div>
<div className="mt-5 flex min-w-0 items-end justify-between gap-3 pl-1.5">
<div className="min-w-0">
<div className="system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary">
{t('deployTab.col.currentRelease')}
</div>
<div className="mt-1 flex min-w-0 items-baseline gap-2">
<span className="min-w-0 truncate title-md-semi-bold text-text-primary">
{showRelease ? releaseLabel(release) : '—'}
</span>
{showRelease && commit !== '—' && (
<span className="shrink-0 rounded-md bg-background-section-burn px-1.5 py-0.5 font-mono system-xs-regular text-text-tertiary">
{commit}
</span>
)}
</div>
</div>
<button
type="button"
disabled={isDisabled}
title={tooltip}
onClick={handleAction}
className={cn(
'inline-flex h-7 max-w-full min-w-0 items-center justify-center gap-1 rounded-md px-1.5 system-xs-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg',
config.actionClass,
isDisabled && 'cursor-not-allowed opacity-60',
)}
>
<span className="whitespace-nowrap">{renderActionLabel(config.kind, Boolean(currentReleaseId), t)}</span>
</button>
</div>
</article>
)
}
function resolveConfig({ drift, status, hasAnyRelease, latestId, currentReleaseId }: {
drift: ReturnType<typeof computeDrift>
status: ReturnType<typeof deploymentStatus>
hasAnyRelease: boolean
latestId: string | undefined
currentReleaseId: string | undefined
}): TileConfig {
if (status === 'deploying') {
return {
kind: 'deploying',
accentClass: 'bg-util-colors-blue-blue-500',
dotClass: 'bg-util-colors-blue-blue-500 animate-pulse',
badgeClass: 'bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700',
iconClass: 'bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700',
actionClass: 'text-text-secondary hover:bg-state-base-hover',
showRelease: true,
intent: 'navigate',
}
}
if (status === 'deploy_failed') {
return {
kind: 'failed',
accentClass: 'bg-util-colors-red-red-500',
dotClass: 'bg-util-colors-red-red-500',
badgeClass: 'bg-util-colors-red-red-50 text-util-colors-red-red-700',
iconClass: 'bg-util-colors-red-red-50 text-util-colors-red-red-700',
actionClass: 'text-primary-600 hover:bg-state-accent-hover',
showRelease: true,
intent: 'drawer',
releaseId: currentReleaseId ?? latestId,
}
}
if (drift.kind === 'undeployed') {
return {
kind: 'empty',
accentClass: 'bg-text-quaternary',
dotClass: 'bg-text-quaternary',
badgeClass: 'bg-background-section-burn text-text-tertiary',
iconClass: 'bg-background-section-burn text-text-tertiary',
actionClass: hasAnyRelease
? 'text-primary-600 hover:bg-state-accent-hover'
: 'text-text-tertiary',
showRelease: false,
intent: hasAnyRelease ? 'drawer' : 'disabled',
releaseId: latestId,
}
}
if (drift.kind === 'up-to-date') {
return {
kind: 'latest',
accentClass: 'bg-util-colors-green-green-500',
dotClass: 'bg-util-colors-green-green-500',
badgeClass: 'bg-util-colors-green-green-50 text-util-colors-green-green-700',
iconClass: 'bg-util-colors-green-green-50 text-util-colors-green-green-700',
actionClass: 'text-text-secondary hover:bg-state-base-hover',
showRelease: true,
intent: 'drawer',
releaseId: currentReleaseId,
}
}
if (drift.kind === 'behind') {
return {
kind: 'behind',
accentClass: 'bg-util-colors-warning-warning-500',
dotClass: 'bg-util-colors-warning-warning-500',
badgeClass: 'bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
iconClass: 'bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
actionClass: 'text-primary-600 hover:bg-state-accent-hover',
showRelease: true,
intent: 'drawer',
releaseId: latestId,
}
}
return {
kind: 'older',
accentClass: 'bg-text-tertiary',
dotClass: 'bg-text-tertiary',
badgeClass: 'bg-background-section-burn text-text-tertiary',
iconClass: 'bg-background-section-burn text-text-tertiary',
actionClass: 'text-primary-600 hover:bg-state-accent-hover',
showRelease: true,
intent: 'drawer',
releaseId: latestId,
}
}
function renderActionLabel(
kind: TileKind,
hasCurrentRelease: boolean,
t: ReturnType<typeof useTranslation<'deployments'>>['t'],
): string {
switch (kind) {
case 'empty':
case 'older':
case 'behind':
return t('overview.cardAction.deployLatest')
case 'latest':
return t('overview.cardAction.redeploy')
case 'deploying':
return t('overview.cardAction.viewProgress')
case 'failed':
return hasCurrentRelease
? t('overview.cardAction.redeploy')
: t('overview.cardAction.deployLatest')
}
}
function renderStatus(
kind: TileKind,
drift: ReturnType<typeof computeDrift>,
t: ReturnType<typeof useTranslation<'deployments'>>['t'],
): string {
switch (kind) {
case 'empty':
return t('overview.chip.empty')
case 'latest':
return t('overview.chip.latest')
case 'behind':
return t('overview.chip.behind', { count: drift.kind === 'behind' ? drift.steps : 0 })
case 'older':
return t('overview.chip.olderRelease')
case 'deploying':
return t('overview.chip.deploying')
case 'failed':
return t('overview.chip.failed')
}
}

View File

@ -0,0 +1,70 @@
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
export type Drift
= | { kind: 'undeployed' }
| { kind: 'unknown' }
| { kind: 'up-to-date' }
| { kind: 'behind', steps: number }
export function computeDrift(
row: EnvironmentDeployment,
releaseRows: Release[],
): Drift {
if (isUndeployedDeploymentRow(row))
return { kind: 'undeployed' }
const currentReleaseId = row.currentRelease?.id
if (!currentReleaseId)
return { kind: 'unknown' }
const idx = releaseRows.findIndex(release => release.id === currentReleaseId)
if (idx === -1)
return { kind: 'unknown' }
if (idx === 0)
return { kind: 'up-to-date' }
return { kind: 'behind', steps: idx }
}
export function latestReleaseId(releaseRows: Release[]): string | undefined {
return releaseRows[0]?.id || undefined
}
export type OverviewStats = {
total: number
ready: number
behind: number
failed: number
deploying: number
undeployed: number
}
export function computeOverviewStats(
rows: EnvironmentDeployment[],
releaseRows: Release[],
): OverviewStats {
const stats: OverviewStats = { total: rows.length, ready: 0, behind: 0, failed: 0, deploying: 0, undeployed: 0 }
for (const row of rows) {
const drift = computeDrift(row, releaseRows)
if (drift.kind === 'undeployed') {
stats.undeployed += 1
continue
}
const status = deploymentStatus(row)
if (status === 'deploy_failed') {
stats.failed += 1
continue
}
if (status === 'deploying') {
stats.deploying += 1
continue
}
if (drift.kind === 'behind') {
stats.behind += 1
continue
}
if (status === 'ready')
stats.ready += 1
}
return stats
}

View File

@ -0,0 +1,109 @@
'use client'
import type { Release } from '@dify/contracts/enterprise/types.gen'
import type { OverviewStats } from './overview-drift'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { formatDate, releaseLabel } from '../../release'
import { CreateReleaseControl } from '../versions-tab/create-release-control'
type ReleaseHeroProps = {
appInstanceId: string
latestRelease?: Release
stats: OverviewStats
}
export function ReleaseHero({ appInstanceId, latestRelease, stats }: ReleaseHeroProps) {
const { t } = useTranslation('deployments')
const { formatTimeFromNow } = useFormatTimeFromNow()
const hasRelease = Boolean(latestRelease?.id)
const author = latestRelease?.createdBy?.name ?? ''
const ago = latestRelease?.createdAt ? formatTimeFromNow(new Date(latestRelease.createdAt).getTime()) : ''
const metaParts: { key: string, value: string }[] = []
if (author)
metaParts.push({ key: 'author', value: t('overview.hero.byName', { name: author }) })
if (ago)
metaParts.push({ key: 'ago', value: ago })
if (hasRelease && stats.total === 0)
metaParts.push({ key: 'untargeted', value: t('overview.hero.untargeted') })
return (
<div className="overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg">
<div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
<div className="flex min-w-0 flex-col gap-2">
{hasRelease
? (
<>
<div className="flex min-w-0 items-center gap-3">
<span
aria-hidden
className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700"
>
<span className="i-ri-stack-fill size-5" />
</span>
<h2 className="truncate font-mono text-2xl font-semibold text-text-primary">
{releaseLabel(latestRelease)}
</h2>
</div>
{metaParts.length > 0 && (
<p
className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-1 system-sm-regular text-text-tertiary"
title={latestRelease?.createdAt ? formatDate(latestRelease.createdAt) : undefined}
>
{metaParts.map((part, index) => (
<span key={part.key} className="inline-flex items-baseline gap-1.5">
{index > 0 && <span aria-hidden className="text-text-quaternary">·</span>}
<span>{part.value}</span>
</span>
))}
</p>
)}
</>
)
: (
<>
<h2 className="system-xl-semibold text-text-primary">
{t('overview.hero.empty')}
</h2>
<p className="max-w-[640px] system-sm-regular text-text-tertiary">
{t('overview.hero.emptyDescription')}
</p>
</>
)}
</div>
<div className="shrink-0">
<CreateReleaseControl appInstanceId={appInstanceId} size="medium" />
</div>
</div>
</div>
)
}
export function ReleaseHeroSkeleton() {
return (
<div
data-slot="deployment-overview-release-hero-skeleton"
className="overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg"
>
<div className="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
<div className="flex min-w-0 flex-col gap-2">
<div className="flex min-w-0 items-center gap-3">
<SkeletonRectangle className="my-0 size-9 shrink-0 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-7 w-40 animate-pulse" />
</div>
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-1">
<SkeletonRectangle className="my-0 h-3 w-32 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-14 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-28 animate-pulse" />
</div>
</div>
<div className="shrink-0">
<SkeletonRectangle className="my-0 h-9 w-32 animate-pulse rounded-lg" />
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,393 @@
'use client'
import type { AppInstance } from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import Textarea from '@/app/components/base/textarea'
import { useRouter } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { isUndeployedDeploymentRow } from '../runtime-status'
import { Section, SectionState } from './common'
type AppInstanceWithId = AppInstance & { id: string }
const SETTINGS_FORM_SKELETON_FIELDS = [
{ key: 'name', inputClassName: 'my-0 h-8 w-full animate-pulse rounded-lg' },
{ key: 'description', inputClassName: 'my-0 h-24 w-full animate-pulse rounded-lg' },
]
function SettingsFormSkeleton() {
return (
<div className="flex flex-col gap-3">
{SETTINGS_FORM_SKELETON_FIELDS.map(field => (
<div key={field.key} className="flex flex-col gap-2">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRectangle className={field.inputClassName} />
</div>
))}
<SkeletonRow className="justify-end gap-2">
<SkeletonRectangle className="my-0 h-8 w-18 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-8 w-16 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
)
}
function DeleteInstanceSkeleton() {
return (
<div className="flex min-h-9 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<SkeletonRectangle className="h-3 w-3/5 animate-pulse" />
<SkeletonRow className="items-center justify-between gap-2">
<SkeletonRectangle className="h-3 w-48 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-18 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
)
}
type DeleteInstanceControlProps = {
app: AppInstanceWithId
hasDeployments: boolean
}
function DeleteInstanceButton({
app,
hasDeployments,
}: DeleteInstanceControlProps) {
const { t } = useTranslation('deployments')
const router = useRouter()
const deleteInstance = useMutation(consoleQuery.enterprise.appInstanceService.deleteAppInstance.mutationOptions())
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const appInstanceId = app.id
const appName = app.name ?? appInstanceId
const canDelete = !hasDeployments
const handleDelete = () => {
deleteInstance.mutate(
{
params: {
appInstanceId,
},
},
{
onSuccess: () => {
toast.success(t('settings.deleted'))
router.push('/deployments')
},
onError: () => {
toast.error(t('settings.deleteFailed'))
},
onSettled: () => {
setShowDeleteConfirm(false)
},
},
)
}
return (
<>
<Button
variant="primary"
tone="destructive"
disabled={!canDelete || deleteInstance.isPending}
onClick={() => setShowDeleteConfirm(true)}
>
{t('settings.delete')}
</Button>
<AlertDialog open={showDeleteConfirm} onOpenChange={open => !open && setShowDeleteConfirm(false)}>
<AlertDialogContent className="w-130">
<div className="flex flex-col gap-3 px-6 pt-6 pb-2">
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
{t('settings.deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="system-md-regular text-text-tertiary">
{t('settings.deleteConfirmDesc', { name: appName })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton variant="secondary">
{t('createModal.cancel')}
</AlertDialogCancelButton>
<AlertDialogConfirmButton onClick={handleDelete}>
{t('settings.delete')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
function DeleteInstanceControl({
app,
hasDeployments,
}: DeleteInstanceControlProps) {
const { t } = useTranslation('deployments')
return (
<div className="flex min-h-9 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="system-xs-regular text-text-secondary">
{hasDeployments
? t('settings.undeployFirst')
: t('settings.safeToDelete')}
</div>
<DeleteInstanceButton
app={app}
hasDeployments={hasDeployments}
/>
</div>
)
}
function DangerSection({ children }: {
children: ReactNode
}) {
const { t } = useTranslation('deployments')
return (
<section className="border-b border-divider-subtle py-4 first:pt-0 last:border-b-0 last:pb-0">
<div className="flex flex-col gap-3 sm:flex-row sm:gap-x-6">
<div className="flex min-w-0 shrink-0 flex-col sm:w-40 sm:pt-1">
<div className="system-sm-semibold text-util-colors-red-red-700">
{t('settings.danger')}
</div>
<p className="mt-1 body-xs-regular text-util-colors-red-red-600">
{t('settings.dangerDesc')}
</p>
</div>
<div className="min-w-0 grow">
{children}
</div>
</div>
</section>
)
}
function SettingsForm({ app }: {
app: AppInstanceWithId
}) {
const { t } = useTranslation('deployments')
const updateInstance = useMutation(consoleQuery.enterprise.appInstanceService.updateAppInstance.mutationOptions())
const appName = app.name ?? app.id
const [name, setName] = useState(appName)
const [description, setDescription] = useState(app.description ?? '')
const initialName = appName
const initialDescription = app.description ?? ''
const canSave = Boolean(name.trim() && (name !== initialName || description !== initialDescription) && !updateInstance.isPending)
const handleSave = () => {
const appInstanceId = app.id
if (!canSave)
return
updateInstance.mutate(
{
params: {
appInstanceId,
},
body: {
appInstanceId,
name: name.trim(),
description: description.trim() || undefined,
},
},
{
onSuccess: () => {
toast.success(t('settings.updated'))
},
onError: () => {
toast.error(t('settings.updateFailed'))
},
},
)
}
return (
<Section
title={t('settings.general')}
description={t('settings.descriptionHelp')}
layout="row"
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="settings-name">
{t('settings.name')}
</label>
<Input
id="settings-name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="h-8"
/>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="settings-desc">
{t('settings.description')}
</label>
<Textarea
id="settings-desc"
value={description}
onChange={e => setDescription(e.target.value)}
className="min-h-24"
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
disabled={updateInstance.isPending || (name === initialName && description === initialDescription)}
onClick={() => {
setName(initialName)
setDescription(initialDescription)
}}
>
{t('settings.reset')}
</Button>
<Button variant="primary" disabled={!canSave} onClick={handleSave}>
{t('settings.save')}
</Button>
</div>
</div>
</Section>
)
}
function SettingsFormSection({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const appInput = { params: { appInstanceId } }
const instanceQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: appInput,
}))
const app = instanceQuery.data?.appInstance
if (instanceQuery.isLoading) {
return (
<Section
title={t('settings.general')}
description={t('settings.descriptionHelp')}
layout="row"
>
<SettingsFormSkeleton />
</Section>
)
}
if (instanceQuery.isError) {
return (
<Section
title={t('settings.general')}
description={t('settings.descriptionHelp')}
layout="row"
>
<SectionState>{t('common.loadFailed')}</SectionState>
</Section>
)
}
if (!app?.id) {
return (
<Section
title={t('settings.general')}
description={t('settings.descriptionHelp')}
layout="row"
>
<SectionState>{t('detail.notFound')}</SectionState>
</Section>
)
}
const appName = app.name ?? app.id
const formKey = `${app.id}-${appName}-${app.description ?? ''}`
const appWithId = {
...app,
id: app.id,
}
return (
<SettingsForm
key={formKey}
app={appWithId}
/>
)
}
function DeleteInstanceControlSection({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const appInput = { params: { appInstanceId } }
const instanceQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: appInput,
}))
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: appInput,
}))
const environmentDeployments = environmentDeploymentsQuery.data
const app = instanceQuery.data?.appInstance
if (instanceQuery.isLoading || environmentDeploymentsQuery.isLoading) {
return (
<DangerSection>
<DeleteInstanceSkeleton />
</DangerSection>
)
}
if (instanceQuery.isError || environmentDeploymentsQuery.isError) {
return (
<DangerSection>
<SectionState>{t('common.loadFailed')}</SectionState>
</DangerSection>
)
}
if (!app?.id) {
return (
<DangerSection>
<SectionState>{t('detail.notFound')}</SectionState>
</DangerSection>
)
}
const hasDeployments = environmentDeployments?.data?.some(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? false
const appWithId = {
...app,
id: app.id,
}
return (
<DangerSection>
<DeleteInstanceControl
app={appWithId}
hasDeployments={hasDeployments}
/>
</DangerSection>
)
}
export function SettingsTab({ appInstanceId }: {
appInstanceId: string
}) {
return (
<div className="flex w-full min-w-0 flex-col gap-y-4 px-6 py-6 sm:py-8">
<SettingsFormSection appInstanceId={appInstanceId} />
<DeleteInstanceControlSection appInstanceId={appInstanceId} />
</div>
)
}

View File

@ -0,0 +1,124 @@
import type { ApiKey } from '@dify/contracts/enterprise/types.gen'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiKeyList } from '../api-keys'
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
accessService: {
deleteApiKey: {
mutationOptions: () => ({ mutationFn: vi.fn() }),
},
},
},
},
}))
function apiKey(overrides: Partial<ApiKey> = {}): ApiKey {
return {
id: 'key-1',
name: 'production-key-001',
environmentId: 'env-1',
maskedToken: 'app-****-abcd',
...overrides,
}
}
function renderApiKeyList(apiKeys: ApiKey[]) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
return render(
<QueryClientProvider client={queryClient}>
<ApiKeyList
apiKeys={apiKeys}
environments={[{ id: 'env-1', name: 'production' }]}
/>
</QueryClientProvider>,
)
}
describe('ApiKeyList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// API keys should match the shared semantic table shell used by deployment detail tabs.
describe('Rendering', () => {
it('should render API keys with the shared detail table design', () => {
// Arrange & Act
const { container } = renderApiKeyList([apiKey()])
// Assert
const desktopWrapper = container.querySelector('.hidden.pc\\:block')
const tableContainer = desktopWrapper?.querySelector('[data-slot="deployment-detail-table-container"]')
const tableShell = desktopWrapper?.querySelector('[data-slot="deployment-detail-table"]')
const header = tableShell?.querySelector('[data-slot="deployment-detail-table-header"]')
const body = tableShell?.querySelector('[data-slot="deployment-detail-table-body"]')
const row = body?.querySelector('[data-slot="deployment-detail-table-row"]')
const head = header?.querySelector('[data-slot="deployment-detail-table-head"]')
const cell = row?.querySelector('[data-slot="deployment-detail-table-cell"]')
expect(tableContainer).toHaveClass(
'relative',
'w-full',
'overflow-x-auto',
)
expect(tableShell?.tagName).toBe('TABLE')
expect(header?.tagName).toBe('THEAD')
expect(body?.tagName).toBe('TBODY')
expect(row?.tagName).toBe('TR')
expect(head?.tagName).toBe('TH')
expect(cell?.tagName).toBe('TD')
expect(tableShell).toHaveClass(
'w-full',
'max-w-full',
'min-w-[700px]',
'border-collapse',
'border-0',
'caption-bottom',
'text-sm',
)
expect(header).toHaveClass(
'h-8',
'border-b',
'border-divider-subtle',
'text-xs/8',
'font-medium',
'text-text-tertiary',
'uppercase',
)
expect(head).toHaveClass(
'max-w-[200px]',
'px-2.5',
'py-0',
'pl-3',
'font-medium',
'whitespace-nowrap',
'text-text-tertiary',
)
expect(body).toHaveClass('text-text-secondary')
expect(row).toHaveClass(
'h-8',
'border-b',
'border-divider-subtle',
'hover:bg-background-default-hover',
)
expect(cell).toHaveClass(
'max-w-[200px]',
'px-2.5',
'py-[5px]',
'pl-3',
'align-middle',
)
expect(row?.querySelector('[data-slot="deployment-detail-table-row-content"]')).toBeNull()
expect(screen.getAllByLabelText('deployments.access.revoke')).toHaveLength(2)
})
})
})

View File

@ -0,0 +1,83 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DeveloperApiSection } from '../developer-api-section'
type QueryOptions = {
queryKey?: string[]
}
type QueryResult = {
data?: unknown
isLoading: boolean
isError: boolean
}
const mockUseQuery = vi.fn<(options: QueryOptions) => QueryResult>()
vi.mock('@tanstack/react-query', () => ({
useMutation: () => ({ mutate: vi.fn() }),
useQueries: () => [],
useQuery: (options: QueryOptions) => mockUseQuery(options),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
accessService: {
createApiKey: {
mutationOptions: () => ({ mutationFn: vi.fn() }),
},
deleteApiKey: {
mutationOptions: () => ({ mutationFn: vi.fn() }),
},
getAccessChannels: {
queryOptions: () => ({ queryKey: ['access-channels'] }),
},
updateAccessChannels: {
mutationOptions: () => ({ mutationFn: vi.fn() }),
},
},
deploymentService: {
listEnvironmentDeployments: {
queryOptions: () => ({ queryKey: ['environment-deployments'] }),
},
},
},
},
}))
function queryResult(overrides: Partial<QueryResult> = {}): QueryResult {
return {
data: undefined,
isLoading: false,
isError: false,
...overrides,
}
}
describe('DeveloperApiSection', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Loading should reserve the same shape as the enabled API page: endpoint copy row plus API key table.
describe('Loading state', () => {
it('should render the updated API tab skeleton while access config is loading', () => {
// Arrange
mockUseQuery.mockReturnValue(queryResult({ isLoading: true }))
// Act
const { container } = render(<DeveloperApiSection appInstanceId="instance-1" />)
// Assert
expect(screen.getByText('deployments.access.api.endpoint')).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: 'deployments.access.api.table.name' })).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: 'deployments.access.api.table.environment' })).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: 'deployments.access.api.table.key' })).toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: 'deployments.access.api.table.action' })).toBeInTheDocument()
expect(container.querySelectorAll('[data-slot="deployment-developer-api-skeleton"]')).toHaveLength(1)
expect(container.querySelectorAll('[data-slot="deployment-developer-api-desktop-row-skeleton"]')).toHaveLength(2)
expect(container.querySelectorAll('[data-slot="deployment-developer-api-mobile-row-skeleton"]')).toHaveLength(2)
})
})
})

View File

@ -0,0 +1,287 @@
'use client'
import type {
ApiKey,
Environment,
} from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { environmentName } from '../../../environment'
import {
DetailTable,
DetailTableBody,
DetailTableCard,
DetailTableCardList,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../../table'
import {
API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from '../../table-styles'
function ApiKeyName({ apiKey }: {
apiKey: ApiKey
}) {
return (
<span className="block truncate text-text-primary">
{apiKey.name || apiKey.id || '—'}
</span>
)
}
function EnvironmentBadge({ environment }: {
environment?: Environment
}) {
return (
<span className="inline-flex h-5 max-w-36 items-center rounded-md bg-background-section-burn px-1.5 text-xs text-text-tertiary">
<span className="truncate">{environmentName(environment)}</span>
</span>
)
}
function ApiKeyValue({ value }: {
value: string
}) {
return (
<div className="flex h-8 min-w-0 items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-2">
<div className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary">
{value}
</div>
</div>
)
}
function RevokeApiKeyButton({ apiKey }: {
apiKey: ApiKey
}) {
const { t } = useTranslation('deployments')
const revokeApiKey = useMutation(consoleQuery.enterprise.accessService.deleteApiKey.mutationOptions())
function handleRevoke() {
if (!apiKey.id)
return
revokeApiKey.mutate({
params: {
apiKeyId: apiKey.id,
},
})
}
return (
<button
type="button"
onClick={handleRevoke}
aria-label={t('access.revoke')}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<span className="i-ri-delete-bin-line size-3.5" />
</button>
)
}
function ApiKeyMobileRow({ apiKey, environment }: {
apiKey: ApiKey
environment?: Environment
}) {
const { t } = useTranslation('deployments')
const displayValue = apiKey.maskedToken || apiKey.id || '—'
return (
<DetailTableCard>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<ApiKeyName apiKey={apiKey} />
<div className="mt-1">
<EnvironmentBadge environment={environment} />
</div>
</div>
<RevokeApiKeyButton apiKey={apiKey} />
</div>
<div className="flex min-w-0 flex-col gap-1">
<span className="system-2xs-medium-uppercase text-text-tertiary">
{t('access.api.table.key')}
</span>
<ApiKeyValue value={displayValue} />
</div>
</div>
</DetailTableCard>
)
}
function ApiKeyDesktopRow({ apiKey, environment }: {
apiKey: ApiKey
environment?: Environment
}) {
const displayValue = apiKey.maskedToken || apiKey.id || '—'
return (
<DetailTableRow>
<DetailTableCell>
<ApiKeyName apiKey={apiKey} />
</DetailTableCell>
<DetailTableCell>
<EnvironmentBadge environment={environment} />
</DetailTableCell>
<DetailTableCell>
<ApiKeyValue value={displayValue} />
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<RevokeApiKeyButton apiKey={apiKey} />
</div>
</DetailTableCell>
</DetailTableRow>
)
}
function ApiKeyTableHeader() {
const { t } = useTranslation('deployments')
return (
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.name}>{t('access.api.table.name')}</DetailTableHead>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('access.api.table.environment')}</DetailTableHead>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.key}>{t('access.api.table.key')}</DetailTableHead>
<DetailTableHead className={`${API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t('access.api.table.action')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
)
}
function ApiKeyTable({ apiKeys, environments }: {
apiKeys: ApiKey[]
environments: Environment[]
}) {
const environmentById = new Map(environments.map(environment => [environment.id, environment]))
return (
<>
<DetailTableCardList className={cn('pc:hidden')}>
{apiKeys.map((apiKey, index) => (
<ApiKeyMobileRow
key={apiKey.id ?? apiKey.maskedToken ?? apiKey.name ?? index}
apiKey={apiKey}
environment={apiKey.environmentId ? environmentById.get(apiKey.environmentId) : undefined}
/>
))}
</DetailTableCardList>
<div className="hidden pc:block">
<DetailTable>
<ApiKeyTableHeader />
<DetailTableBody>
{apiKeys.map((apiKey, index) => (
<ApiKeyDesktopRow
key={apiKey.id ?? apiKey.maskedToken ?? apiKey.name ?? index}
apiKey={apiKey}
environment={apiKey.environmentId ? environmentById.get(apiKey.environmentId) : undefined}
/>
))}
</DetailTableBody>
</DetailTable>
</div>
</>
)
}
export function ApiKeyList({ apiKeys, environments }: {
apiKeys: ApiKey[]
environments: Environment[]
}) {
return (
<ApiKeyTable apiKeys={apiKeys} environments={environments} />
)
}
export function ApiKeyGenerateMenu({ appInstanceId, environments, apiKeys, onCreatedToken }: {
appInstanceId: string
environments: Environment[]
apiKeys: ApiKey[]
onCreatedToken: (token: string) => void
}) {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const generateApiKey = useMutation(consoleQuery.enterprise.accessService.createApiKey.mutationOptions())
const selectableEnvironments = environments.filter(env => env.id)
const disabled = selectableEnvironments.length === 0
function createApiKeyLabel(environmentId: string) {
const existingCount = apiKeys.filter(key =>
key.environmentId === environmentId,
).length
const name = environments.find(env => env.id === environmentId)?.name ?? 'env'
return `${name}-key-${String(existingCount + 1).padStart(3, '0')}`
}
function handleGenerateApiKey(environmentId: string) {
generateApiKey.mutate(
{
params: {
appInstanceId,
environmentId,
},
body: {
appInstanceId,
environmentId,
name: createApiKeyLabel(environmentId),
},
},
{
onSuccess: (response) => {
if (response.token)
onCreatedToken(response.token)
},
},
)
}
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
className={cn(
'inline-flex h-8 items-center gap-1.5 rounded-lg px-3 system-sm-medium',
'border border-components-button-secondary-border bg-components-button-secondary-bg text-components-button-secondary-text',
'hover:bg-components-button-secondary-bg-hover',
disabled && 'cursor-not-allowed opacity-50',
)}
>
<span className="i-ri-add-line size-3.5" />
{t('access.api.newKey')}
<span className="i-ri-arrow-down-s-line size-3.5" />
</DropdownMenuTrigger>
{open && !disabled && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-55">
{selectableEnvironments.map(env => (
<DropdownMenuItem
key={env.id}
className="gap-2 px-3"
onClick={() => {
setOpen(false)
handleGenerateApiKey(env.id!)
}}
>
<span className="system-sm-regular text-text-secondary">
{t('access.api.newKeyForEnv', { env: environmentName(env) })}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
)}
</DropdownMenu>
)
}

View File

@ -0,0 +1,223 @@
'use client'
import type { Environment } from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { environmentName } from '../../../environment'
import { webappUrl } from '../../../webapp-url'
import { Section, SectionState } from '../../common'
import { CopyPill, EndpointRow } from './common'
import { getUrlOrigin } from './url'
const ACCESS_CHANNEL_SKELETON_SECTIONS = [
{ key: 'webapp' },
{ key: 'cli' },
]
function AccessChannelsSwitch({ appInstanceId, checked, disabled }: {
appInstanceId: string
checked: boolean
disabled?: boolean
}) {
const toggleAccessChannel = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
return (
<Switch
checked={checked}
disabled={disabled}
onCheckedChange={(enabled) => {
toggleAccessChannel.mutate({
params: { appInstanceId },
body: { appInstanceId, webAppEnabled: enabled },
})
}}
/>
)
}
function AccessChannelsSkeleton() {
return (
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
{ACCESS_CHANNEL_SKELETON_SECTIONS.map(section => (
<SkeletonRow
key={section.key}
className="grid gap-3 border-t border-divider-subtle px-4 py-4 first:border-t-0 lg:grid-cols-[minmax(220px,280px)_minmax(0,1fr)]"
>
<div className="flex flex-col gap-1.5">
<SkeletonRectangle className="h-3.5 w-24 animate-pulse" />
<SkeletonRectangle className="h-3 w-40 animate-pulse" />
</div>
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</SkeletonRow>
))}
</div>
)
}
function ChannelStatusBadge() {
const { t } = useTranslation('deployments')
return (
<span className="inline-flex h-5 w-fit max-w-full items-center rounded-md bg-state-success-hover px-1.5 system-2xs-medium text-state-success-solid">
<span className="truncate">{t('access.channels.followPermission')}</span>
</span>
)
}
function ChannelInfo({ icon, title, description, status }: {
icon: ReactNode
title: string
description: string
status: ReactNode
}) {
return (
<div className="flex min-w-0 items-start gap-2.5">
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary">
{icon}
</span>
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="system-sm-medium text-text-primary">{title}</span>
{status}
</div>
<div className="system-xs-regular text-text-tertiary">{description}</div>
</div>
</div>
)
}
export function AccessChannelsSection({
appInstanceId,
}: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const accessChannelsQuery = useQuery(consoleQuery.enterprise.accessService.getAccessChannels.queryOptions({
input: {
params: { appInstanceId },
},
}))
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
}))
const accessChannels = accessChannelsQuery.data?.accessChannels
const runEnabled = accessChannels?.webAppEnabled ?? false
const webappRows = environmentDeploymentsQuery.data?.data
?.map(row => row.environment)
.filter((environment): environment is Environment & { id: string, runtimeEndpoint: string } => Boolean(environment?.id && environment.runtimeEndpoint)) ?? []
const cliDomain = getUrlOrigin(webappRows[0]?.runtimeEndpoint)
const cliDocsUrl = cliDomain ? `${cliDomain}/cli` : undefined
const isLoading = accessChannelsQuery.isLoading || (runEnabled && environmentDeploymentsQuery.isLoading)
const isError = accessChannelsQuery.isError || (runEnabled && environmentDeploymentsQuery.isError)
return (
<Section
title={t('access.channels.title')}
description={t('access.channels.description')}
action={(
isLoading
? <SwitchSkeleton />
: (
<AccessChannelsSwitch
appInstanceId={appInstanceId}
checked={runEnabled}
disabled={isError}
/>
)
)}
>
{isLoading
? <AccessChannelsSkeleton />
: isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: runEnabled
? (
<div className="overflow-hidden rounded-lg border border-divider-subtle bg-components-panel-bg">
<div className="grid gap-3 px-4 py-4 lg:grid-cols-[minmax(220px,280px)_minmax(0,1fr)]">
<ChannelInfo
icon={<span className="i-ri-global-line size-3.5" aria-hidden="true" />}
title={t('access.runAccess.webapp')}
description={t('access.runAccess.webappDesc')}
status={<ChannelStatusBadge />}
/>
{webappRows.length > 0
? (
<div className="flex flex-col gap-1.5">
{webappRows.map((environment) => {
const endpointUrl = webappUrl(environment.runtimeEndpoint ?? '')
return (
<EndpointRow
key={`webapp-${environment.id ?? environment.runtimeEndpoint}`}
envName={environmentName(environment)}
label={t('access.runAccess.urlLabel')}
value={endpointUrl}
openLabel={t('access.runAccess.openWebapp')}
/>
)
})}
</div>
)
: (
<SectionState>
{t('access.runAccess.webappEmpty')}
</SectionState>
)}
</div>
<div className="grid gap-3 border-t border-divider-subtle px-4 py-4 lg:grid-cols-[minmax(220px,280px)_minmax(0,1fr)]">
<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')}
status={<ChannelStatusBadge />}
/>
{cliDomain
? (
<div className="flex flex-wrap items-center gap-2">
<CopyPill
label={t('access.cli.domain')}
value={cliDomain}
className="min-w-0 flex-1"
/>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-download-cloud-2-line size-3.5" />
{t('access.cli.install')}
</a>
<a
href={cliDocsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-book-open-line size-3.5" />
{t('access.cli.docs')}
</a>
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.cli.empty')}
</div>
)}
</div>
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.channels.disabled')}
</div>
)}
</Section>
)
}

View File

@ -0,0 +1,78 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { useTranslation } from 'react-i18next'
import { useClipboard } from '@/hooks/use-clipboard'
type CopyPillProps = {
label: string
value: string
prefix?: ReactNode
className?: string
}
export function CopyPill({ label, value, prefix, className }: CopyPillProps) {
const { t } = useTranslation('deployments')
const { copied, copy } = useClipboard({
onCopyError: () => {
toast.error(t('access.copyFailed'))
},
})
return (
<div
className={cn(
'flex h-8 items-center gap-1 rounded-lg border border-components-input-border-active bg-components-input-bg-normal pr-1 pl-1.5',
className,
)}
>
<div className="flex h-5 shrink-0 items-center rounded-md border border-divider-subtle px-1.5 system-2xs-medium text-text-tertiary">
{label}
</div>
{prefix}
<div className="min-w-0 flex-1 truncate px-1 font-mono system-sm-medium text-text-secondary">
{value}
</div>
<div className="h-3.5 w-px shrink-0 bg-divider-regular" />
<button
type="button"
onClick={() => copy(value)}
aria-label={t('access.copy')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span className={cn(copied ? 'i-ri-check-line' : 'i-ri-file-copy-line', 'size-3.5')} />
</button>
</div>
)
}
type EndpointRowProps = {
envName: string
label: string
value: string
openLabel?: string
}
export function EndpointRow({ envName, label, value, openLabel }: EndpointRowProps) {
return (
<div className="grid items-center gap-x-3 gap-y-1.5 sm:grid-cols-[minmax(88px,108px)_minmax(0,1fr)_auto]">
<span className="min-w-0 truncate system-xs-regular text-text-tertiary">
{envName}
</span>
<CopyPill label={label} value={value} className="min-w-0" />
{openLabel && (
<a
href={value}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-components-button-secondary-border bg-components-button-secondary-bg px-3 system-sm-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover"
>
<span className="i-ri-external-link-line size-3.5" />
{openLabel}
</a>
)}
</div>
)
}

View File

@ -0,0 +1,328 @@
'use client'
import type {
ApiKey,
Environment,
} from '@dify/contracts/enterprise/types.gen'
import { Switch, SwitchSkeleton } from '@langgenius/dify-ui/switch'
import { useMutation, useQueries, useQuery } from '@tanstack/react-query'
import { atom, useAtom, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { SectionState } from '../../common'
import {
DetailTable,
DetailTableBody,
DetailTableCard,
DetailTableCardList,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../../table'
import { API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES } from '../../table-styles'
import { ApiKeyGenerateMenu, ApiKeyList } from './api-keys'
import { CopyPill } from './common'
type CreatedApiToken = {
appInstanceId: string
token: string
}
const createdApiTokenAtom = atom<CreatedApiToken | undefined>(undefined)
const DEVELOPER_API_KEY_SKELETON_KEYS = ['primary-key', 'secondary-key']
function deploymentEnvironment(row: { environment?: Environment }): Environment | undefined {
return row.environment?.id ? row.environment : undefined
}
function useDeveloperApiResources(appInstanceId: string) {
const accessChannelsQuery = useQuery(consoleQuery.enterprise.accessService.getAccessChannels.queryOptions({
input: {
params: { appInstanceId },
},
}))
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
}))
const apiEnabled = accessChannelsQuery.data?.accessChannels?.developerApiEnabled ?? false
const environments = environmentDeploymentsQuery.data?.data
?.map(deploymentEnvironment)
.filter((environment): environment is Environment & { id: string } => Boolean(environment)) ?? []
const apiKeyQueries = useQueries({
queries: environments.map(environment => consoleQuery.enterprise.accessService.listApiKeys.queryOptions({
input: {
params: {
appInstanceId,
environmentId: environment.id,
},
},
enabled: Boolean(apiEnabled),
})),
})
const apiKeys: ApiKey[] = apiKeyQueries.flatMap(query => query.data?.data ?? [])
const apiKeysLoading = apiKeyQueries.some(query => query.isLoading)
const apiKeysError = apiKeyQueries.some(query => query.isError)
return {
apiEnabled,
environments,
apiKeys,
isLoading: accessChannelsQuery.isLoading || environmentDeploymentsQuery.isLoading || (apiEnabled && apiKeysLoading),
isError: accessChannelsQuery.isError || environmentDeploymentsQuery.isError || (apiEnabled && apiKeysError),
}
}
function DeveloperApiSwitch({ appInstanceId, checked, disabled }: {
appInstanceId: string
checked: boolean
disabled?: boolean
}) {
const toggleDeveloperAPI = useMutation(consoleQuery.enterprise.accessService.updateAccessChannels.mutationOptions())
return (
<Switch
checked={checked}
disabled={disabled}
onCheckedChange={(enabled) => {
toggleDeveloperAPI.mutate({
params: { appInstanceId },
body: { appInstanceId, developerApiEnabled: enabled },
})
}}
/>
)
}
export function DeveloperApiHeaderActions({ appInstanceId }: {
appInstanceId: string
}) {
const setCreatedApiToken = useSetAtom(createdApiTokenAtom)
const {
apiEnabled,
apiKeys,
environments,
isLoading,
isError,
} = useDeveloperApiResources(appInstanceId)
if (isLoading) {
return (
<div className="flex items-center gap-2">
<SkeletonRectangle className="my-0 h-8 w-32 animate-pulse rounded-lg" />
<SwitchSkeleton />
</div>
)
}
return (
<div className="flex items-center gap-2">
{apiEnabled && (
<ApiKeyGenerateMenu
appInstanceId={appInstanceId}
environments={environments}
apiKeys={apiKeys}
onCreatedToken={token => setCreatedApiToken({ appInstanceId, token })}
/>
)}
<DeveloperApiSwitch
appInstanceId={appInstanceId}
checked={apiEnabled}
disabled={isError}
/>
</div>
)
}
function CreatedApiTokenCard({ token, onDismiss }: {
token: string
onDismiss: () => void
}) {
const { t } = useTranslation('deployments')
return (
<div className="flex flex-col gap-2 border-y border-divider-subtle bg-background-default-subtle px-3 py-2.5">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col">
<span className="system-sm-medium text-text-primary">
{t('access.api.newTokenTitle')}
</span>
<span className="system-xs-regular text-text-tertiary">
{t('access.api.newTokenDescription')}
</span>
</div>
<button
type="button"
onClick={onDismiss}
aria-label={t('access.api.dismissToken')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span className="i-ri-close-line size-3.5" />
</button>
</div>
<CopyPill
label={t('access.api.newTokenLabel')}
value={token}
/>
</div>
)
}
function DeveloperApiSkeleton() {
const { t } = useTranslation('deployments')
return (
<div className="flex flex-col gap-4" data-slot="deployment-developer-api-skeleton">
<div className="flex h-8 items-center gap-1 rounded-lg border border-components-input-border-active bg-components-input-bg-normal pr-1 pl-1.5">
<div className="flex h-5 shrink-0 items-center rounded-md border border-divider-subtle px-1.5 system-2xs-medium text-text-tertiary">
{t('access.api.endpoint')}
</div>
<SkeletonRectangle className="my-0 h-3 min-w-0 flex-1 animate-pulse" />
<div className="h-3.5 w-px shrink-0 bg-divider-regular" />
<SkeletonRectangle className="my-0 size-6 shrink-0 animate-pulse rounded-md" />
</div>
<ApiKeyTableSkeleton />
</div>
)
}
function ApiKeyTableSkeleton() {
return (
<>
<DetailTableCardList className="pc:hidden">
{DEVELOPER_API_KEY_SKELETON_KEYS.map(key => (
<DetailTableCard key={key} data-slot="deployment-developer-api-mobile-row-skeleton">
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<SkeletonRectangle className="my-0 h-3.5 w-32 animate-pulse" />
<SkeletonRectangle className="mt-2 h-5 w-20 animate-pulse rounded-md" />
</div>
<SkeletonRectangle className="my-0 size-8 shrink-0 animate-pulse rounded-md" />
</div>
<div className="flex min-w-0 flex-col gap-1">
<SkeletonRectangle className="my-0 h-2.5 w-14 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</div>
</div>
</DetailTableCard>
))}
</DetailTableCardList>
<div className="hidden pc:block">
<DetailTable>
<ApiKeyTableHeaderSkeleton />
<DetailTableBody>
{DEVELOPER_API_KEY_SKELETON_KEYS.map(key => (
<ApiKeyDesktopRowSkeleton key={key} />
))}
</DetailTableBody>
</DetailTable>
</div>
</>
)
}
function ApiKeyTableHeaderSkeleton() {
const { t } = useTranslation('deployments')
return (
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.name}>{t('access.api.table.name')}</DetailTableHead>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('access.api.table.environment')}</DetailTableHead>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.key}>{t('access.api.table.key')}</DetailTableHead>
<DetailTableHead className={`${API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t('access.api.table.action')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
)
}
function ApiKeyDesktopRowSkeleton() {
return (
<DetailTableRow data-slot="deployment-developer-api-desktop-row-skeleton">
<DetailTableCell>
<SkeletonRectangle className="my-0 h-3.5 w-32 animate-pulse" />
</DetailTableCell>
<DetailTableCell>
<SkeletonRectangle className="my-0 h-5 w-20 animate-pulse rounded-md" />
</DetailTableCell>
<DetailTableCell>
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</DetailTableCell>
</DetailTableRow>
)
}
export function DeveloperApiSection({
appInstanceId,
}: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const [createdApiToken, setCreatedApiToken] = useAtom(createdApiTokenAtom)
const {
apiEnabled,
apiKeys,
environments,
isLoading,
isError,
} = useDeveloperApiResources(appInstanceId)
const apiUrl = environments.find(environment => environment.runtimeEndpoint)?.runtimeEndpoint
const visibleCreatedApiToken = createdApiToken?.appInstanceId === appInstanceId
? createdApiToken.token
: undefined
return (
<>
{isLoading
? <DeveloperApiSkeleton />
: isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: apiEnabled
? (
<div className="flex flex-col gap-4">
{apiUrl && (
<CopyPill
label={t('access.api.endpoint')}
value={apiUrl}
/>
)}
{visibleCreatedApiToken && (
<CreatedApiTokenCard
token={visibleCreatedApiToken}
onDismiss={() => setCreatedApiToken(undefined)}
/>
)}
{apiKeys.length === 0
? (
<SectionState>
{environments.length === 0
? t('access.api.empty')
: t('access.api.noKeys')}
</SectionState>
)
: (
<ApiKeyList
apiKeys={apiKeys}
environments={environments}
/>
)}
</div>
)
: (
<div className="system-xs-regular text-text-tertiary">
{t('access.api.disabled')}
</div>
)}
</>
)
}

View File

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

View File

@ -0,0 +1,497 @@
'use client'
import type {
AccessPolicy,
AccessSubject,
Environment,
Subject,
} from '@dify/contracts/enterprise/types.gen'
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxChip,
ComboboxChipRemove,
ComboboxChips,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxInputTrigger,
ComboboxItem,
ComboboxItemIndicator,
ComboboxItemText,
ComboboxList,
ComboboxStatus,
ComboboxValue,
} from '@langgenius/dify-ui/combobox'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { environmentName } from '../../../environment'
import {
DetailTableCell,
DetailTableRow,
} from '../../table'
type AccessPermissionKind = 'organization' | 'specific' | 'anyone'
type AccessMode = NonNullable<AccessPolicy['mode']>
type AccessSubjectType = NonNullable<AccessSubject['subjectType']>
const ACCESS_MODE_PUBLIC = 'ACCESS_MODE_PUBLIC' satisfies AccessMode
const ACCESS_MODE_PRIVATE = 'ACCESS_MODE_PRIVATE' satisfies AccessMode
const ACCESS_MODE_PRIVATE_ALL = 'ACCESS_MODE_PRIVATE_ALL' satisfies AccessMode
const ACCESS_SUBJECT_TYPE_ACCOUNT = 'ACCESS_SUBJECT_TYPE_ACCOUNT' satisfies AccessSubjectType
const ACCESS_SUBJECT_TYPE_GROUP = 'ACCESS_SUBJECT_TYPE_GROUP' satisfies AccessSubjectType
function accessModeToPermissionKey(mode?: AccessPolicy['mode']): AccessPermissionKind {
if (mode === ACCESS_MODE_PRIVATE)
return 'specific'
if (mode === ACCESS_MODE_PUBLIC)
return 'anyone'
return 'organization'
}
function permissionKeyToAccessMode(key: AccessPermissionKind): AccessMode {
if (key === 'organization')
return ACCESS_MODE_PRIVATE_ALL
if (key === 'specific')
return ACCESS_MODE_PRIVATE
return ACCESS_MODE_PUBLIC
}
const permissionIcon: Record<AccessPermissionKind, string> = {
organization: 'i-ri-team-line',
specific: 'i-ri-lock-line',
anyone: 'i-ri-global-line',
}
const permissionOrder: AccessPermissionKind[] = ['organization', 'specific', 'anyone']
function PermissionPicker({ value, disabled, onChange }: {
value: AccessPermissionKind
disabled?: boolean
onChange: (kind: AccessPermissionKind) => void
}) {
const { t } = useTranslation('deployments')
const icon = permissionIcon[value]
const label = t(`access.permission.${value}`)
return (
<DropdownMenu>
<DropdownMenuTrigger
disabled={disabled}
className={cn(
'inline-flex h-8 w-full min-w-0 items-center gap-2 rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-2.5 system-sm-regular text-text-secondary hover:bg-state-base-hover',
disabled && 'opacity-50',
)}
>
<span className={cn(icon, 'size-4 shrink-0 text-text-tertiary')} />
<span className="flex-1 truncate text-left">{label}</span>
<span className="i-ri-arrow-down-s-line size-4 shrink-0 text-text-tertiary" />
</DropdownMenuTrigger>
<DropdownMenuContent placement="bottom-start" popupClassName="w-85 p-1">
{permissionOrder.map((kind) => {
const itemIcon = permissionIcon[kind]
const isSelected = kind === value
return (
<DropdownMenuItem
key={kind}
onClick={() => onChange(kind)}
className="mx-0 h-auto items-start gap-3 rounded-lg px-2.5 py-2"
>
<span className={cn(itemIcon, 'mt-0.5 size-4 shrink-0 text-text-tertiary')} />
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate system-sm-medium text-text-primary">
{t(`access.permission.${kind}`)}
</span>
</div>
<span className="system-xs-regular text-text-tertiary">
{t(`access.permission.${kind}Desc`)}
</span>
</div>
{isSelected && (
<span className="mt-0.5 i-ri-check-line size-4 shrink-0 text-text-accent" />
)}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
type SelectableAccessSubject = {
id: string
subjectType: AccessSubjectType
name?: string
memberCount?: number
}
function subjectTypeFromSubject(subject: Subject): AccessSubjectType {
if (subject.subjectType === 'group' || subject.groupData)
return ACCESS_SUBJECT_TYPE_GROUP
return ACCESS_SUBJECT_TYPE_ACCOUNT
}
function normalizeSubject(subject: Subject): SelectableAccessSubject | undefined {
const id = subject.subjectId || subject.accountData?.id || subject.groupData?.id
if (!id)
return undefined
return {
id,
subjectType: subjectTypeFromSubject(subject),
name: subject.accountData?.name || subject.accountData?.email || subject.groupData?.name || id,
memberCount: subject.groupData?.groupSize,
}
}
function subjectKey(subject: Pick<SelectableAccessSubject, 'id' | 'subjectType'>) {
return `${subject.subjectType}:${subject.id}`
}
function getSubjectLabel(subject: SelectableAccessSubject) {
return subject.name || subject.id
}
function getSubjectValue(subject: SelectableAccessSubject) {
return subjectKey(subject)
}
function isSameSubject(item: SelectableAccessSubject, value: SelectableAccessSubject) {
return item.id === value.id && item.subjectType === value.subjectType
}
const SUBJECT_PICKER_SKELETON_KEYS = ['first-subject', 'second-subject', 'third-subject']
function policySubjects(subjects: SelectableAccessSubject[]): AccessSubject[] {
return subjects.map(subject => ({
subjectId: subject.id,
subjectType: subject.subjectType,
}))
}
function selectedSubjectsFromPolicy(policy?: AccessPolicy) {
return policy?.subjects
?.map((subject): SelectableAccessSubject | undefined => {
if (!subject.subjectId || !subject.subjectType)
return undefined
return {
id: subject.subjectId,
subjectType: subject.subjectType,
name: subject.subjectId,
}
})
.filter((subject): subject is SelectableAccessSubject => Boolean(subject)) ?? []
}
function SubjectIcon({ subject }: {
subject: SelectableAccessSubject
}) {
const isGroup = subject.subjectType === ACCESS_SUBJECT_TYPE_GROUP
return (
<span className={cn(isGroup ? 'i-ri-group-line' : 'i-ri-user-line', 'size-3.5 shrink-0 text-text-tertiary')} aria-hidden="true" />
)
}
type SubjectPickerProps = {
disabled?: boolean
selectedSubjects: SelectableAccessSubject[]
onChange: (subjects: SelectableAccessSubject[]) => void
}
function SubjectPicker({
disabled,
selectedSubjects,
onChange,
}: SubjectPickerProps) {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const debouncedKeyword = useDebounce(keyword, { wait: 300 })
const subjectsQuery = useQuery(consoleQuery.enterprise.accessSubjectService.listAccessSubjects.queryOptions({
input: {
query: {
keyword: debouncedKeyword.trim() || undefined,
pageNumber: 1,
resultsPerPage: 50,
},
},
enabled: open,
}))
const subjects = subjectsQuery.data?.subjects
?.map(normalizeSubject)
.filter((subject): subject is SelectableAccessSubject => Boolean(subject)) ?? []
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen)
setKeyword('')
setOpen(nextOpen)
}
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
if (details.reason !== 'item-press')
setKeyword(inputValue)
}
const handleValueChange = (nextSubjects: SelectableAccessSubject[]) => {
setKeyword('')
onChange(nextSubjects)
}
return (
<Combobox<SelectableAccessSubject, true>
multiple
open={open}
value={selectedSubjects}
inputValue={keyword}
items={subjects}
disabled={disabled}
itemToStringLabel={getSubjectLabel}
itemToStringValue={getSubjectValue}
isItemEqualToValue={isSameSubject}
filter={null}
onOpenChange={handleOpenChange}
onInputValueChange={handleInputValueChange}
onValueChange={handleValueChange}
>
<ComboboxInputGroup className="h-auto min-h-8 w-full max-w-full overflow-hidden py-1 pr-1">
<ComboboxValue>
{(selectedValue: SelectableAccessSubject[]) => (
<>
{selectedValue.length > 0 && (
<ComboboxChips className="flex-nowrap overflow-hidden">
{selectedValue.map(subject => (
<ComboboxChip
key={subjectKey(subject)}
className="shrink-0 rounded-full border border-divider-subtle bg-components-badge-white-to-dark"
>
<SubjectIcon subject={subject} />
<span className="max-w-32 truncate">{getSubjectLabel(subject)}</span>
{subject.subjectType === ACCESS_SUBJECT_TYPE_GROUP && subject.memberCount != null && (
<span className="system-2xs-regular text-text-tertiary">{subject.memberCount}</span>
)}
<ComboboxChipRemove
disabled={disabled || selectedSubjects.length <= 1}
aria-label={t('operation.remove', { ns: 'common' })}
>
<span className="i-ri-close-circle-fill size-3.5" aria-hidden="true" />
</ComboboxChipRemove>
</ComboboxChip>
))}
</ComboboxChips>
)}
<ComboboxInput
aria-label={t('access.members.pickPlaceholder')}
placeholder={selectedValue.length ? '' : t('access.members.pickPlaceholder')}
className={cn('px-2 py-0 system-sm-medium', selectedValue.length ? 'min-w-16' : 'min-w-0')}
/>
</>
)}
</ComboboxValue>
<ComboboxInputTrigger />
</ComboboxInputGroup>
<ComboboxContent popupClassName="w-(--anchor-width) min-w-90 p-0">
{subjectsQuery.isLoading
? (
<ComboboxStatus className="flex flex-col gap-2 px-3 py-3">
{SUBJECT_PICKER_SKELETON_KEYS.map(key => (
<SkeletonRow key={key} className="h-6">
<SkeletonRectangle className="h-3 w-full animate-pulse" />
</SkeletonRow>
))}
</ComboboxStatus>
)
: (
<>
<ComboboxList className="p-1">
{subjects.map(subject => (
<ComboboxItem
key={subjectKey(subject)}
value={subject}
className="mx-0"
>
<ComboboxItemText className="flex items-center gap-2 px-0">
<SubjectIcon subject={subject} />
<span className="min-w-0 flex-1 truncate">{getSubjectLabel(subject)}</span>
{subject.subjectType === ACCESS_SUBJECT_TYPE_GROUP && subject.memberCount != null && (
<span className="shrink-0 system-xs-regular text-text-tertiary">
{t('access.members.memberCount', { count: subject.memberCount })}
</span>
)}
</ComboboxItemText>
<ComboboxItemIndicator />
</ComboboxItem>
))}
</ComboboxList>
<ComboboxEmpty className="px-3 py-5 text-center system-xs-regular">
{t('access.members.empty')}
</ComboboxEmpty>
</>
)}
</ComboboxContent>
</Combobox>
)
}
type EnvironmentPermissionRowProps = {
appInstanceId: string
environment: Environment
summaryPolicy?: AccessPolicy
}
export function EnvironmentPermissionRow({
appInstanceId,
environment,
summaryPolicy,
}: EnvironmentPermissionRowProps) {
const { t } = useTranslation('deployments')
const environmentId = environment.id
const accessPolicyQuery = useQuery(consoleQuery.enterprise.accessService.getAccessPolicy.queryOptions({
input: {
params: {
appInstanceId,
environmentId: environmentId ?? '',
},
},
enabled: Boolean(environmentId),
}))
const setEnvironmentAccessPolicy = useMutation(consoleQuery.enterprise.accessService.putAccessPolicy.mutationOptions())
const policy = accessPolicyQuery.data?.policy ?? summaryPolicy
const policyKind = accessModeToPermissionKey(policy?.mode)
const policySubjectFingerprint = policy?.subjects
?.map(subject => `${subject.subjectType ?? ''}:${subject.subjectId ?? ''}`)
.join(',')
const policyFingerprint = [
policy?.mode ?? '',
policySubjectFingerprint ?? '',
].join(':')
const policySelectedSubjects = policyKind === 'specific' ? selectedSubjectsFromPolicy(policy) : []
const [draft, setDraft] = useState<{
fingerprint?: string
kind?: AccessPermissionKind
subjects?: SelectableAccessSubject[]
}>({})
const hasDraft = draft.fingerprint === policyFingerprint
const permissionKind = hasDraft && draft.kind ? draft.kind : policyKind
const subjects = hasDraft && draft.subjects ? draft.subjects : policySelectedSubjects
const isSaving = setEnvironmentAccessPolicy.isPending
const controlsDisabled = isSaving || accessPolicyQuery.isLoading || accessPolicyQuery.isError
const persistPolicy = (nextKind: AccessPermissionKind, nextSubjects: SelectableAccessSubject[]) => {
if (!environmentId)
return
if (nextKind === 'specific' && nextSubjects.length === 0)
return
setEnvironmentAccessPolicy.mutate(
{
params: {
appInstanceId,
environmentId,
},
body: {
appInstanceId,
environmentId,
mode: permissionKeyToAccessMode(nextKind),
subjects: nextKind === 'specific' ? policySubjects(nextSubjects) : [],
},
},
{
onSuccess: () => {
setDraft({})
},
onError: () => {
toast.error(t('access.permission.updateFailed'))
},
},
)
}
const handlePermissionChange = (nextKind: AccessPermissionKind) => {
setDraft({
fingerprint: policyFingerprint,
kind: nextKind,
subjects: nextKind === 'specific' ? subjects : [],
})
if (nextKind === 'specific') {
persistPolicy(nextKind, subjects)
return
}
persistPolicy(nextKind, [])
}
const handleSubjectsChange = (nextSubjects: SelectableAccessSubject[]) => {
if (nextSubjects.length === 0)
return
setDraft({
fingerprint: policyFingerprint,
kind: 'specific',
subjects: nextSubjects,
})
persistPolicy('specific', nextSubjects)
}
return (
<DetailTableRow className="block pc:table-row">
<DetailTableCell className="block h-auto px-4 pt-3 pb-1 align-top pc:table-cell pc:px-2.5 pc:py-[5px] pc:pl-3">
<div className="system-2xs-medium-uppercase text-text-tertiary pc:hidden">
{t('access.permissions.col.environment')}
</div>
<div className="mt-1 flex min-h-8 min-w-0 items-center pc:mt-0">
<span className="min-w-0 truncate text-text-primary">
{environmentName(environment)}
</span>
</div>
</DetailTableCell>
<DetailTableCell className="block h-auto px-4 py-1 align-top pc:table-cell pc:px-2.5 pc:py-[5px] pc:pl-3">
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary pc:hidden">
{t('access.permissions.col.permission')}
</div>
<PermissionPicker
value={permissionKind}
disabled={controlsDisabled}
onChange={handlePermissionChange}
/>
</DetailTableCell>
<DetailTableCell className="block h-auto px-4 pt-1 pb-3 align-top pc:table-cell pc:px-2.5 pc:py-[5px] pc:pl-3">
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary pc:hidden">
{t('access.permissions.col.subjects')}
</div>
{permissionKind === 'specific'
? (
<>
<SubjectPicker
selectedSubjects={subjects}
disabled={controlsDisabled}
onChange={handleSubjectsChange}
/>
{subjects.length === 0 && (
<span className="mt-1.5 block system-xs-regular text-text-tertiary">
{t('access.members.emptySelection')}
</span>
)}
</>
)
: (
<div className="flex min-h-8 items-center system-xs-regular text-text-tertiary">
{t(`access.permission.${permissionKind}Desc`)}
</div>
)}
</DetailTableCell>
</DetailTableRow>
)
}

View File

@ -0,0 +1,10 @@
export function getUrlOrigin(url?: string) {
if (!url)
return undefined
try {
return new URL(url).origin
}
catch {
return url
}
}

View File

@ -0,0 +1,36 @@
import { cn } from '@langgenius/dify-ui/cn'
export const DEPLOYMENT_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
actions: 'w-14',
currentRelease: 'w-[34%]',
environment: 'w-[34%]',
status: 'w-[24%]',
}
export const RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
action: 'w-14',
author: 'w-[18%]',
createdAt: 'w-[18%]',
deployedTo: 'w-[28%]',
release: 'w-[28%]',
}
export const ACCESS_PERMISSION_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
environment: 'w-[22%]',
permission: 'w-[28%]',
subjects: 'w-[50%]',
}
export const API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES = {
action: 'w-16',
environment: 'w-[20%]',
key: 'w-[38%]',
name: 'w-[28%]',
}
export const DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME = cn(
'inline-flex size-8 items-center justify-center rounded-md text-text-tertiary outline-hidden',
'hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid',
'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary',
'disabled:cursor-not-allowed disabled:opacity-50',
)

View File

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

View File

@ -0,0 +1,9 @@
export const INSTANCE_DETAIL_TAB_KEYS = ['overview', 'deploy', 'releases', 'access', 'api', 'settings'] as const
export type InstanceDetailTabKey = typeof INSTANCE_DETAIL_TAB_KEYS[number]
const INSTANCE_DETAIL_TAB_KEY_SET = new Set<string>(INSTANCE_DETAIL_TAB_KEYS)
export function isInstanceDetailTabKey(value?: string): value is InstanceDetailTabKey {
return value != null && INSTANCE_DETAIL_TAB_KEY_SET.has(value)
}

View File

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

View File

@ -0,0 +1,85 @@
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
import { describe, expect, it } from 'vitest'
import {
RUNTIME_INSTANCE_STATUS_DEPLOYING,
RUNTIME_INSTANCE_STATUS_READY,
} from '../../../runtime-status'
import { getReleaseDeployments } from '../release-deployments'
describe('getReleaseDeployments', () => {
it('should return runtime deployment state for the target release', () => {
// Arrange
const releaseRow = {
id: 'release-1',
} satisfies Release
const deploymentRows = [
{
currentDeployment: { id: 'deployment-1' },
environment: {
id: 'env-1',
name: 'Production',
},
status: RUNTIME_INSTANCE_STATUS_READY,
currentRelease: {
id: 'release-1',
},
},
] satisfies EnvironmentDeployment[]
// Act
const result = getReleaseDeployments(releaseRow, deploymentRows)
// Assert
expect(result).toEqual([
{
environmentId: 'env-1',
environmentName: 'Production',
state: 'active',
},
])
})
it('should only include runtime deployments from the new release contract', () => {
// Arrange
const releaseRow = {
id: 'release-1',
} satisfies Release
const deploymentRows = [
{
currentDeployment: { id: 'deployment-2' },
environment: {
id: 'env-2',
name: 'Staging',
},
status: RUNTIME_INSTANCE_STATUS_DEPLOYING,
currentRelease: {
id: 'release-1',
},
},
] satisfies EnvironmentDeployment[]
// Act
const result = getReleaseDeployments(releaseRow, deploymentRows)
// Assert
expect(result).toEqual([
{
environmentId: 'env-2',
environmentName: 'Staging',
state: 'deploying',
},
])
})
it('should return no deployments when the release row has no release id', () => {
// Arrange
const releaseRow = {
} satisfies Release
// Act
const result = getReleaseDeployments(releaseRow, [])
// Assert
expect(result).toEqual([])
})
})

View File

@ -0,0 +1,159 @@
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RUNTIME_INSTANCE_STATUS_READY } from '../../../runtime-status'
import { ReleaseHistoryTable } from '../release-history-table'
const mockUseQuery = vi.fn()
vi.mock('@tanstack/react-query', () => ({
keepPreviousData: Symbol('keepPreviousData'),
useQuery: (options: { queryKey?: string[] }) => mockUseQuery(options),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: () => '9 days ago',
}),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
enterprise: {
releaseService: {
listReleases: {
queryOptions: () => ({ queryKey: ['release-history'] }),
},
},
deploymentService: {
listEnvironmentDeployments: {
queryOptions: () => ({ queryKey: ['runtime-instances'] }),
},
},
},
},
}))
function release(overrides: Partial<Release> = {}): Release {
return {
id: 'release-1',
name: 'R-001',
createdAt: '2026-05-05T10:00:00Z',
createdBy: { name: 'App-runner-demo' },
...overrides,
}
}
function runtimeInstance(overrides: Partial<EnvironmentDeployment> = {}): EnvironmentDeployment {
return {
currentDeployment: { id: 'deployment-1' },
environment: { id: 'env-1', name: 'default' },
status: RUNTIME_INSTANCE_STATUS_READY,
currentRelease: { id: 'release-1' },
...overrides,
}
}
describe('ReleaseHistoryTable', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseQuery.mockImplementation((options: { queryKey?: string[] }) => {
switch (options.queryKey?.[0]) {
case 'release-history':
return {
data: {
data: [release()],
pagination: { totalCount: 1 },
},
isLoading: false,
isError: false,
}
case 'runtime-instances':
return {
data: { data: [runtimeInstance()] },
isLoading: false,
isError: false,
}
default:
return {
data: undefined,
isLoading: false,
isError: false,
}
}
})
})
// The desktop release history should use the shared semantic deployment detail table.
describe('Rendering', () => {
it('should render the desktop release history with the shared detail table design', () => {
// Arrange & Act
const { container } = render(<ReleaseHistoryTable appInstanceId="instance-1" />)
// Assert
const desktopWrapper = container.querySelector('.hidden.pc\\:block')
const tableContainer = desktopWrapper?.querySelector('[data-slot="deployment-detail-table-container"]')
const tableShell = desktopWrapper?.querySelector('[data-slot="deployment-detail-table"]')
const header = tableShell?.querySelector('[data-slot="deployment-detail-table-header"]')
const body = tableShell?.querySelector('[data-slot="deployment-detail-table-body"]')
const row = body?.querySelector('[data-slot="deployment-detail-table-row"]')
const head = header?.querySelector('[data-slot="deployment-detail-table-head"]')
const cell = row?.querySelector('[data-slot="deployment-detail-table-cell"]')
expect(tableContainer).toHaveClass(
'relative',
'w-full',
'overflow-x-auto',
)
expect(tableShell?.tagName).toBe('TABLE')
expect(header?.tagName).toBe('THEAD')
expect(body?.tagName).toBe('TBODY')
expect(row?.tagName).toBe('TR')
expect(head?.tagName).toBe('TH')
expect(cell?.tagName).toBe('TD')
expect(tableShell).toHaveClass(
'w-full',
'max-w-full',
'min-w-[700px]',
'border-collapse',
'border-0',
'caption-bottom',
'text-sm',
)
expect(header).toHaveClass(
'h-8',
'border-b',
'border-divider-subtle',
'text-xs/8',
'font-medium',
'text-text-tertiary',
'uppercase',
)
expect(head).toHaveClass(
'max-w-[200px]',
'px-2.5',
'py-0',
'pl-3',
'font-medium',
'whitespace-nowrap',
'text-text-tertiary',
)
expect(body).toHaveClass('text-text-secondary')
expect(row).toHaveClass(
'h-8',
'border-b',
'border-divider-subtle',
'hover:bg-background-default-hover',
)
expect(cell).toHaveClass(
'max-w-[200px]',
'px-2.5',
'py-[5px]',
'pl-3',
'align-middle',
)
expect(row?.querySelector('[data-slot="deployment-detail-table-row-content"]')).toBeNull()
expect(screen.getAllByText('R-001')).toHaveLength(2)
})
})
})

View File

@ -0,0 +1,207 @@
'use client'
import type { App } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { consoleQuery } from '@/service/client'
import { SourceAppPicker } from '../../components/create-instance-modal'
const DESCRIPTION_MAX_LENGTH = 512
const DESCRIPTION_WARN_THRESHOLD = 460
export function CreateReleaseControl({ appInstanceId, variant = 'primary', size = 'small', label, className }: {
appInstanceId: string
variant?: 'primary' | 'secondary'
size?: 'small' | 'medium'
label?: string
className?: string
}) {
const { t } = useTranslation('deployments')
const createRelease = useMutation(consoleQuery.enterprise.releaseService.createReleaseFromSourceApp.mutationOptions())
const [isCreating, setIsCreating] = useState(false)
const [sourceApp, setSourceApp] = useState<App>()
const [description, setDescription] = useState('')
function closeDialog() {
setIsCreating(false)
setSourceApp(undefined)
setDescription('')
}
function handleCreateRelease(form: HTMLFormElement) {
if (createRelease.isPending)
return
const formData = new FormData(form)
const releaseName = String(formData.get('name') ?? '').trim()
const releaseDescription = description.trim()
if (!releaseName || !sourceApp?.id)
return
createRelease.mutate(
{
body: {
appInstanceId,
sourceAppId: sourceApp.id,
name: releaseName,
description: releaseDescription || undefined,
},
},
{
onSuccess: (response) => {
if (!response.release?.id) {
toast.error(t('versions.createFailed'))
return
}
const createdName = response.release.name ?? releaseName
toast.success(t('versions.createSuccess', { name: createdName }))
form.reset()
closeDialog()
},
onError: () => {
toast.error(t('versions.createFailed'))
},
},
)
}
const descriptionLength = description.length
const isNearLimit = descriptionLength >= DESCRIPTION_WARN_THRESHOLD
const canCreate = Boolean(sourceApp?.id && !createRelease.isPending)
return (
<>
<Button
size={size}
variant={variant}
className={className}
disabled={createRelease.isPending}
onClick={() => setIsCreating(true)}
>
{label ?? t('versions.createRelease')}
</Button>
<Dialog
open={isCreating}
onOpenChange={(open) => {
if (!open)
closeDialog()
else
setIsCreating(true)
}}
>
<DialogContent className="w-140 overflow-hidden p-0">
<DialogCloseButton />
{isCreating && (
<form
onSubmit={(event) => {
event.preventDefault()
handleCreateRelease(event.currentTarget)
}}
>
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<div className="min-w-0">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('versions.createRelease')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('versions.createReleaseDescription')}
</DialogDescription>
</div>
</div>
<div className="flex flex-col gap-5 px-6 py-5">
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary">
{t('createModal.sourceApp')}
</label>
<SourceAppPicker
value={sourceApp}
onChange={setSourceApp}
/>
</div>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-name">
{t('versions.releaseNameLabel')}
</label>
<Input
id="release-name"
name="name"
placeholder={t('versions.releaseNamePlaceholder')}
maxLength={128}
required
autoFocus
className="h-9"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="release-description">
{t('versions.releaseDescriptionLabel')}
</label>
<div className="flex items-center gap-2">
<span className="system-xs-regular text-text-quaternary">
{t('versions.optional')}
</span>
<span
className={cn(
'system-xs-regular tabular-nums',
isNearLimit ? 'text-util-colors-warning-warning-700' : 'text-text-quaternary',
)}
>
{descriptionLength}
/
{DESCRIPTION_MAX_LENGTH}
</span>
</div>
</div>
<textarea
id="release-description"
name="description"
placeholder={t('versions.releaseDescriptionPlaceholder')}
maxLength={DESCRIPTION_MAX_LENGTH}
value={description}
onChange={e => setDescription(e.target.value)}
className="min-h-24 w-full resize-none appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
/>
</div>
</div>
<div className="flex items-center justify-between gap-4 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<div className="system-xs-regular text-text-tertiary">
{t('versions.createReleaseHint')}
</div>
<div className="flex shrink-0 justify-end gap-2">
<Button
type="button"
variant="secondary"
disabled={createRelease.isPending}
onClick={closeDialog}
>
{t('versions.cancelCreate')}
</Button>
<Button
type="submit"
variant="primary"
className="min-w-22"
disabled={!canCreate}
>
{createRelease.isPending ? t('versions.creating') : t('versions.create')}
</Button>
</div>
</div>
</form>
)}
</DialogContent>
</Dialog>
</>
)
}

View File

@ -0,0 +1,182 @@
'use client'
import type {
Environment,
EnvironmentDeployment,
Release,
} from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { environmentId, environmentName } from '../../environment'
import { releaseDeploymentAction } from '../../release-action'
import { deploymentStatus, isUndeployedDeploymentRow } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import { DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME } from '../table-styles'
type EnvironmentOption = Environment & {
id: string
}
type DeployMenuRowState = 'promote' | 'deploy' | 'rollback' | 'current' | 'deploying'
type DeployMenuRow = {
env: EnvironmentOption
state: DeployMenuRowState
label: string
disabledReason?: string
}
type DeployMenuGroup = 'promote' | 'deploy' | 'rollback' | 'unavailable'
const GROUP_ORDER: DeployMenuGroup[] = ['promote', 'deploy', 'rollback', 'unavailable']
function stateToGroup(state: DeployMenuRowState): DeployMenuGroup {
if (state === 'promote')
return 'promote'
if (state === 'rollback')
return 'rollback'
if (state === 'deploy')
return 'deploy'
return 'unavailable'
}
export function DeployReleaseMenu({ appInstanceId, releaseId, releaseRows }: {
appInstanceId: string
releaseId: string
releaseRows: Release[]
}) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const [open, setOpen] = useState(false)
const { data: environmentDeployments } = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
enabled: open,
}))
const environments: EnvironmentOption[] = (environmentDeployments?.data ?? [])
.map(row => row.environment)
.filter((env): env is EnvironmentOption => Boolean(env?.id))
const deploymentRows = environmentDeployments?.data?.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? []
const targetRelease = releaseRows.find(release => release.id === releaseId)
if (!targetRelease)
return null
const menuRows: DeployMenuRow[] = environments.map((env) => {
const envId = env.id
const envName = environmentName(env)
const row: EnvironmentDeployment | undefined = deploymentRows.find(item => environmentId(item.environment) === envId)
const currentRelease = row?.currentRelease
const isCurrent = currentRelease?.id === releaseId
const isEnvironmentDeploying = row ? deploymentStatus(row) === 'deploying' : false
if (isEnvironmentDeploying) {
return {
env,
state: 'deploying',
label: t('versions.deployingTo', { name: envName }),
disabledReason: t('versions.disabledReason.deploying'),
}
}
if (isCurrent) {
return {
env,
state: 'current',
label: t('versions.currentOn', { name: envName }),
disabledReason: t('versions.disabledReason.current', { name: envName }),
}
}
const action = releaseDeploymentAction({
targetRelease,
currentRelease,
releaseRows,
isExistingRelease: true,
})
if (!row) {
return {
env,
state: 'deploy',
label: t('versions.deployTo', { name: envName }),
}
}
if (action === 'rollback') {
return {
env,
state: 'rollback',
label: t('versions.rollbackTo', { name: envName }),
}
}
return {
env,
state: 'promote',
label: t('versions.promoteTo', { name: envName }),
}
})
const groupedRows = GROUP_ORDER.map(group => ({
group,
rows: menuRows.filter(row => stateToGroup(row.state) === group),
})).filter(section => section.rows.length > 0)
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
aria-label={t('versions.moreActions')}
className={DETAIL_TABLE_ACTION_TRIGGER_CLASS_NAME}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-60">
{groupedRows.map((section, sectionIndex) => (
<div key={section.group}>
{sectionIndex > 0 && <div className="my-1 border-t border-divider-subtle" aria-hidden />}
<div className="px-3 pt-1.5 pb-1 system-2xs-medium-uppercase text-text-quaternary">
{t(`versions.groupHeader.${section.group}`)}
</div>
{section.rows.map((row) => {
const isDisabled = row.state === 'current' || row.state === 'deploying'
return (
<DropdownMenuItem
key={row.env.id}
disabled={isDisabled}
title={isDisabled ? row.disabledReason : undefined}
aria-disabled={isDisabled}
className={cn(
'gap-2 px-3',
isDisabled && 'cursor-not-allowed opacity-60',
)}
onClick={() => {
if (isDisabled)
return
setOpen(false)
openDeployDrawer({ appInstanceId, environmentId: row.env.id, releaseId })
}}
>
<span className="system-sm-regular text-text-secondary">
{row.label}
</span>
</DropdownMenuItem>
)
})}
</div>
))}
</DropdownMenuContent>
)}
</DropdownMenu>
)
}

View File

@ -0,0 +1,46 @@
'use client'
import type { ReleaseDeployment, ReleaseDeploymentState } from './release-deployments'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
const RELEASE_DEPLOYMENT_STYLES: Record<ReleaseDeploymentState, string> = {
active: 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700',
deploying: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700',
failed: 'border-util-colors-red-red-200 bg-util-colors-red-red-50 text-util-colors-red-red-700',
}
export function DeployedToBadge({ item }: {
item: ReleaseDeployment
}) {
const { t } = useTranslation('deployments')
const statusLabel = t(`versions.deployedStatus.${item.state}`)
return (
<Tooltip>
<TooltipTrigger
render={(
<span
className={cn(
'inline-flex h-6 items-center gap-1 rounded-md border px-1.5 text-xs',
RELEASE_DEPLOYMENT_STYLES[item.state],
)}
>
{item.state === 'deploying'
? <span className="i-ri-loader-4-line size-3.5 animate-spin" />
: item.state === 'failed'
? <span className="i-ri-alert-line size-3.5" />
: <span className="size-1.5 rounded-full bg-current" />}
{item.environmentName}
</span>
)}
/>
<TooltipContent>
{statusLabel}
{' · '}
{item.environmentName}
</TooltipContent>
</Tooltip>
)
}

View File

@ -0,0 +1,50 @@
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
import { environmentId, environmentName } from '../../environment'
import { deploymentStatus } from '../../runtime-status'
export type ReleaseDeploymentState = 'active' | 'deploying' | 'failed'
export type ReleaseDeployment = {
environmentId: string
environmentName: string
state: ReleaseDeploymentState
}
function releaseDeploymentState(status?: string): ReleaseDeploymentState {
const normalized = status?.toLowerCase() ?? ''
if (normalized.includes('deploying') || normalized.includes('pending'))
return 'deploying'
if (normalized.includes('fail') || normalized.includes('error') || normalized.includes('invalid'))
return 'failed'
return 'active'
}
function dedupeReleaseDeployments(items: ReleaseDeployment[]) {
return items.filter((item, index) => {
return items.findIndex(candidate => candidate.environmentId === item.environmentId) === index
})
}
export function getReleaseDeployments(row: Release, deploymentRows: EnvironmentDeployment[]) {
const releaseId = row.id
if (!releaseId)
return []
const runtimeItems = deploymentRows.flatMap((deployment) => {
const envId = environmentId(deployment.environment)
if (!envId)
return []
const items: ReleaseDeployment[] = []
if (deployment.currentRelease?.id === releaseId) {
items.push({
environmentId: envId,
environmentName: environmentName(deployment.environment),
state: releaseDeploymentState(deploymentStatus(deployment)),
})
}
return items
})
return dedupeReleaseDeployments(runtimeItems)
}

View File

@ -0,0 +1,387 @@
'use client'
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
import type { ReleaseDeployment } from './release-deployments'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Pagination from '@/app/components/base/pagination'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { consoleQuery } from '@/service/client'
import { RELEASE_HISTORY_PAGE_SIZE } from '../../data'
import {
formatDate,
releaseCommit,
releaseLabel,
} from '../../release'
import { isUndeployedDeploymentRow } from '../../runtime-status'
import {
DetailListState,
} from '../common'
import {
DetailTable,
DetailTableBody,
DetailTableCard,
DetailTableCardList,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../table'
import {
RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from '../table-styles'
import { DeployReleaseMenu } from './deploy-release-menu'
import { DeployedToBadge } from './deployed-to-badge'
import { getReleaseDeployments } from './release-deployments'
const RELEASE_TABLE_ROW_SKELETON_KEYS = ['latest', 'previous', 'older', 'archived', 'initial']
type ReleaseRowWithId = Release & {
id: string
}
function hasReleaseId(row: Release): row is ReleaseRowWithId {
return Boolean(row.id)
}
function ReleaseHistoryTableSkeleton() {
const { t } = useTranslation('deployments')
return (
<>
<DetailTableCardList className="pc:hidden">
{RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => (
<DetailTableCard key={key}>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRow className="mt-1 gap-2">
<SkeletonRectangle className="h-3 w-28 animate-pulse" />
<SkeletonRectangle className="h-3 w-20 animate-pulse" />
</SkeletonRow>
</div>
<SkeletonRectangle className="my-0 h-7 w-8 animate-pulse rounded-lg" />
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<ReleaseDeploymentsSkeleton />
</div>
</div>
</DetailTableCard>
))}
</DetailTableCardList>
<div className="hidden pc:block">
<DetailTable>
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}>{t('versions.col.release')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt}>{t('versions.col.createdAt')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author}>{t('versions.col.author')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}>{t('versions.col.deployedTo')}</DetailTableHead>
<DetailTableHead className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t('versions.col.action')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
{RELEASE_TABLE_ROW_SKELETON_KEYS.map(key => (
<DetailTableRow key={key}>
<DetailTableCell>
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
</DetailTableCell>
<DetailTableCell>
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
</DetailTableCell>
<DetailTableCell>
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
</DetailTableCell>
<DetailTableCell>
<ReleaseDeploymentsSkeleton />
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<SkeletonRectangle className="my-0 size-8 animate-pulse rounded-md" />
</div>
</DetailTableCell>
</DetailTableRow>
))}
</DetailTableBody>
</DetailTable>
</div>
</>
)
}
function ReleaseHistoryMobileRows({ appInstanceId, releaseRows, deploymentRows, deployedToLoading, deployedToHasError }: {
appInstanceId: string
releaseRows: ReleaseRowWithId[]
deploymentRows: EnvironmentDeployment[]
deployedToLoading?: boolean
deployedToHasError?: boolean
}) {
const { t } = useTranslation('deployments')
return (
<DetailTableCardList className="pc:hidden">
{releaseRows.map((row) => {
const release = row
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
const hasDeployments = releaseDeployments.length > 0 || deployedToLoading || deployedToHasError
return (
<DetailTableCard key={release.id}>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Tooltip>
<TooltipTrigger
render={(
<span className="inline-flex max-w-full cursor-default truncate text-text-primary">
{releaseLabel(release)}
</span>
)}
/>
<TooltipContent>
{t('versions.commitTooltip', { commit: releaseCommit(release) })}
</TooltipContent>
</Tooltip>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 system-xs-regular text-text-secondary">
<CreatedAtCell createdAt={release.createdAt} />
<span aria-hidden>·</span>
<span>{row.createdBy?.name ?? '—'}</span>
</div>
</div>
<div className="flex shrink-0 justify-end gap-1">
<DeployReleaseMenu releaseId={release.id} appInstanceId={appInstanceId} releaseRows={releaseRows} />
</div>
</div>
{hasDeployments && (
<div className="flex min-w-0 flex-wrap items-center gap-1">
<ReleaseDeploymentsContent
items={releaseDeployments}
isLoading={deployedToLoading}
hasError={deployedToHasError}
loadFailedLabel={t('common.loadFailed')}
/>
</div>
)}
</div>
</DetailTableCard>
)
})}
</DetailTableCardList>
)
}
function ReleaseDeploymentsSkeleton() {
return (
<SkeletonRow className="gap-1">
<SkeletonRectangle className="my-0 h-5 w-20 animate-pulse rounded-md" />
<SkeletonRectangle className="my-0 h-5 w-18 animate-pulse rounded-md" />
</SkeletonRow>
)
}
function ReleaseDeploymentsContent({
items,
isLoading,
hasError,
loadFailedLabel,
}: {
items: ReleaseDeployment[]
isLoading?: boolean
hasError?: boolean
loadFailedLabel: string
}) {
if (isLoading)
return <ReleaseDeploymentsSkeleton />
if (hasError)
return <span className="text-text-tertiary">{loadFailedLabel}</span>
if (items.length === 0)
return <span className="text-text-quaternary"></span>
return items.map(item => (
<DeployedToBadge
key={`${item.environmentId}-${item.state}`}
item={item}
/>
))
}
function CreatedAtCell({ createdAt }: {
createdAt?: string
}) {
const { formatTimeFromNow } = useFormatTimeFromNow()
if (!createdAt)
return <></>
const ms = Date.parse(createdAt)
if (Number.isNaN(ms))
return <>{formatDate(createdAt)}</>
return (
<Tooltip>
<TooltipTrigger
render={(
<span className="cursor-default">
{formatTimeFromNow(ms)}
</span>
)}
/>
<TooltipContent>{formatDate(createdAt)}</TooltipContent>
</Tooltip>
)
}
function ReleaseHistoryRows({ appInstanceId, releaseRows, deploymentRows, deployedToLoading, deployedToHasError }: {
appInstanceId: string
releaseRows: ReleaseRowWithId[]
deploymentRows: EnvironmentDeployment[]
deployedToLoading?: boolean
deployedToHasError?: boolean
}) {
const { t } = useTranslation('deployments')
return (
<>
<ReleaseHistoryMobileRows
appInstanceId={appInstanceId}
releaseRows={releaseRows}
deploymentRows={deploymentRows}
deployedToLoading={deployedToLoading}
deployedToHasError={deployedToHasError}
/>
<div className="hidden pc:block">
<DetailTable>
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.release}>{t('versions.col.release')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.createdAt}>{t('versions.col.createdAt')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.author}>{t('versions.col.author')}</DetailTableHead>
<DetailTableHead className={RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.deployedTo}>{t('versions.col.deployedTo')}</DetailTableHead>
<DetailTableHead className={`${RELEASE_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t('versions.col.action')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
<DetailTableBody>
{releaseRows.map((row) => {
const release = row
const releaseDeployments = getReleaseDeployments(row, deploymentRows)
return (
<DetailTableRow key={release.id}>
<DetailTableCell>
<Tooltip>
<TooltipTrigger
render={(
<span className="inline-flex max-w-full cursor-default truncate text-text-primary">
{releaseLabel(release)}
</span>
)}
/>
<TooltipContent>
{t('versions.commitTooltip', { commit: releaseCommit(release) })}
</TooltipContent>
</Tooltip>
</DetailTableCell>
<DetailTableCell className="text-text-secondary">
<CreatedAtCell createdAt={release.createdAt} />
</DetailTableCell>
<DetailTableCell className="truncate text-text-secondary">
{row.createdBy?.name ?? '—'}
</DetailTableCell>
<DetailTableCell>
<div className="flex flex-wrap gap-1">
<ReleaseDeploymentsContent
items={releaseDeployments}
isLoading={deployedToLoading}
hasError={deployedToHasError}
loadFailedLabel={t('common.loadFailed')}
/>
</div>
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<DeployReleaseMenu releaseId={release.id} appInstanceId={appInstanceId} releaseRows={releaseRows} />
</div>
</DetailTableCell>
</DetailTableRow>
)
})}
</DetailTableBody>
</DetailTable>
</div>
</>
)
}
export function ReleaseHistoryTable({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const [currentPage, setCurrentPage] = useState(0)
const input = { params: { appInstanceId } }
const releaseHistoryQuery = useQuery(consoleQuery.enterprise.releaseService.listReleases.queryOptions({
input: {
...input,
query: {
pageNumber: currentPage + 1,
resultsPerPage: RELEASE_HISTORY_PAGE_SIZE,
},
},
placeholderData: keepPreviousData,
}))
const releaseRows = releaseHistoryQuery.data?.data?.filter(hasReleaseId) ?? []
const totalReleases = releaseHistoryQuery.data?.pagination?.totalCount ?? releaseRows.length
const shouldLoadRuntimeInstances = releaseRows.length > 0
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input,
enabled: shouldLoadRuntimeInstances,
}))
const isLoading = releaseHistoryQuery.isLoading
const hasError = releaseHistoryQuery.isError
const deployedToLoading = shouldLoadRuntimeInstances && environmentDeploymentsQuery.isLoading
const deployedToHasError = shouldLoadRuntimeInstances && environmentDeploymentsQuery.isError
const deploymentRows = environmentDeploymentsQuery.data?.data?.filter(row => Boolean(row.environment?.id) && !isUndeployedDeploymentRow(row)) ?? []
if (isLoading) {
return <ReleaseHistoryTableSkeleton />
}
if (hasError) {
return (
<DetailListState>
{t('common.loadFailed')}
</DetailListState>
)
}
if (releaseRows.length === 0) {
return (
<DetailListState>
{t('versions.emptyWithCreate')}
</DetailListState>
)
}
return (
<div className="flex flex-col gap-3">
<ReleaseHistoryRows
appInstanceId={appInstanceId}
releaseRows={releaseRows}
deploymentRows={deploymentRows}
deployedToLoading={deployedToLoading}
deployedToHasError={deployedToHasError}
/>
{totalReleases > RELEASE_HISTORY_PAGE_SIZE && (
<Pagination
className="border-y border-divider-subtle"
current={currentPage}
total={totalReleases}
limit={RELEASE_HISTORY_PAGE_SIZE}
onChange={setCurrentPage}
/>
)}
</div>
)
}

View File

@ -0,0 +1,25 @@
import type { Environment } from '@dify/contracts/enterprise/types.gen'
const ENVIRONMENT_MODE_ISOLATED = 'ENVIRONMENT_MODE_ISOLATED' satisfies NonNullable<Environment['mode']>
const RUNTIME_BACKEND_EXTERNAL = 'RUNTIME_BACKEND_EXTERNAL' satisfies NonNullable<Environment['backend']>
const ENVIRONMENT_STATUS_READY = 'ENVIRONMENT_STATUS_READY' satisfies NonNullable<Environment['status']>
export function environmentId(environment?: Environment) {
return environment?.id ?? ''
}
export function environmentName(environment?: Environment) {
return environment?.name || environment?.id || '—'
}
export function environmentMode(environment?: Environment) {
return environment?.mode === ENVIRONMENT_MODE_ISOLATED ? 'isolated' : 'shared'
}
export function environmentBackend(environment?: Environment) {
return environment?.backend === RUNTIME_BACKEND_EXTERNAL ? 'host' : 'k8s'
}
export function environmentHealth(environment?: Environment) {
return environment?.status === ENVIRONMENT_STATUS_READY ? 'ready' : 'degraded'
}

View File

@ -0,0 +1,6 @@
export function createDeploymentIdempotencyKey() {
if (globalThis.crypto?.randomUUID)
return globalThis.crypto.randomUUID()
return `deployment-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}

View File

@ -0,0 +1,18 @@
'use client'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
export function CreateDeploymentButton() {
const { t } = useTranslation('deployments')
return (
<Link
href="/deployments/create"
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary-600 px-3 system-sm-medium text-text-primary-on-surface hover:bg-primary-700"
>
<span className="i-ri-add-line size-4 shrink-0" aria-hidden="true" />
<span>{t('list.createDeployment')}</span>
</Link>
)
}

View File

@ -0,0 +1,92 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import { useQueryState } from 'nuqs'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { envFilterQueryState } from './query-state'
type EnvironmentFilterOption = {
value: string
text: string
icon: ReactNode
}
export function EnvironmentFilter() {
const { t } = useTranslation('deployments')
const [open, setOpen] = useState(false)
const [envFilter, setEnvFilter] = useQueryState('env', envFilterQueryState)
const activeFilter = envFilter === 'all' || envFilter === 'not-deployed'
? envFilter
: 'all'
const filterOptions: EnvironmentFilterOption[] = [
{
value: 'all',
text: t('filter.allEnvs'),
icon: <span className="i-ri-apps-2-line size-[14px]" />,
},
{
value: 'not-deployed',
text: t('filter.notDeployed'),
icon: <span className="i-ri-inbox-line size-[14px]" />,
},
]
const selectedOption = filterOptions.find(option => option.value === activeFilter) ?? filterOptions[0]
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn(
'flex h-8 cursor-pointer items-center gap-1 rounded-lg border border-transparent bg-components-input-bg-normal px-2 text-left select-none',
open && 'shadow-xs',
)}
>
<div className="p-px text-text-tertiary">
{selectedOption?.icon}
</div>
<div className="max-w-40 min-w-0 truncate system-sm-regular text-text-secondary">
{selectedOption?.text}
</div>
<div className="shrink-0 p-px">
<span className={cn('i-ri-arrow-down-s-line size-3.5 text-text-tertiary transition-transform', open && 'rotate-180')} />
</div>
</DropdownMenuTrigger>
{open && (
<DropdownMenuContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-60 rounded-lg border border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs"
>
<div className="max-h-72 overflow-auto p-1">
{filterOptions.map(option => (
<DropdownMenuItem
key={option.value}
onClick={() => {
void setEnvFilter(option.value)
setOpen(false)
}}
className={cn(
'flex items-center gap-2 rounded-lg py-1.5 pr-2 pl-3 select-none',
'cursor-pointer hover:bg-state-base-hover',
)}
>
<span className="shrink-0 text-text-tertiary">{option.icon}</span>
<span className="grow truncate text-sm/5 text-text-tertiary">{option.text}</span>
{option.value === activeFilter && (
<span className="i-custom-vender-line-general-check size-4 shrink-0 text-text-secondary" />
)}
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
)}
</DropdownMenu>
)
}

View File

@ -0,0 +1,209 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
import { debounce, useQueryState } from 'nuqs'
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../data'
import { CreateDeploymentButton } from './create-deployment-button'
import { EnvironmentFilter } from './environment-filter'
import { InstanceCard } from './instance-card'
import { envFilterQueryState, keywordsQueryState } from './query-state'
const INSTANCE_CARD_SKELETON_KEYS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth']
const EMPTY_INSTANCE_CARD_KEYS = Array.from({ length: 36 }, (_, index) => `empty-instance-card-${index}`)
function DeploymentsListState({ children }: {
children: ReactNode
}) {
return (
<div className="col-span-full rounded-xl border border-dashed border-components-panel-border bg-components-panel-bg-blur px-4 py-12 text-center system-sm-regular text-text-tertiary">
{children}
</div>
)
}
function DeploymentsListEmpty() {
const { t } = useTranslation('deployments')
return (
<>
{EMPTY_INSTANCE_CARD_KEYS.map(key => (
<div
key={key}
className="inline-flex h-40 rounded-xl bg-background-default-lighter"
/>
))}
<div className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-linear-to-t from-background-body to-transparent">
<span className="system-md-medium text-text-tertiary">
{t('list.empty')}
</span>
</div>
</>
)
}
function InstanceCardSkeleton() {
return (
<div className="col-span-1 inline-flex h-40 min-w-0 flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-xs">
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-w-0 px-4 pt-4">
<SkeletonRectangle className="my-0 h-4 w-2/5 animate-pulse" />
<div className="mt-2 flex min-h-9 flex-col gap-1.5">
<SkeletonRectangle className="my-0 h-3 w-4/5 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-3/5 animate-pulse" />
</div>
</div>
<div className="min-h-7 px-4 pt-1">
<div className="flex min-w-0 items-center gap-1.5">
<SkeletonRectangle className="my-0 h-5 w-18 animate-pulse rounded-md" />
<SkeletonRectangle className="my-0 h-5 w-22 animate-pulse rounded-md" />
</div>
</div>
<div className="mt-auto flex h-10.5 min-w-0 items-center border-t border-divider-subtle px-4">
<div className="flex min-w-0 grow items-center gap-2">
<SkeletonRectangle className="my-0 size-4 animate-pulse rounded-sm" />
<SkeletonRectangle className="my-0 size-4 animate-pulse rounded-sm" />
</div>
<SkeletonRectangle className="my-0 h-3 w-24 animate-pulse" />
</div>
</div>
</div>
)
}
function DeploymentsListSkeleton() {
return INSTANCE_CARD_SKELETON_KEYS.map(key => (
<InstanceCardSkeleton key={key} />
))
}
function DeploymentsSearchInput() {
const { t } = useTranslation('deployments')
const [keywords, setKeywords] = useQueryState('keywords', keywordsQueryState)
function handleKeywordsChange(next: string) {
void setKeywords(next.trim() ? next : null, {
limitUrlUpdates: next.trim() ? debounce(300) : undefined,
})
}
return (
<Input
showLeftIcon
showClearIcon
wrapperClassName="w-50"
placeholder={t('filter.searchPlaceholder')}
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
)
}
function DeploymentsListControls() {
return (
<div className="sticky top-0 z-10 flex flex-wrap items-center justify-between gap-3 bg-background-body px-12 pt-7 pb-5">
<div className="flex items-center gap-2">
<EnvironmentFilter />
<DeploymentsSearchInput />
</div>
<CreateDeploymentButton />
</div>
)
}
export function DeploymentsList() {
const { t } = useTranslation('deployments')
const [envFilter] = useQueryState('env', envFilterQueryState)
const [keywords] = useQueryState('keywords', keywordsQueryState)
const containerRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const queryKeywords = keywords.trim()
const {
data,
error,
fetchNextPage,
hasNextPage,
isError,
isFetching,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
...consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
...(envFilter === 'not-deployed' ? { notDeployed: true } : {}),
...(queryKeywords ? { query: queryKeywords } : {}),
},
}),
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
})
const pages = data?.pages ?? []
const apps = pages.flatMap(page => page.data ?? [])
const showSkeleton = isLoading || (isFetching && pages.length === 0)
const showEmptyState = !showSkeleton && !isError && apps.length === 0
useEffect(() => {
if (!hasNextPage || isLoading || isFetchingNextPage || error)
return
const anchor = anchorRef.current
const container = containerRef.current
if (!anchor || !container)
return
const observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting)
void fetchNextPage()
}, {
root: container,
rootMargin: '160px',
threshold: 0.1,
})
observer.observe(anchor)
return () => observer.disconnect()
}, [error, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading])
return (
<div ref={containerRef} className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<DeploymentsListControls />
<div className={cn(
'relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 2k:grid-cols-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
showEmptyState && 'overflow-hidden',
)}
>
{showSkeleton
? <DeploymentsListSkeleton />
: isError
? <DeploymentsListState>{t('common.loadFailed')}</DeploymentsListState>
: apps.length === 0
? <DeploymentsListEmpty />
: apps.map(app => (
<InstanceCard
key={app.id}
app={app}
/>
))}
{isFetchingNextPage && <DeploymentsListSkeleton />}
</div>
<div ref={anchorRef} className="h-0" />
<div className="py-4" />
</div>
)
}

View File

@ -0,0 +1,423 @@
'use client'
import type {
AccessChannels,
AppInstance,
EnvironmentDeployment,
Release,
} from '@dify/contracts/enterprise/types.gen'
import type { InstanceDetailTabKey } from '../detail/tabs'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { CreateReleaseControl } from '../detail/versions-tab/create-release-control'
import { environmentName } from '../environment'
import { releaseLabel } from '../release'
import {
deploymentStatus,
deploymentStatusPollingInterval,
isUndeployedDeploymentRow,
} from '../runtime-status'
const VISIBLE_ENVIRONMENT_COUNT = 3
const CARD_RELEASE_QUERY_PAGE_SIZE = 1
function getInstanceTabHref(appInstanceId: string, tabKey: InstanceDetailTabKey) {
return `/deployments/${appInstanceId}/${tabKey}`
}
function hasEnvironment(row: EnvironmentDeployment) {
return Boolean(row.environment?.id)
}
function isActiveDeployment(row: EnvironmentDeployment) {
return hasEnvironment(row) && !isUndeployedDeploymentRow(row)
}
function deploymentChipClasses(row: EnvironmentDeployment) {
const status = deploymentStatus(row)
if (status === 'deploy_failed') {
return {
container: 'bg-util-colors-red-red-50 text-util-colors-red-red-700',
dot: 'bg-util-colors-red-red-500',
}
}
if (status === 'deploying') {
return {
container: 'bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
dot: 'bg-util-colors-warning-warning-500 animate-pulse',
}
}
if (status === 'drifted') {
return {
container: 'bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
dot: 'bg-util-colors-warning-warning-500',
}
}
if (status === 'invalid') {
return {
container: 'bg-util-colors-red-red-50 text-util-colors-red-red-700',
dot: 'bg-util-colors-red-red-500',
}
}
if (status === 'ready') {
return {
container: 'bg-util-colors-green-green-50 text-util-colors-green-green-700',
dot: 'bg-util-colors-green-green-500',
}
}
return {
container: 'bg-background-section-burn text-text-tertiary',
dot: 'bg-text-quaternary',
}
}
function statusLabel(row: EnvironmentDeployment, t: ReturnType<typeof useTranslation<'deployments'>>['t']) {
const status = deploymentStatus(row)
if (status === 'deploy_failed')
return t('status.deployFailed')
if (status === 'deploying')
return t('status.deploying')
if (status === 'ready')
return t('status.ready')
if (status === 'drifted')
return t('status.drifted')
if (status === 'invalid')
return t('status.invalid')
if (status === 'not_deployed')
return t('status.notDeployed')
return t('status.unknown')
}
function pickDisplayedRelease(rows: EnvironmentDeployment[]): Release | undefined {
const releases = rows
.map(row => row.currentRelease)
.filter((release): release is Release => Boolean(release?.id))
return releases.sort((a, b) => {
const aTime = a.createdAt ? Date.parse(a.createdAt) : 0
const bTime = b.createdAt ? Date.parse(b.createdAt) : 0
return bTime - aTime
})[0]
}
function EnvironmentChip({ row }: {
row: EnvironmentDeployment
}) {
const { t } = useTranslation('deployments')
const name = environmentName(row.environment)
const classes = deploymentChipClasses(row)
return (
<Tooltip>
<TooltipTrigger
render={(
<span
className={cn(
'inline-flex h-5 max-w-32 cursor-default items-center gap-1 rounded-md px-1.5 system-xs-medium',
classes.container,
)}
title={name}
>
<span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', classes.dot)} />
<span className="truncate">{name}</span>
</span>
)}
/>
<TooltipContent>
<div className="flex min-w-40 flex-col gap-1">
<div className="flex justify-between gap-3">
<span className="truncate text-text-secondary">{name}</span>
<span className="shrink-0">{statusLabel(row, t)}</span>
</div>
{row.currentRelease?.id && (
<div className="flex justify-between gap-3 text-text-tertiary">
<span>{t('card.tooltip.release')}</span>
<span className="font-mono">{releaseLabel(row.currentRelease)}</span>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
)
}
function EnvironmentOverflow({ rows }: {
rows: EnvironmentDeployment[]
}) {
const { t } = useTranslation('deployments')
return (
<Tooltip>
<TooltipTrigger
render={(
<span className="inline-flex h-5 cursor-default items-center rounded-md bg-background-section-burn px-1.5 system-xs-medium text-text-tertiary">
{t('card.envOverflow', { count: rows.length })}
</span>
)}
/>
<TooltipContent>
<div className="flex min-w-40 flex-col gap-1">
{rows.map(row => (
<div key={row.environment?.id} className="flex justify-between gap-3">
<span className="truncate text-text-secondary">{environmentName(row.environment)}</span>
<span className="shrink-0">{statusLabel(row, t)}</span>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
)
}
function DeploymentStatusContent({
appInstanceId,
rows,
hasRelease,
isLoading,
hasError,
}: {
appInstanceId: string
rows: EnvironmentDeployment[]
hasRelease: boolean
isLoading: boolean
hasError: boolean
}) {
const { t } = useTranslation('deployments')
const visibleRows = rows.slice(0, VISIBLE_ENVIRONMENT_COUNT)
const overflowRows = rows.slice(VISIBLE_ENVIRONMENT_COUNT)
if (isLoading) {
return (
<div className="flex items-center gap-2">
<SkeletonRectangle className="my-0 h-5 w-20 animate-pulse rounded-md" />
<SkeletonRectangle className="my-0 h-5 w-24 animate-pulse rounded-md" />
</div>
)
}
if (hasError) {
return (
<span className="system-xs-regular text-text-tertiary">
{t('common.loadFailed')}
</span>
)
}
if (rows.length > 0) {
return (
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{visibleRows.map(row => (
<EnvironmentChip key={row.environment?.id} row={row} />
))}
{overflowRows.length > 0 && <EnvironmentOverflow rows={overflowRows} />}
</div>
)
}
if (hasRelease) {
return (
<Link
href={getInstanceTabHref(appInstanceId, 'deploy')}
className="inline-flex h-6 items-center gap-1 system-xs-medium text-text-accent hover:underline"
>
<span aria-hidden className="i-ri-rocket-line size-3.5" />
{t('card.deploy')}
</Link>
)
}
return (
<CreateReleaseControl
appInstanceId={appInstanceId}
variant="secondary"
label={t('card.createFirstRelease')}
className="h-6 border-0 bg-transparent px-0 text-text-accent shadow-none hover:bg-transparent hover:underline"
/>
)
}
function DeploymentAccessLinks({ appInstanceId, access, isLoading }: {
appInstanceId: string
access?: AccessChannels
isLoading?: boolean
}) {
const { t } = useTranslation('deployments')
if (isLoading) {
return (
<div className="flex min-w-0 grow items-center gap-2">
<SkeletonRectangle className="my-0 size-4 animate-pulse rounded-sm" />
<SkeletonRectangle className="my-0 size-4 animate-pulse rounded-sm" />
</div>
)
}
const links = [
access?.webAppEnabled
? {
key: 'webapp',
href: getInstanceTabHref(appInstanceId, 'access'),
label: t('card.access.webApp'),
icon: 'i-ri-global-line',
}
: undefined,
access?.webAppEnabled
? {
key: 'cli',
href: getInstanceTabHref(appInstanceId, 'access'),
label: t('card.access.cli'),
icon: 'i-ri-terminal-box-line',
}
: undefined,
access?.developerApiEnabled
? {
key: 'api',
href: getInstanceTabHref(appInstanceId, 'api'),
label: t('card.access.api'),
icon: 'i-ri-code-s-slash-line',
}
: undefined,
].filter((link): link is { key: string, href: string, label: string, icon: string } => Boolean(link))
if (links.length === 0)
return <div className="min-w-0 grow" />
return (
<div className="flex min-w-0 grow items-center gap-2">
{links.map(link => (
<Tooltip key={link.key}>
<TooltipTrigger
render={(
<Link
href={link.href}
aria-label={link.label}
className="inline-flex size-5 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
>
<span aria-hidden className={cn('size-3.5', link.icon)} />
</Link>
)}
/>
<TooltipContent>{link.label}</TooltipContent>
</Tooltip>
))}
</div>
)
}
export function InstanceCard({ app }: {
app: AppInstance
}) {
const { t } = useTranslation('deployments')
const { formatTimeFromNow } = useFormatTimeFromNow()
const appInstanceId = app.id ?? ''
const appName = app.name ?? appInstanceId
const detailHref = getInstanceTabHref(appInstanceId, 'overview')
const input = { params: { appInstanceId } }
const instanceQuery = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input,
enabled: Boolean(appInstanceId),
}))
const accessChannelsQuery = useQuery(consoleQuery.enterprise.accessService.getAccessChannels.queryOptions({
input,
enabled: Boolean(appInstanceId),
}))
const releaseHistoryQuery = useQuery(consoleQuery.enterprise.releaseService.listReleases.queryOptions({
input: {
...input,
query: {
pageNumber: 1,
resultsPerPage: CARD_RELEASE_QUERY_PAGE_SIZE,
},
},
enabled: Boolean(appInstanceId),
}))
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input,
enabled: Boolean(appInstanceId),
refetchInterval: query => deploymentStatusPollingInterval(query.state.data),
}))
if (!app.id)
return null
const description = (instanceQuery.data?.appInstance?.description ?? app.description)?.trim()
const access = accessChannelsQuery.data?.accessChannels
const releaseRows = releaseHistoryQuery.data?.data?.filter((release): release is Release & { id: string } => Boolean(release.id)) ?? []
const hasRelease = releaseRows.length > 0
const activeDeploymentRows = environmentDeploymentsQuery.data?.data?.filter(isActiveDeployment) ?? []
const displayedRelease = pickDisplayedRelease(activeDeploymentRows)
const displayedTime = displayedRelease?.createdAt
const displayedTimeMs = displayedTime ? Date.parse(displayedTime) : Number.NaN
const releaseMeta = displayedRelease
? [
releaseLabel(displayedRelease),
Number.isNaN(displayedTimeMs) ? undefined : formatTimeFromNow(displayedTimeMs),
].filter(Boolean).join(' · ')
: t('card.notDeployed')
const statusIsLoading = environmentDeploymentsQuery.isLoading || (!activeDeploymentRows.length && releaseHistoryQuery.isLoading)
const statusHasError = environmentDeploymentsQuery.isError || releaseHistoryQuery.isError
return (
<div
className="group relative col-span-1 inline-flex h-40 min-w-0 cursor-default flex-col rounded-xl border border-solid border-components-card-border bg-components-card-bg shadow-xs transition-all duration-200 ease-in-out hover:shadow-lg"
>
<div className="flex min-h-0 flex-1 flex-col">
<Link
href={detailHref}
className="block min-w-0 rounded-t-xl px-4 pt-4 outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
>
<h3 className="truncate title-md-semi-bold text-text-primary" title={appName}>
{appName}
</h3>
{instanceQuery.isLoading
? (
<div className="mt-2 flex min-h-9 flex-col gap-1.5">
<SkeletonRectangle className="my-0 h-3 w-4/5 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-3/5 animate-pulse" />
</div>
)
: (
description
? (
<p
className="mt-2 line-clamp-2 min-h-9 system-xs-regular text-text-tertiary"
title={description}
>
{description}
</p>
)
: <div className="mt-2 min-h-9" />
)}
</Link>
<div className="min-h-7 px-4 pt-1">
<DeploymentStatusContent
appInstanceId={appInstanceId}
rows={activeDeploymentRows}
hasRelease={hasRelease}
isLoading={statusIsLoading}
hasError={statusHasError}
/>
</div>
<div className="mt-auto flex h-10.5 min-w-0 items-center border-t border-divider-subtle px-4">
<DeploymentAccessLinks appInstanceId={appInstanceId} access={access} isLoading={accessChannelsQuery.isLoading} />
<Link
href={displayedRelease ? getInstanceTabHref(appInstanceId, 'releases') : getInstanceTabHref(appInstanceId, 'deploy')}
className="min-w-0 shrink-0 truncate text-right system-xs-regular text-text-tertiary hover:text-text-secondary"
title={releaseMeta}
>
{releaseMeta}
</Link>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,4 @@
import { parseAsString } from 'nuqs'
export const envFilterQueryState = parseAsString.withDefault('all').withOptions({ history: 'push' })
export const keywordsQueryState = parseAsString.withDefault('').withOptions({ history: 'push' })

View File

@ -0,0 +1,114 @@
'use client'
import type { AppInstance } from '@dify/contracts/enterprise/types.gen'
import type { NavItem } from '@/app/components/header/nav/nav-selector'
import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import Nav from '@/app/components/header/nav'
import { useParams, useRouter, useSelectedLayoutSegment } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { toAppMode } from '../app-mode'
import { getNextPageParamFromPagination, SOURCE_APPS_PAGE_SIZE } from '../data'
function navItemFromListApp(app: AppInstance): NavItem[] {
if (!app.id || !app.name)
return []
return [{
id: app.id,
name: app.name,
link: `/deployments/${app.id}/overview`,
icon_type: 'emoji',
icon: '',
icon_background: null,
icon_url: null,
mode: toAppMode(),
}]
}
function navItemFromOverview(instance?: AppInstance): NavItem | undefined {
if (!instance?.id)
return undefined
const name = instance.name ?? instance.id
return {
id: instance.id,
name,
link: `/deployments/${instance.id}/overview`,
icon_type: 'emoji',
icon: '',
icon_background: null,
icon_url: null,
mode: toAppMode(),
}
}
export function DeploymentsNav() {
const { t } = useTranslation()
const router = useRouter()
const selectedSegment = useSelectedLayoutSegment()
const isActive = selectedSegment === 'deployments'
const params = useParams<{ appInstanceId?: string }>()
const appInstanceId = params?.appInstanceId
const hasAppInstanceId = Boolean(appInstanceId)
const { data: currentInstance } = useQuery(consoleQuery.enterprise.appInstanceService.getAppInstance.queryOptions({
input: { params: { appInstanceId: appInstanceId ?? '' } },
enabled: isActive && hasAppInstanceId,
select: data => data.appInstance,
}))
const listQuery = useInfiniteQuery({
...consoleQuery.enterprise.appInstanceService.listAppInstances.infiniteOptions({
input: pageParam => ({
query: {
pageNumber: Number(pageParam),
resultsPerPage: SOURCE_APPS_PAGE_SIZE,
},
}),
getNextPageParam: lastPage => getNextPageParamFromPagination(lastPage.pagination),
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
enabled: isActive,
})
const appNavItems = listQuery.data?.pages.flatMap(page => page.data?.flatMap(navItemFromListApp) ?? []) ?? []
const currentNavItem = navItemFromOverview(currentInstance)
const navigationItems: NavItem[] = isActive
? currentNavItem && !appNavItems.some(item => item.id === currentNavItem.id)
? [...appNavItems, currentNavItem]
: appNavItems
: []
const curNav = appInstanceId
? navigationItems.find(item => item.id === appInstanceId)
: undefined
function handleCreate() {
router.push('/deployments/create')
}
function handleLoadMore() {
if (listQuery.hasNextPage && !listQuery.isFetchingNextPage)
void listQuery.fetchNextPage()
}
return (
<Nav
isApp={false}
icon={<span aria-hidden className="i-ri-rocket-line size-4" />}
activeIcon={<span aria-hidden className="i-ri-rocket-fill size-4" />}
text={t('menus.deployments', { ns: 'common' })}
activeSegment="deployments"
link="/deployments"
curNav={curNav}
navigationItems={navigationItems}
createText={t('deployments:list.createDeployment')}
onCreate={handleCreate}
onLoadMore={handleLoadMore}
isLoadingMore={listQuery.isFetchingNextPage}
/>
)
}

View File

@ -0,0 +1,73 @@
import type { Release } from '@dify/contracts/enterprise/types.gen'
export type ReleaseDeploymentAction = 'deploy' | 'deployExistingRelease' | 'promote' | 'rollback'
function releaseCreatedAt(release?: Release) {
const value = release?.createdAt
if (!value)
return undefined
const time = Date.parse(value)
return Number.isFinite(time) ? time : undefined
}
function releaseById(releaseRows: Release[], releaseId?: string) {
return releaseRows.find(release => release.id === releaseId)
}
function releaseOrderIndex(releaseRows: Release[], releaseId?: string) {
return releaseRows.findIndex(release => release.id === releaseId)
}
function compareReleaseOrder(
targetRelease: Release | undefined,
currentRelease: Release,
releaseRows: Release[],
) {
if (!targetRelease?.id || !currentRelease.id)
return undefined
if (targetRelease.id === currentRelease.id)
return 0
const normalizedTargetRelease = releaseById(releaseRows, targetRelease.id) ?? targetRelease
const normalizedCurrentRelease = releaseById(releaseRows, currentRelease.id) ?? currentRelease
const targetCreatedAt = releaseCreatedAt(normalizedTargetRelease)
const currentCreatedAt = releaseCreatedAt(normalizedCurrentRelease)
if (targetCreatedAt !== undefined && currentCreatedAt !== undefined && targetCreatedAt !== currentCreatedAt)
return targetCreatedAt > currentCreatedAt ? 1 : -1
const targetIndex = releaseOrderIndex(releaseRows, targetRelease.id)
const currentIndex = releaseOrderIndex(releaseRows, currentRelease.id)
if (targetIndex >= 0 && currentIndex >= 0 && targetIndex !== currentIndex)
return targetIndex < currentIndex ? 1 : -1
return undefined
}
export function releaseDeploymentAction({
targetRelease,
currentRelease,
releaseRows,
isExistingRelease,
}: {
targetRelease?: Release
currentRelease?: Release
releaseRows: Release[]
isExistingRelease?: boolean
}): ReleaseDeploymentAction {
if (!currentRelease?.id)
return isExistingRelease ? 'deployExistingRelease' : 'deploy'
const order = compareReleaseOrder(targetRelease, currentRelease, releaseRows)
if (order === -1)
return 'rollback'
if (order === 1)
return 'promote'
return targetRelease?.id && targetRelease.id !== currentRelease.id
? 'promote'
: isExistingRelease
? 'deployExistingRelease'
: 'deploy'
}

View File

@ -0,0 +1,15 @@
import type { Release } from '@dify/contracts/enterprise/types.gen'
export function formatDate(value?: string) {
if (!value)
return '—'
return value.replace('T', ' ').replace(/\.\d+Z?$/, '').replace(/Z$/, '').slice(0, 16)
}
export function releaseLabel(release?: Release) {
return release?.name || release?.id || '—'
}
export function releaseCommit(release?: Release) {
return release?.gateCommitId ? release.gateCommitId.slice(0, 8) : '—'
}

View File

@ -0,0 +1,20 @@
import type { RequiredSlot } from '@dify/contracts/enterprise/types.gen'
const SLOT_TYPE_PLUGIN_CREDENTIAL = 'SLOT_TYPE_PLUGIN_CREDENTIAL' satisfies NonNullable<RequiredSlot['type']>
const SLOT_TYPE_ENV_VAR = 'SLOT_TYPE_ENV_VAR' satisfies NonNullable<RequiredSlot['type']>
export function runtimeBindingSummary(binding?: RequiredSlot) {
return binding?.name || binding?.providerId || '—'
}
export function isRuntimeEnvVarBinding(binding?: RequiredSlot) {
return binding?.type === SLOT_TYPE_ENV_VAR
}
export function isRuntimeModelBinding(binding?: RequiredSlot) {
return binding?.type === SLOT_TYPE_PLUGIN_CREDENTIAL
}
export function isRuntimePluginBinding(binding?: RequiredSlot) {
return !isRuntimeEnvVarBinding(binding) && !isRuntimeModelBinding(binding)
}

View File

@ -0,0 +1,115 @@
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
type RuntimeInstanceStatusValue = number | string
type RuntimeInstanceStatusRow = {
status?: RuntimeInstanceStatusValue
}
type RuntimeInstanceStatus = NonNullable<EnvironmentDeployment['status']>
export type DeploymentUiStatus
= | 'ready'
| 'deploying'
| 'deploy_failed'
| 'drifted'
| 'invalid'
| 'not_deployed'
| 'unknown'
export const DEPLOYMENT_STATUS_POLLING_INTERVAL = 3000
// Mirrors appdeploy/v1/common.proto RuntimeInstanceStatus for EnvironmentDeployment.status.
export const RUNTIME_INSTANCE_STATUS_UNDEPLOYED = 'RUNTIME_INSTANCE_STATUS_UNDEPLOYED' satisfies RuntimeInstanceStatus
export const RUNTIME_INSTANCE_STATUS_DEPLOYING = 'RUNTIME_INSTANCE_STATUS_DEPLOYING' satisfies RuntimeInstanceStatus
export const RUNTIME_INSTANCE_STATUS_READY = 'RUNTIME_INSTANCE_STATUS_READY' satisfies RuntimeInstanceStatus
export const RUNTIME_INSTANCE_STATUS_FAILED = 'RUNTIME_INSTANCE_STATUS_FAILED' satisfies RuntimeInstanceStatus
export const RUNTIME_INSTANCE_STATUS_DRIFTED = 'RUNTIME_INSTANCE_STATUS_DRIFTED' satisfies RuntimeInstanceStatus
export const RUNTIME_INSTANCE_STATUS_INVALID = 'RUNTIME_INSTANCE_STATUS_INVALID' satisfies RuntimeInstanceStatus
type RuntimeInstanceStatusQueryData = {
data?: RuntimeInstanceStatusRow[]
}
export function isUndeployedDeploymentRow(row?: EnvironmentDeployment) {
return deploymentStatus(row) === 'not_deployed'
|| (!row?.currentRelease?.id && !row?.desiredRelease?.id && !row?.currentDeployment?.id)
}
function normalizeRuntimeInstanceStatus(status?: RuntimeInstanceStatusValue): RuntimeInstanceStatus | undefined {
if (typeof status === 'number') {
switch (status) {
case 1:
return RUNTIME_INSTANCE_STATUS_UNDEPLOYED
case 2:
return RUNTIME_INSTANCE_STATUS_DEPLOYING
case 3:
return RUNTIME_INSTANCE_STATUS_READY
case 4:
return RUNTIME_INSTANCE_STATUS_FAILED
case 5:
return RUNTIME_INSTANCE_STATUS_DRIFTED
case 6:
return RUNTIME_INSTANCE_STATUS_INVALID
default:
return undefined
}
}
const normalized = status?.trim().toUpperCase()
if (!normalized)
return undefined
switch (normalized) {
case '1':
case 'UNDEPLOYED':
case RUNTIME_INSTANCE_STATUS_UNDEPLOYED:
return RUNTIME_INSTANCE_STATUS_UNDEPLOYED
case '2':
case 'DEPLOYING':
case RUNTIME_INSTANCE_STATUS_DEPLOYING:
return RUNTIME_INSTANCE_STATUS_DEPLOYING
case '3':
case 'READY':
case RUNTIME_INSTANCE_STATUS_READY:
return RUNTIME_INSTANCE_STATUS_READY
case '4':
case 'FAILED':
case RUNTIME_INSTANCE_STATUS_FAILED:
return RUNTIME_INSTANCE_STATUS_FAILED
case '5':
case 'DRIFTED':
case RUNTIME_INSTANCE_STATUS_DRIFTED:
return RUNTIME_INSTANCE_STATUS_DRIFTED
case '6':
case 'INVALID':
case RUNTIME_INSTANCE_STATUS_INVALID:
return RUNTIME_INSTANCE_STATUS_INVALID
default:
return undefined
}
}
export function deploymentStatus(row?: RuntimeInstanceStatusRow): DeploymentUiStatus {
const status = normalizeRuntimeInstanceStatus(row?.status)
if (!status)
return 'unknown'
if (status === RUNTIME_INSTANCE_STATUS_UNDEPLOYED)
return 'not_deployed'
if (status === RUNTIME_INSTANCE_STATUS_DEPLOYING)
return 'deploying'
if (status === RUNTIME_INSTANCE_STATUS_FAILED)
return 'deploy_failed'
if (status === RUNTIME_INSTANCE_STATUS_READY)
return 'ready'
if (status === RUNTIME_INSTANCE_STATUS_DRIFTED)
return 'drifted'
if (status === RUNTIME_INSTANCE_STATUS_INVALID)
return 'invalid'
return 'unknown'
}
export function hasDeployingDeployment(rows?: RuntimeInstanceStatusRow[]) {
return rows?.some(row => deploymentStatus(row) === 'deploying') ?? false
}
export function deploymentStatusPollingInterval(data?: RuntimeInstanceStatusQueryData) {
return hasDeployingDeployment(data?.data) ? DEPLOYMENT_STATUS_POLLING_INTERVAL : false
}

View File

@ -0,0 +1,25 @@
import { atom } from 'jotai'
type OpenDeployDrawerParams = {
appInstanceId: string
environmentId?: string
releaseId?: string
}
export const deployDrawerOpenAtom = atom(false)
export const deployDrawerAppInstanceIdAtom = atom<string | undefined>(undefined)
export const deployDrawerEnvironmentIdAtom = atom<string | undefined>(undefined)
export const deployDrawerReleaseIdAtom = atom<string | undefined>(undefined)
export const openDeployDrawerAtom = atom(null, (_get, set, params: OpenDeployDrawerParams) => {
set(deployDrawerAppInstanceIdAtom, params.appInstanceId)
set(deployDrawerEnvironmentIdAtom, params.environmentId)
set(deployDrawerReleaseIdAtom, params.releaseId)
set(deployDrawerOpenAtom, true)
})
export const closeDeployDrawerAtom = atom(null, (_get, set) => {
set(deployDrawerOpenAtom, false)
set(deployDrawerAppInstanceIdAtom, undefined)
set(deployDrawerEnvironmentIdAtom, undefined)
set(deployDrawerReleaseIdAtom, undefined)
})

View File

@ -0,0 +1,26 @@
import { PUBLIC_API_PREFIX } from '@/config'
const absoluteUrlRegExp = /^[a-z][a-z\d+.-]*:\/\//i
function withLeadingSlash(path: string) {
return path.startsWith('/') ? path : `/${path}`
}
function publicWebappOrigin() {
try {
return new URL(PUBLIC_API_PREFIX).origin
}
catch {
return PUBLIC_API_PREFIX.replace(/\/api\/?$/, '').replace(/\/+$/, '')
}
}
export function webappUrl(url?: string) {
if (!url)
return ''
if (absoluteUrlRegExp.test(url))
return url
const origin = publicWebappOrigin()
return `${origin}${withLeadingSlash(url)}`
}

View File

@ -13,6 +13,7 @@ import type datasetHitTesting from '../i18n/en-US/dataset-hit-testing.json'
import type datasetPipeline from '../i18n/en-US/dataset-pipeline.json'
import type datasetSettings from '../i18n/en-US/dataset-settings.json'
import type dataset from '../i18n/en-US/dataset.json'
import type deployments from '../i18n/en-US/deployments.json'
import type education from '../i18n/en-US/education.json'
import type explore from '../i18n/en-US/explore.json'
import type layout from '../i18n/en-US/layout.json'
@ -46,6 +47,7 @@ export type Resources = {
datasetHitTesting: typeof datasetHitTesting
datasetPipeline: typeof datasetPipeline
datasetSettings: typeof datasetSettings
deployments: typeof deployments
education: typeof education
explore: typeof explore
layout: typeof layout
@ -79,6 +81,7 @@ export const namespaces = [
'datasetHitTesting',
'datasetPipeline',
'datasetSettings',
'deployments',
'education',
'explore',
'layout',

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