Compare commits

..

278 Commits

Author SHA1 Message Date
9050638eef Merge branch 'main' into 4-27-app-deploy 2026-05-29 21:04:00 +08:00
eaf8620d7d [autofix.ci] apply automated fixes 2026-05-29 12:59:44 +00:00
b0ea419c7d Refactor deployment queries and access control components 2026-05-29 20:54:47 +08:00
0006941ada Merge branch 'main' into 4-27-app-deploy 2026-05-29 19:05:07 +08:00
978fd61d14 Fix deployment instance current release label 2026-05-29 18:39:22 +08:00
2e759440b3 Add workflow API docs drawer to deployment tokens 2026-05-29 18:34:56 +08:00
7ab48f0112 fix(web): replace deployment title attrs with tooltips 2026-05-29 18:21:45 +08:00
e902aaa7ca Unify deployment access permission copy 2026-05-29 18:09:30 +08:00
1d4545bed1 Fix deployment lint and type check issues 2026-05-29 16:41:04 +08:00
4b73d7e5d8 Unify deployment empty states and source app links 2026-05-29 16:30:00 +08:00
0e484f43c6 Unify deployment empty states across detail views 2026-05-29 16:24:08 +08:00
b45e6d1d1a Update commit message generation 2026-05-29 16:09:10 +08:00
1c0193a18a Move deployment actions into the detail header 2026-05-29 15:58:18 +08:00
d98863802d Refactor deployment drawer for better layout and scrolling 2026-05-29 15:21:50 +08:00
28e606b5be [autofix.ci] apply automated fixes 2026-05-29 03:54:30 +00:00
65aa077694 Fix lint issues in deployment export and CLI store 2026-05-29 11:49:40 +08:00
7cf70a7b91 Polish deployment tables with content-driven widths 2026-05-29 11:04:32 +08:00
fa4af43853 Remove left border highlight from source app list 2026-05-29 10:45:55 +08:00
3958ef2cfe Merge branch 'main' into 4-27-app-deploy 2026-05-29 10:25:41 +08:00
1c83554c1d Restore release name field and lint deployment UI changes 2026-05-29 10:25:12 +08:00
d44973b7de Refine deployment terminology and guide flow 2026-05-28 18:49:29 +08:00
76037b1cdc Unify deployment detail empty states 2026-05-28 18:28:22 +08:00
b3b5c7a451 Refine deployment settings layout and actions 2026-05-28 17:20:09 +08:00
fac979b597 Polish deployment API tokens page 2026-05-28 17:03:07 +08:00
ae53ed273c Update commit message instructions 2026-05-28 16:53:19 +08:00
f9c28b09f0 Refine deployment terminology for projects releases and instances 2026-05-28 16:29:21 +08:00
8c6bac03b9 Rename deployment copy to project and release 2026-05-28 16:10:09 +08:00
1f1d3ed579 Update commit message generation instructions 2026-05-28 15:58:34 +08:00
f5ae94ed3c Refine deployment drawer bindings and empty environment states 2026-05-28 15:33:09 +08:00
9fd5a28598 Update commit message guidance 2026-05-28 15:11:32 +08:00
9d7e792802 Restore workflow keyboard key code helper 2026-05-28 14:40:51 +08:00
24a31838ce Merge branch 'main' into 4-27-app-deploy 2026-05-28 11:17:17 +08:00
257de6a2c7 Refine deployment card badges and access icons 2026-05-28 00:54:35 +08:00
2b49ae083f Update project instructions for Chinese responses 2026-05-28 00:17:47 +08:00
1d27872098 Refine deployments list card hierarchy and states 2026-05-27 23:27:37 +08:00
40e40cf2bb Require deployment names before continuing 2026-05-27 23:08:43 +08:00
082b223ab1 Refine deployment creation flow and restore method cards 2026-05-27 22:46:29 +08:00
cee6d5eeb9 Polish deployment creation wizard layout 2026-05-27 22:18:28 +08:00
9818880e00 Add release deletion support 2026-05-27 20:54:41 +08:00
f30e4fa7cb Remove source app left border highlight 2026-05-27 20:18:13 +08:00
ae66ec279f update 2026-05-27 19:16:24 +08:00
ea5073d692 Enforce Chinese responses in project instructions 2026-05-27 17:50:13 +08:00
42eda4ded8 Refactor app deploy layouts and deployment flow 2026-05-27 17:05:03 +08:00
2e8029f72f Allow deleting app instances without undeploy check 2026-05-27 10:50:38 +08:00
e8b0683450 Remove undeploy check before deleting deployments 2026-05-27 10:39:42 +08:00
b3c55e741f Merge branch 'main' into 4-27-app-deploy 2026-05-27 10:25:12 +08:00
yyh
f519192880 fix: align deployment access combobox async behavior 2026-05-26 19:33:08 +08:00
yyh
41a3c4f9b9 fix(deployments): refine access subject combobox states 2026-05-26 16:46:08 +08:00
yyh
5382686e07 Merge remote-tracking branch 'origin/main' into 4-27-app-deploy 2026-05-26 16:09:32 +08:00
4481c7ee90 Improve API token cURL example copy behavior 2026-05-26 16:00:19 +08:00
2f6d01e30a Use apiUrl from API token list response 2026-05-26 15:37:18 +08:00
96de78beab Add AGENTS instructions for the Dify workspace 2026-05-26 15:31:23 +08:00
af8c58fc6a Update project instructions for Chinese responses 2026-05-26 15:24:01 +08:00
6b6c1c56da Fix deployment card release loading flicker 2026-05-26 15:18:27 +08:00
d1f1c29660 Invalidate deployment list caches after initial deployment 2026-05-26 15:13:38 +08:00
6e782ab617 Refine API token creation dialog defaults 2026-05-26 13:04:34 +08:00
4103b340b7 Rename deployment API tokens route and labels 2026-05-26 12:59:12 +08:00
466e2973b8 Rename deployment API keys to tokens and harden dialog dismissal 2026-05-26 12:37:24 +08:00
ed0c5be71c Rename deployment API keys to tokens 2026-05-26 12:35:33 +08:00
a37c150556 Merge branch 'main' into 4-27-app-deploy 2026-05-26 12:32:44 +08:00
16789217bf Clarify AGENTS instructions for Chinese responses 2026-05-26 12:32:22 +08:00
2c6e02c7fe Refine developer API key creation dialog 2026-05-26 12:26:27 +08:00
41fe77d5b8 Update AGENTS.md workflow instructions 2026-05-26 10:39:41 +08:00
199a296fba Fix leaked conditional rendering in deployment sections 2026-05-26 10:26:55 +08:00
2850d32931 Fix web type-check errors 2026-05-26 10:14:47 +08:00
ca72c4433c Merge branch 'main' into 4-27-app-deploy 2026-05-26 10:01:32 +08:00
71e068a5ec update 2026-05-26 10:00:36 +08:00
2ab82cb13c Filter deployments by real environments 2026-05-25 22:50:42 +08:00
00b6bc4f04 Wire deployments list to environment filtering 2026-05-25 22:47:01 +08:00
d5c4725f94 update 2026-05-25 22:32:05 +08:00
d0d7f2d5e5 update 2026-05-25 22:13:39 +08:00
f2478a7466 update 2026-05-25 21:39:16 +08:00
b7df55e526 update 2026-05-25 21:15:21 +08:00
e21be9e8fe update 2026-05-25 21:13:04 +08:00
4531d93114 update 2026-05-25 21:06:12 +08:00
9d8c3280e2 update 2026-05-25 21:03:55 +08:00
480dc17685 update 2026-05-25 19:13:44 +08:00
b7ceb0d6a8 update 2026-05-25 19:08:38 +08:00
a2394a6d8e update 2026-05-25 18:41:53 +08:00
bd969670ab update 2026-05-25 18:27:05 +08:00
8d47a26212 fix remove child 2026-05-25 17:22:11 +08:00
17a4269df9 update 2026-05-25 16:07:45 +08:00
5f1b566213 update 2026-05-25 15:54:48 +08:00
5c58f78cc8 update 2026-05-25 15:11:15 +08:00
572527ae25 update 2026-05-25 14:15:11 +08:00
0d00c2a5f9 update 2026-05-25 13:59:18 +08:00
fded3932ad update 2026-05-25 13:42:51 +08:00
52765ad9cf update1 2026-05-25 13:36:35 +08:00
5b6d29f3bf update 2026-05-25 12:56:05 +08:00
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
214 changed files with 17296 additions and 3044 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:
- "*"

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

@ -15114,6 +15114,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
@ -253,6 +254,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

@ -5052,6 +5052,11 @@
"count": 1
}
},
"web/features/deployments/detail/versions-tab/release-dsl-export.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/hooks/use-async-window-open.spec.ts": {
"ts/no-explicit-any": {
"count": 6
@ -5145,11 +5150,6 @@
"count": 1
}
},
"web/models/access-control.ts": {
"erasable-syntax-only/enums": {
"count": 2
}
},
"web/models/app.ts": {
"erasable-syntax-only/enums": {
"count": 2
@ -5542,11 +5542,6 @@
"count": 1
}
},
"web/types/feature.ts": {
"erasable-syntax-only/enums": {
"count": 3
}
},
"web/types/lamejs.d.ts": {
"ts/no-explicit-any": {
"count": 3
@ -5562,11 +5557,6 @@
"count": 17
}
},
"web/utils/clipboard.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/utils/completion-params.spec.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,135 +4,505 @@ import { oc } from '@orpc/contract'
import * as z from 'zod'
import {
zConsoleSsoOAuth2LoginResponse,
zConsoleSsoOidcLoginResponse,
zConsoleSsoSamlLoginResponse,
zWebAppAuthGetGroupSubjectsQuery,
zWebAppAuthGetGroupSubjectsResponse,
zWebAppAuthGetWebAppAccessModeQuery,
zWebAppAuthGetWebAppAccessModeResponse,
zWebAppAuthGetWebAppWhitelistSubjectsQuery,
zWebAppAuthGetWebAppWhitelistSubjectsResponse,
zWebAppAuthIsUserAllowedToAccessWebAppQuery,
zWebAppAuthIsUserAllowedToAccessWebAppResponse,
zWebAppAuthSearchForWhilteListCandidatesQuery,
zWebAppAuthSearchForWhilteListCandidatesResponse,
zWebAppAuthUpdateWebAppWhitelistSubjectsBody,
zWebAppAuthUpdateWebAppWhitelistSubjectsResponse,
zAccessServiceCreateApiKeyBody,
zAccessServiceCreateApiKeyPath,
zAccessServiceCreateApiKeyResponse,
zAccessServiceDeleteApiKeyPath,
zAccessServiceDeleteApiKeyResponse,
zAccessServiceGetAccessChannelsPath,
zAccessServiceGetAccessChannelsResponse,
zAccessServiceGetAccessPolicyPath,
zAccessServiceGetAccessPolicyResponse,
zAccessServiceListApiKeysPath,
zAccessServiceListApiKeysResponse,
zAccessServicePutAccessPolicyBody,
zAccessServicePutAccessPolicyPath,
zAccessServicePutAccessPolicyResponse,
zAccessServiceUpdateAccessChannelsBody,
zAccessServiceUpdateAccessChannelsPath,
zAccessServiceUpdateAccessChannelsResponse,
zAppInstanceServiceCreateAppInstanceBody,
zAppInstanceServiceCreateAppInstanceResponse,
zAppInstanceServiceDeleteAppInstancePath,
zAppInstanceServiceDeleteAppInstanceResponse,
zAppInstanceServiceGetAppInstancePath,
zAppInstanceServiceGetAppInstanceResponse,
zAppInstanceServiceListAppInstancesQuery,
zAppInstanceServiceListAppInstancesResponse,
zAppInstanceServiceUpdateAppInstanceBody,
zAppInstanceServiceUpdateAppInstancePath,
zAppInstanceServiceUpdateAppInstanceResponse,
zDeploymentServiceCancelDeploymentBody,
zDeploymentServiceCancelDeploymentPath,
zDeploymentServiceCancelDeploymentResponse,
zDeploymentServiceCreateInitialDeploymentFromDslBody,
zDeploymentServiceCreateInitialDeploymentFromDslResponse,
zDeploymentServiceCreateInitialDeploymentFromSourceAppBody,
zDeploymentServiceCreateInitialDeploymentFromSourceAppResponse,
zDeploymentServiceDeployBody,
zDeploymentServiceDeployPath,
zDeploymentServiceDeployResponse,
zDeploymentServiceListDeploymentsPath,
zDeploymentServiceListDeploymentsQuery,
zDeploymentServiceListDeploymentsResponse,
zDeploymentServiceListEnvironmentDeploymentsPath,
zDeploymentServiceListEnvironmentDeploymentsResponse,
zDeploymentServicePromoteBody,
zDeploymentServicePromotePath,
zDeploymentServicePromoteResponse,
zDeploymentServiceRollbackBody,
zDeploymentServiceRollbackPath,
zDeploymentServiceRollbackResponse,
zDeploymentServiceUndeployBody,
zDeploymentServiceUndeployPath,
zDeploymentServiceUndeployResponse,
zEnvironmentServiceListDeployableEnvironmentsQuery,
zEnvironmentServiceListDeployableEnvironmentsResponse,
zReleaseServiceCreateReleaseFromDslBody,
zReleaseServiceCreateReleaseFromDslResponse,
zReleaseServiceCreateReleaseFromSourceAppBody,
zReleaseServiceCreateReleaseFromSourceAppResponse,
zReleaseServiceDeleteReleasePath,
zReleaseServiceDeleteReleaseResponse,
zReleaseServiceGetDeploymentOptionsFromDslBody,
zReleaseServiceGetDeploymentOptionsFromDslResponse,
zReleaseServiceGetDeploymentOptionsFromSourceAppBody,
zReleaseServiceGetDeploymentOptionsFromSourceAppResponse,
zReleaseServiceGetReleasePath,
zReleaseServiceGetReleaseResponse,
zReleaseServiceListReleaseCredentialCandidatesPath,
zReleaseServiceListReleaseCredentialCandidatesResponse,
zReleaseServiceListReleasesPath,
zReleaseServiceListReleasesQuery,
zReleaseServiceListReleasesResponse,
zReleaseServiceUpdateReleaseBody,
zReleaseServiceUpdateReleasePath,
zReleaseServiceUpdateReleaseResponse,
} from './zod.gen'
export const oAuth2Login = oc
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: 'ConsoleSSO_OAuth2Login',
path: '/enterprise/sso/oauth2/login',
tags: ['ConsoleSSO'],
operationId: 'AccessService_GetAccessChannels',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/access-channels',
tags: ['AccessService'],
})
.output(zConsoleSsoOAuth2LoginResponse)
.input(z.object({ params: zAccessServiceGetAccessChannelsPath }))
.output(zAccessServiceGetAccessChannelsResponse)
export const oidcLogin = oc
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: 'ConsoleSSO_OIDCLogin',
path: '/enterprise/sso/oidc/login',
tags: ['ConsoleSSO'],
operationId: 'AccessService_GetAccessPolicy',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/access-policy',
tags: ['AccessService'],
})
.output(zConsoleSsoOidcLoginResponse)
.input(z.object({ params: zAccessServiceGetAccessPolicyPath }))
.output(zAccessServiceGetAccessPolicyResponse)
export const samlLogin = oc
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: 'ConsoleSSO_SAMLLogin',
path: '/enterprise/sso/saml/login',
tags: ['ConsoleSSO'],
operationId: 'AccessService_ListApiKeys',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/api-keys',
tags: ['AccessService'],
})
.output(zConsoleSsoSamlLoginResponse)
.input(z.object({ params: zAccessServiceListApiKeysPath }))
.output(zAccessServiceListApiKeysResponse)
export const consoleSso = {
oAuth2Login,
oidcLogin,
samlLogin,
}
export const getWebAppAccessMode = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'WebAppAuth_GetWebAppAccessMode',
path: '/enterprise/webapp/app/access-mode',
tags: ['WebAppAuth'],
})
.input(z.object({ query: zWebAppAuthGetWebAppAccessModeQuery.optional() }))
.output(zWebAppAuthGetWebAppAccessModeResponse)
export const updateWebAppWhitelistSubjects = oc
export const createApiKey = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'WebAppAuth_UpdateWebAppWhitelistSubjects',
path: '/enterprise/webapp/app/access-mode',
tags: ['WebAppAuth'],
operationId: 'AccessService_CreateApiKey',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environments/{environmentId}/api-keys',
tags: ['AccessService'],
})
.input(z.object({ body: zWebAppAuthUpdateWebAppWhitelistSubjectsBody }))
.output(zWebAppAuthUpdateWebAppWhitelistSubjectsResponse)
.input(z.object({ body: zAccessServiceCreateApiKeyBody, params: zAccessServiceCreateApiKeyPath }))
.output(zAccessServiceCreateApiKeyResponse)
export const searchForWhilteListCandidates = oc
export const accessService = {
deleteApiKey,
getAccessChannels,
updateAccessChannels,
getAccessPolicy,
putAccessPolicy,
listApiKeys,
createApiKey,
}
export const listAppInstances = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'WebAppAuth_SearchForWhilteListCandidates',
path: '/enterprise/webapp/app/subject/search',
tags: ['WebAppAuth'],
operationId: 'AppInstanceService_ListAppInstances',
path: '/enterprise/app-deploy/app-instances',
tags: ['AppInstanceService'],
})
.input(z.object({ query: zWebAppAuthSearchForWhilteListCandidatesQuery.optional() }))
.output(zWebAppAuthSearchForWhilteListCandidatesResponse)
.input(z.object({ query: zAppInstanceServiceListAppInstancesQuery.optional() }))
.output(zAppInstanceServiceListAppInstancesResponse)
export const getWebAppWhitelistSubjects = oc
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: 'WebAppAuth_GetWebAppWhitelistSubjects',
path: '/enterprise/webapp/app/subjects',
tags: ['WebAppAuth'],
operationId: 'AppInstanceService_GetAppInstance',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}',
tags: ['AppInstanceService'],
})
.input(z.object({ query: zWebAppAuthGetWebAppWhitelistSubjectsQuery.optional() }))
.output(zWebAppAuthGetWebAppWhitelistSubjectsResponse)
.input(z.object({ params: zAppInstanceServiceGetAppInstancePath }))
.output(zAppInstanceServiceGetAppInstanceResponse)
export const getGroupSubjects = oc
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: 'WebAppAuth_GetGroupSubjects',
path: '/enterprise/webapp/group/subjects',
tags: ['WebAppAuth'],
operationId: 'DeploymentService_ListDeployments',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/deployments',
tags: ['DeploymentService'],
})
.input(z.object({ query: zWebAppAuthGetGroupSubjectsQuery.optional() }))
.output(zWebAppAuthGetGroupSubjectsResponse)
.input(
z.object({
params: zDeploymentServiceListDeploymentsPath,
query: zDeploymentServiceListDeploymentsQuery.optional(),
}),
)
.output(zDeploymentServiceListDeploymentsResponse)
export const isUserAllowedToAccessWebApp = oc
export const listEnvironmentDeployments = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'WebAppAuth_IsUserAllowedToAccessWebApp',
path: '/enterprise/webapp/permission',
tags: ['WebAppAuth'],
operationId: 'DeploymentService_ListEnvironmentDeployments',
path: '/enterprise/app-deploy/app-instances/{appInstanceId}/environment-deployments',
tags: ['DeploymentService'],
})
.input(z.object({ query: zWebAppAuthIsUserAllowedToAccessWebAppQuery.optional() }))
.output(zWebAppAuthIsUserAllowedToAccessWebAppResponse)
.input(z.object({ params: zDeploymentServiceListEnvironmentDeploymentsPath }))
.output(zDeploymentServiceListEnvironmentDeploymentsResponse)
export const webAppAuth = {
getWebAppAccessMode,
updateWebAppWhitelistSubjects,
searchForWhilteListCandidates,
getWebAppWhitelistSubjects,
getGroupSubjects,
isUserAllowedToAccessWebApp,
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 deleteRelease = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'ReleaseService_DeleteRelease',
path: '/enterprise/app-deploy/releases/{releaseId}',
tags: ['ReleaseService'],
})
.input(z.object({ params: zReleaseServiceDeleteReleasePath }))
.output(zReleaseServiceDeleteReleaseResponse)
export const getRelease = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ReleaseService_GetRelease',
path: '/enterprise/app-deploy/releases/{releaseId}',
tags: ['ReleaseService'],
})
.input(z.object({ params: zReleaseServiceGetReleasePath }))
.output(zReleaseServiceGetReleaseResponse)
export const updateRelease = oc
.route({
inputStructure: 'detailed',
method: 'PATCH',
operationId: 'ReleaseService_UpdateRelease',
path: '/enterprise/app-deploy/releases/{releaseId}',
tags: ['ReleaseService'],
})
.input(
z.object({ body: zReleaseServiceUpdateReleaseBody, params: zReleaseServiceUpdateReleasePath }),
)
.output(zReleaseServiceUpdateReleaseResponse)
export const listReleaseCredentialCandidates = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'ReleaseService_ListReleaseCredentialCandidates',
path: '/enterprise/app-deploy/releases/{releaseId}/credential-candidates',
tags: ['ReleaseService'],
})
.input(z.object({ params: zReleaseServiceListReleaseCredentialCandidatesPath }))
.output(zReleaseServiceListReleaseCredentialCandidatesResponse)
export const releaseService = {
listReleases,
getDeploymentOptionsFromDsl,
getDeploymentOptionsFromSourceApp,
createReleaseFromDsl,
createReleaseFromSourceApp,
deleteRelease,
getRelease,
updateRelease,
listReleaseCredentialCandidates,
}
export const listDeployableEnvironments = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'EnvironmentService_ListDeployableEnvironments',
path: '/enterprise/app-deploy/deployable-environments',
tags: ['EnvironmentService'],
})
.input(z.object({ query: zEnvironmentServiceListDeployableEnvironmentsQuery.optional() }))
.output(zEnvironmentServiceListDeployableEnvironmentsResponse)
export const environmentService = {
listDeployableEnvironments,
}
export const contract = {
consoleSso,
webAppAuth,
accessService,
appInstanceService,
deploymentService,
releaseService,
environmentService,
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,21 @@ type OpenApiDocument = JsonObject & {
paths?: Record<string, unknown>
}
type OpenApiMediaType = JsonObject & {
schema?: unknown
}
type OpenApiOperation = JsonObject & {
operationId?: string
responses?: Record<string, OpenApiResponse>
}
type OpenApiPathItem = Record<string, unknown>
type OpenApiResponse = JsonObject & {
content?: Record<string, OpenApiMediaType>
}
type ContractOperation = {
id: string
operationId?: string
@ -21,9 +36,26 @@ const enterpriseServerDir = process.env.DIFY_ENTERPRISE_SERVER
? path.resolve(process.env.DIFY_ENTERPRISE_SERVER)
: path.resolve(currentDir, '../../../dify-enterprise/server')
const enterpriseOpenApiPath = path.join(enterpriseServerDir, 'pkg/apis/enterprise/openapi.yaml')
const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put'])
const isConsoleApiPath = (routePath: string) => routePath.startsWith('/console/api/')
const isObject = (value: unknown): value is JsonObject => {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
const asOpenApiOperation = (value: unknown): OpenApiOperation | undefined => {
return isObject(value) ? value as OpenApiOperation : undefined
}
const asOpenApiResponse = (value: unknown): OpenApiResponse | undefined => {
return isObject(value) ? value as OpenApiResponse : undefined
}
const asOpenApiMediaType = (value: unknown): OpenApiMediaType | undefined => {
return isObject(value) ? value as OpenApiMediaType : undefined
}
const stripConsoleApiPrefix = (routePath: string) => {
if (isConsoleApiPath(routePath))
return routePath.replace('/console/api', '')
@ -34,9 +66,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 +88,37 @@ const contractNameSegments = (operation: ContractOperation) => {
}
const contractPathSegments = (operation: ContractOperation) => {
return [operation.tags?.[0] || 'default', ...contractNameSegments(operation)]
return [contractTagSegment(operation.tags?.[0]), ...contractNameSegments(operation)]
}
const hasSchemaLessResponseContent = (operation: OpenApiOperation) => {
if (!isObject(operation.responses))
return false
return Object.values(operation.responses).some((response) => {
const openApiResponse = asOpenApiResponse(response)
if (!openApiResponse || !isObject(openApiResponse.content))
return false
return Object.values(openApiResponse.content).some((mediaType) => {
const openApiMediaType = asOpenApiMediaType(mediaType)
return !!openApiMediaType && !('schema' in openApiMediaType)
})
})
}
// protoc-gen-openapi emits google.api.HttpBody responses as `*/*: {}`. Skip these
// raw download operations until the source OpenAPI exposes an explicit schema.
const stripSchemaLessResponseOperations = (pathItem: OpenApiPathItem) => {
return Object.fromEntries(
Object.entries(pathItem).filter(([method, operation]) => {
if (!operationMethods.has(method.toLowerCase()))
return true
const openApiOperation = asOpenApiOperation(operation)
return !openApiOperation || !hasSchemaLessResponseContent(openApiOperation)
}),
)
}
const normalizeEnterpriseOpenApi = () => {
@ -63,7 +133,13 @@ const normalizeEnterpriseOpenApi = () => {
document.paths = Object.fromEntries(
Object.entries(paths)
.filter(([routePath]) => isConsoleApiPath(routePath))
.map(([routePath, pathItem]) => [stripConsoleApiPrefix(routePath), pathItem]),
.map(([routePath, pathItem]) => {
if (!isObject(pathItem))
return [stripConsoleApiPrefix(routePath), pathItem]
return [stripConsoleApiPrefix(routePath), stripSchemaLessResponseOperations(pathItem)]
})
.filter(([, pathItem]) => !isObject(pathItem) || Object.keys(pathItem).length > 0),
)
return document

View File

@ -19,10 +19,6 @@ vi.mock('@/context/web-app-context', () => ({
useWebAppStore: vi.fn(),
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: vi.fn(),
}))

View File

@ -1,14 +1,8 @@
import * as React from 'react'
import ExternalKnowledgeBaseConnector from '@/app/components/datasets/external-knowledge-base/connector'
import { DocumentTitleSetter } from '../document-title-setter'
const ExternalKnowledgeBaseCreation = () => {
return (
<>
<DocumentTitleSetter i18nKey="pageTitle.connectExternalKnowledgeBase" namespace="dataset" />
<ExternalKnowledgeBaseConnector />
</>
)
return <ExternalKnowledgeBaseConnector />
}
export default ExternalKnowledgeBaseCreation

View File

@ -1,13 +1,9 @@
import * as React from 'react'
import CreateFromPipeline from '@/app/components/datasets/create-from-pipeline'
import { DocumentTitleSetter } from '../document-title-setter'
const DatasetCreation = async () => {
return (
<>
<DocumentTitleSetter i18nKey="creation.pageTitle" namespace="datasetPipeline" />
<CreateFromPipeline />
</>
<CreateFromPipeline />
)
}

View File

@ -1,13 +1,9 @@
import * as React from 'react'
import DatasetUpdateForm from '@/app/components/datasets/create'
import { DocumentTitleSetter } from '../document-title-setter'
const DatasetCreation = async () => {
return (
<>
<DocumentTitleSetter i18nKey="createDataset" namespace="dataset" />
<DatasetUpdateForm />
</>
<DatasetUpdateForm />
)
}

View File

@ -1,20 +0,0 @@
'use client'
import type { Namespace } from '@/i18n-config/resources'
import { useTranslation } from 'react-i18next'
import useDocumentTitle from '@/hooks/use-document-title'
type DocumentTitleSetterProps = {
i18nKey: string
namespace: Namespace
}
export function DocumentTitleSetter({
i18nKey,
namespace,
}: DocumentTitleSetterProps) {
const { t } = useTranslation()
useDocumentTitle(t(i18nKey, { ns: namespace }))
return null
}

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 InstanceDetailApiTokensPage({ 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 InstanceDetailInstancesPage({ 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

@ -1,18 +1,15 @@
'use client'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { FullScreenLoading } from '@/app/components/full-screen-loading'
import EducationApplyPage from '@/app/education-apply/education-apply-page'
import { useProviderContext } from '@/context/provider-context'
import useDocumentTitle from '@/hooks/use-document-title'
import {
useRouter,
useSearchParams,
} from '@/next/navigation'
export default function EducationApply() {
const { t } = useTranslation()
const router = useRouter()
const {
enableEducationPlan,
@ -21,7 +18,6 @@ export default function EducationApply() {
} = useProviderContext()
const searchParams = useSearchParams()
const token = searchParams.get('token')
useDocumentTitle(t('pageTitle.verification', { ns: 'education' }))
useEffect(() => {
if (!isFetchedPlanInfo)

View File

@ -6,7 +6,7 @@ import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import { GotoAnything } from '@/app/components/goto-anything'
import Header from '@/app/components/header'
import { Header } from '@/app/components/header'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import ReadmePanel from '@/app/components/plugins/readme-panel'

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

@ -0,0 +1,98 @@
'use client'
import type { ReactNode } from 'react'
import type { SpecificGroupsOrMembersProps } from './specific-groups-or-members'
import { Button } from '@langgenius/dify-ui/button'
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { useTranslation } from 'react-i18next'
import { AccessMode } from '@/models/access-control'
import AccessControlItem from './access-control-item'
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
type AccessControlDialogContentProps = {
title?: ReactNode
description?: ReactNode
accessLabel?: ReactNode
hideExternal?: boolean
hideExternalTip?: boolean
saving?: boolean
confirmDisabled?: boolean
specificGroupsOrMembersProps?: SpecificGroupsOrMembersProps
onClose: () => void
onConfirm: () => void
}
export function AccessControlDialogContent({
title,
description,
accessLabel,
hideExternal = false,
hideExternalTip = false,
saving = false,
confirmDisabled = false,
specificGroupsOrMembersProps,
onClose,
onConfirm,
}: AccessControlDialogContentProps) {
const { t } = useTranslation()
return (
<div className="flex flex-col gap-y-3">
<div className="pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">
{title ?? t('accessControlDialog.title', { ns: 'app' })}
</DialogTitle>
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">
{description ?? t('accessControlDialog.description', { ns: 'app' })}
</DialogDescription>
</div>
<div className="flex flex-col gap-y-1 px-6 pb-3">
<div className="leading-6">
<p className="system-sm-medium text-text-tertiary">
{accessLabel ?? t('accessControlDialog.accessLabel', { ns: 'app' })}
</p>
</div>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span className="i-ri-building-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t('accessControlDialog.accessItems.organization', { ns: 'app' })}
</p>
</div>
</div>
</AccessControlItem>
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
<SpecificGroupsOrMembers {...specificGroupsOrMembersProps} />
</AccessControlItem>
{!hideExternal && (
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<span className="i-ri-verified-badge-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t('accessControlDialog.accessItems.external', { ns: 'app' })}
</p>
</div>
{!hideExternalTip && <WebAppSSONotEnabledTip />}
</div>
</AccessControlItem>
)}
<AccessControlItem type={AccessMode.PUBLIC}>
<div className="flex items-center gap-x-2 p-3">
<span className="i-ri-global-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">
{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}
</p>
</div>
</AccessControlItem>
</div>
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
<Button disabled={saving} onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button disabled={confirmDisabled || saving} loading={saving} variant="primary" onClick={onConfirm}>
{t('operation.confirm', { ns: 'common' })}
</Button>
</div>
</div>
)
}

View File

@ -1,34 +1,25 @@
'use client'
import type { FC, PropsWithChildren } from 'react'
import type { PropsWithChildren } from 'react'
import type { AccessMode } from '@/models/access-control'
import { AccessControlOptionCard } from '@/app/components/base/access-control-option-card'
import useAccessControlStore from '@/context/access-control-store'
type AccessControlItemProps = PropsWithChildren<{
type: AccessMode
}>
const AccessControlItem: FC<AccessControlItemProps> = ({ type, children }) => {
function AccessControlItem({ type, children }: AccessControlItemProps) {
const currentMenu = useAccessControlStore(s => s.currentMenu)
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
if (currentMenu !== type) {
return (
<div
className="cursor-pointer rounded-[10px] border
border-components-option-card-option-border bg-components-option-card-option-bg
hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover"
onClick={() => setCurrentMenu(type)}
>
{children}
</div>
)
}
const selected = currentMenu === type
return (
<div className="rounded-[10px] border-[1.5px]
border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm"
<AccessControlOptionCard
selected={selected}
onSelect={selected ? undefined : () => setCurrentMenu(type)}
>
{children}
</div>
</AccessControlOptionCard>
)
}

View File

@ -1,369 +1,28 @@
'use client'
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxItem,
ComboboxItemText,
ComboboxList,
ComboboxStatus,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react'
import { useDebounce } from 'ahooks'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from '@/context/app-context'
import { SubjectType } from '@/models/access-control'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
AccessSubjectAddButton,
} from '@/app/components/base/access-subject-selector'
import useAccessControlStore from '../../../../context/access-control-store'
import Loading from '../../base/loading'
export default function AddMemberOrGroupDialog() {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const scrollRootRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const specificGroups = useAccessControlStore(s => s.specificGroups)
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, open)
const pages = data?.pages ?? []
const subjects = pages.flatMap(page => page.subjects ?? [])
const selectedSubjects = [
...specificGroups.map(groupToSubject),
...specificMembers.map(memberToSubject),
]
const hasResults = pages.length > 0 && subjects.length > 0
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
const hasMore = pages[pages.length - 1]?.hasMore ?? false
useEffect(() => {
let observer: IntersectionObserver | undefined
if (anchorRef.current) {
observer = new IntersectionObserver((entries) => {
if (entries[0]!.isIntersecting && !isLoading && hasMore)
fetchNextPage()
}, { root: scrollRootRef.current, rootMargin: '20px' })
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [isLoading, fetchNextPage, hasMore])
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen)
setKeyword('')
setOpen(nextOpen)
}
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
if (details.reason !== 'item-press')
setKeyword(inputValue)
}
const handleValueChange = (nextSubjects: Subject[]) => {
const nextGroups: AccessControlGroup[] = []
const nextMembers: AccessControlAccount[] = []
for (const subject of nextSubjects) {
if (subject.subjectType === SubjectType.GROUP)
nextGroups.push((subject as SubjectGroup).groupData)
else
nextMembers.push((subject as SubjectAccount).accountData)
}
setSpecificGroups(nextGroups)
setSpecificMembers(nextMembers)
}
return (
<Combobox<Subject, true>
multiple
open={open}
value={selectedSubjects}
inputValue={keyword}
items={subjects}
itemToStringLabel={getSubjectLabel}
itemToStringValue={getSubjectValue}
isItemEqualToValue={isSameSubject}
filter={null}
onOpenChange={handleOpenChange}
onInputValueChange={handleInputValueChange}
onValueChange={handleValueChange}
>
<ComboboxTrigger
aria-label={t('operation.add', { ns: 'common' })}
icon={false}
size="small"
className="flex h-6 w-auto shrink-0 items-center gap-x-0.5 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover"
>
<RiAddCircleFill className="size-4" aria-hidden="true" />
<span>{t('operation.add', { ns: 'common' })}</span>
</ComboboxTrigger>
<ComboboxContent
placement="bottom-end"
alignOffset={300}
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
>
<div ref={scrollRootRef} className="min-h-0 overflow-y-auto">
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<ComboboxInput
aria-label={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
placeholder={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
</div>
{isLoading
? (
<ComboboxStatus className="p-1">
<Loading />
</ComboboxStatus>
)
: (
<>
{shouldShowBreadcrumb && (
<div className="flex h-7 items-center px-2 py-0.5">
<SelectedGroupsBreadCrumb />
</div>
)}
{hasResults
? (
<>
<ComboboxList className="max-h-none p-1">
{(subject: Subject) => <SubjectItem key={getSubjectValue(subject)} subject={subject} />}
</ComboboxList>
{isFetchingNextPage && <Loading />}
<div ref={anchorRef} className="h-0" />
</>
)
: (
<ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5">
{t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })}
</ComboboxEmpty>
)}
</>
)}
</div>
</ComboboxContent>
</Combobox>
)
}
function groupToSubject(group: AccessControlGroup): SubjectGroup {
return {
subjectId: group.id,
subjectType: SubjectType.GROUP,
groupData: group,
}
}
function memberToSubject(member: AccessControlAccount): SubjectAccount {
return {
subjectId: member.id,
subjectType: SubjectType.ACCOUNT,
accountData: member,
}
}
function getSubjectLabel(subject: Subject) {
if (subject.subjectType === SubjectType.GROUP)
return (subject as SubjectGroup).groupData.name
return (subject as SubjectAccount).accountData.name
}
function getSubjectValue(subject: Subject) {
return `${subject.subjectType}:${subject.subjectId}`
}
function isSameSubject(item: Subject, value: Subject) {
return item.subjectId === value.subjectId && item.subjectType === value.subjectType
}
function SubjectItem({ subject }: { subject: Subject }) {
if (subject.subjectType === SubjectType.GROUP)
return <GroupItem group={(subject as SubjectGroup).groupData} subject={subject} />
return <MemberItem member={(subject as SubjectAccount).accountData} subject={subject} />
}
function SelectedGroupsBreadCrumb() {
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
const { t } = useTranslation()
const handleBreadCrumbClick = (index: number) => {
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
setSelectedGroupsForBreadcrumb(newGroups)
}
const handleReset = () => {
setSelectedGroupsForBreadcrumb([])
}
const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0
return (
<div className="flex h-7 items-center gap-x-0.5 px-2 py-0.5">
{hasBreadcrumb
? (
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={handleReset}
>
{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}
</button>
)
: (
<span className="system-xs-regular text-text-tertiary">{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}</span>
)}
{selectedGroupsForBreadcrumb.map((group, index) => {
const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1
return (
<div key={index} className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary">
<span>/</span>
{isLastGroup
? <span>{group.name}</span>
: (
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() => handleBreadCrumbClick(index)}
>
{group.name}
</button>
)}
</div>
)
})}
</div>
)
}
type GroupItemProps = {
group: AccessControlGroup
subject: Subject
}
function GroupItem({ group, subject }: GroupItemProps) {
const { t } = useTranslation()
const specificGroups = useAccessControlStore(s => s.specificGroups)
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
const isChecked = specificGroups.some(g => g.id === group.id)
const handleExpandClick = () => {
setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])
}
return (
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
<BaseItem subject={subject}>
<SelectionBox checked={isChecked} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
</ComboboxItemText>
</BaseItem>
<Button
size="small"
disabled={isChecked}
variant="ghost-accent"
className="mr-1 flex shrink-0 items-center justify-between px-1.5 py-1"
onPointerDown={event => event.preventDefault()}
onClick={handleExpandClick}
>
<span className="px-[3px]">{t('accessControlDialog.operateGroupAndMember.expand', { ns: 'app' })}</span>
<RiArrowRightSLine className="size-4" aria-hidden="true" />
</Button>
</div>
)
}
type MemberItemProps = {
member: AccessControlAccount
subject: Subject
}
function MemberItem({ member, subject }: MemberItemProps) {
const currentUser = useSelector(s => s.userProfile)
const { t } = useTranslation()
const specificMembers = useAccessControlStore(s => s.specificMembers)
const isChecked = specificMembers.some(m => m.id === member.id)
return (
<BaseItem subject={subject} className="pr-3">
<SelectionBox checked={isChecked} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
<Avatar size="xxs" avatar={null} name={member.name} />
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
{currentUser.email === member.email && (
<span className="system-xs-regular text-text-tertiary">
(
{t('you', { ns: 'common' })}
)
</span>
)}
</ComboboxItemText>
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
</BaseItem>
)
}
type BaseItemProps = {
className?: string
subject: Subject
children: React.ReactNode
}
function BaseItem({ children, className, subject }: BaseItemProps) {
return (
<ComboboxItem
value={subject}
className={cn(
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
className,
)}
>
{children}
</ComboboxItem>
)
}
function SelectionBox({ checked }: { checked: boolean }) {
return (
<span
aria-hidden="true"
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3',
checked
? 'bg-components-checkbox-bg text-components-checkbox-icon'
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
)}
>
{checked && <span className="i-ri-check-line size-3" />}
</span>
<AccessSubjectAddButton
selectedGroups={specificGroups}
selectedMembers={specificMembers}
breadcrumbGroups={selectedGroupsForBreadcrumb}
onBreadcrumbGroupsChange={setSelectedGroupsForBreadcrumb}
onChange={({ groups, members }) => {
setSpecificGroups(groups)
setSpecificMembers(members)
}}
/>
)
}

View File

@ -1,10 +1,7 @@
'use client'
import type { Subject } from '@/models/access-control'
import type { App } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
import { toast } from '@langgenius/dify-ui/toast'
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
@ -13,8 +10,7 @@ import { useUpdateAccessMode } from '@/service/access-control'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import useAccessControlStore from '../../../../context/access-control-store'
import AccessControlDialog from './access-control-dialog'
import AccessControlItem from './access-control-item'
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
import { AccessControlDialogContent } from './access-control-dialog-content'
type AccessControlProps = {
app: App
@ -31,7 +27,7 @@ export default function AccessControl(props: AccessControlProps) {
const specificMembers = useAccessControlStore(s => s.specificMembers)
const currentMenu = useAccessControlStore(s => s.currentMenu)
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
const hideTip = systemFeatures.webapp_auth.enabled
const hideExternalTip = systemFeatures.webapp_auth.enabled
&& (systemFeatures.webapp_auth.allow_sso
|| systemFeatures.webapp_auth.allow_email_password_login
|| systemFeatures.webapp_auth.allow_email_code_login)
@ -67,47 +63,12 @@ export default function AccessControl(props: AccessControlProps) {
}, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu])
return (
<AccessControlDialog show onClose={onClose}>
<div className="flex flex-col gap-y-3">
<div className="pt-6 pr-14 pb-3 pl-6">
<DialogTitle className="title-2xl-semi-bold text-text-primary">{t('accessControlDialog.title', { ns: 'app' })}</DialogTitle>
<DialogDescription className="mt-1 system-xs-regular text-text-tertiary">{t('accessControlDialog.description', { ns: 'app' })}</DialogDescription>
</div>
<div className="flex flex-col gap-y-1 px-6 pb-3">
<div className="leading-6">
<p className="system-sm-medium text-text-tertiary">{t('accessControlDialog.accessLabel', { ns: 'app' })}</p>
</div>
<AccessControlItem type={AccessMode.ORGANIZATION}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<RiBuildingLine className="size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.organization', { ns: 'app' })}</p>
</div>
</div>
</AccessControlItem>
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
<SpecificGroupsOrMembers />
</AccessControlItem>
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<RiVerifiedBadgeLine className="size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.external', { ns: 'app' })}</p>
</div>
{!hideTip && <WebAppSSONotEnabledTip />}
</div>
</AccessControlItem>
<AccessControlItem type={AccessMode.PUBLIC}>
<div className="flex items-center gap-x-2 p-3">
<RiGlobalLine className="size-4 text-text-primary" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.anyone', { ns: 'app' })}</p>
</div>
</AccessControlItem>
</div>
<div className="flex items-center justify-end gap-x-2 p-6 pt-5">
<Button onClick={onClose}>{t('operation.cancel', { ns: 'common' })}</Button>
<Button disabled={isPending} loading={isPending} variant="primary" onClick={handleConfirm}>{t('operation.confirm', { ns: 'common' })}</Button>
</div>
</div>
<AccessControlDialogContent
hideExternalTip={hideExternalTip}
saving={isPending}
onClose={onClose}
onConfirm={handleConfirm}
/>
</AccessControlDialog>
)
}

View File

@ -1,34 +1,46 @@
'use client'
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react'
import { useCallback, useEffect } from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessSubjectSelectionList } from '@/app/components/base/access-subject-selector'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control'
import useAccessControlStore from '../../../../context/access-control-store'
import { Infotip } from '../../base/infotip'
import Loading from '../../base/loading'
import AddMemberOrGroupDialog from './add-member-or-group-pop'
export default function SpecificGroupsOrMembers() {
export type SpecificGroupsOrMembersProps = {
loadSubjects?: boolean
loading?: boolean
}
export default function SpecificGroupsOrMembers({
loadSubjects = true,
loading = false,
}: SpecificGroupsOrMembersProps) {
const currentMenu = useAccessControlStore(s => s.currentMenu)
const appId = useAccessControlStore(s => s.appId)
const specificGroups = useAccessControlStore(s => s.specificGroups)
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const { t } = useTranslation()
const { isPending, data } = useAppWhiteListSubjects(appId, Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS)
const { isPending, data } = useAppWhiteListSubjects(
appId,
loadSubjects && Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS,
)
useEffect(() => {
if (!loadSubjects)
return
setSpecificGroups(data?.groups ?? [])
setSpecificMembers(data?.members ?? [])
}, [data, setSpecificGroups, setSpecificMembers])
}, [data, loadSubjects, setSpecificGroups, setSpecificMembers])
if (currentMenu !== AccessMode.SPECIFIC_GROUPS_MEMBERS) {
return (
<div className="flex items-center p-3">
<div className="flex grow items-center gap-x-2">
<RiLockLine className="size-4 text-text-primary" />
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
</div>
</div>
@ -39,7 +51,7 @@ export default function SpecificGroupsOrMembers() {
<div>
<div className="flex items-center gap-x-1 p-3">
<div className="flex grow items-center gap-x-1">
<RiLockLine className="size-4 text-text-primary" />
<span className="i-ri-lock-line size-4 text-text-primary" aria-hidden="true" />
<p className="system-sm-medium text-text-primary">{t('accessControlDialog.accessItems.specific', { ns: 'app' })}</p>
</div>
<div className="flex items-center gap-x-1">
@ -47,101 +59,20 @@ export default function SpecificGroupsOrMembers() {
</div>
</div>
<div className="px-1 pb-1">
<div className="flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2">
{isPending ? <Loading /> : <RenderGroupsAndMembers />}
</div>
<AccessSubjectSelectionList
selectedGroups={specificGroups}
selectedMembers={specificMembers}
loading={loadSubjects ? isPending : loading}
onChange={({ groups, members }) => {
setSpecificGroups(groups)
setSpecificMembers(members)
}}
/>
</div>
</div>
)
}
function RenderGroupsAndMembers() {
const { t } = useTranslation()
const specificGroups = useAccessControlStore(s => s.specificGroups)
const specificMembers = useAccessControlStore(s => s.specificMembers)
if (specificGroups.length <= 0 && specificMembers.length <= 0)
return <div className="px-2 pt-5 pb-1.5"><p className="text-center system-xs-regular text-text-tertiary">{t('accessControlDialog.noGroupsOrMembers', { ns: 'app' })}</p></div>
return (
<>
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">{t('accessControlDialog.groups', { ns: 'app', count: specificGroups.length ?? 0 })}</p>
<div className="flex flex-row flex-wrap gap-1">
{specificGroups.map((group, index) => <GroupItem key={index} group={group} />)}
</div>
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">{t('accessControlDialog.members', { ns: 'app', count: specificMembers.length ?? 0 })}</p>
<div className="flex flex-row flex-wrap gap-1">
{specificMembers.map((member, index) => <MemberItem key={index} member={member} />)}
</div>
</>
)
}
type GroupItemProps = {
group: AccessControlGroup
}
function GroupItem({ group }: GroupItemProps) {
const specificGroups = useAccessControlStore(s => s.specificGroups)
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
const handleRemoveGroup = useCallback(() => {
setSpecificGroups(specificGroups.filter(g => g.id !== group.id))
}, [group, setSpecificGroups, specificGroups])
return (
<BaseItem
icon={<RiOrganizationChart className="h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" />}
onRemove={handleRemoveGroup}
>
<p className="system-xs-regular text-text-primary">{group.name}</p>
<p className="system-xs-regular text-text-tertiary">{group.groupSize}</p>
</BaseItem>
)
}
type MemberItemProps = {
member: AccessControlAccount
}
function MemberItem({ member }: MemberItemProps) {
const specificMembers = useAccessControlStore(s => s.specificMembers)
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
const handleRemoveMember = useCallback(() => {
setSpecificMembers(specificMembers.filter(m => m.id !== member.id))
}, [member, setSpecificMembers, specificMembers])
return (
<BaseItem
icon={<Avatar size="xxs" avatar={null} name={member.name} />}
onRemove={handleRemoveMember}
>
<p className="system-xs-regular text-text-primary">{member.name}</p>
</BaseItem>
)
}
type BaseItemProps = {
icon: React.ReactNode
children: React.ReactNode
onRemove?: () => void
}
function BaseItem({ icon, onRemove, children }: BaseItemProps) {
const { t } = useTranslation()
return (
<div className="group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs">
<div className="size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="bg-access-app-icon-mask-bg flex size-full items-center justify-center">
{icon}
</div>
</div>
{children}
<button
type="button"
className="flex size-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
aria-label={t('operation.remove', { ns: 'common' })}
onClick={onRemove}
>
<RiCloseCircleFill className="h-[14px] w-[14px] text-text-quaternary" aria-hidden="true" />
</button>
</div>
)
}
export function WebAppSSONotEnabledTip() {
const { t } = useTranslation()
const tip = t('accessControlDialog.webAppSSONotEnabledTip', { ns: 'app' })

View File

@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AccessControlOptionCard } from '../index'
describe('AccessControlOptionCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render selected content with selected styles', () => {
render(
<AccessControlOptionCard selected>
<span>Selected access</span>
</AccessControlOptionCard>,
)
const card = screen.getByText('Selected access').parentElement
expect(card).toHaveClass('border-components-option-card-option-selected-border')
expect(card).toHaveClass('bg-components-option-card-option-selected-bg')
})
it('should call onSelect when clicked', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<AccessControlOptionCard onSelect={onSelect}>
<span>Selectable access</span>
</AccessControlOptionCard>,
)
await user.click(screen.getByRole('button', { name: 'Selectable access' }))
expect(onSelect).toHaveBeenCalledTimes(1)
})
it('should call onSelect from keyboard activation', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<AccessControlOptionCard onSelect={onSelect}>
<span>Keyboard access</span>
</AccessControlOptionCard>,
)
const card = screen.getByRole('button', { name: 'Keyboard access' })
card.focus()
await user.keyboard('{Enter}')
await user.keyboard(' ')
expect(onSelect).toHaveBeenCalledTimes(2)
})
it('should not call onSelect when disabled', async () => {
const user = userEvent.setup()
const onSelect = vi.fn()
render(
<AccessControlOptionCard disabled onSelect={onSelect}>
<span>Disabled access</span>
</AccessControlOptionCard>,
)
await user.click(screen.getByText('Disabled access'))
expect(onSelect).not.toHaveBeenCalled()
expect(screen.getByText('Disabled access').parentElement).toHaveAttribute('aria-disabled', 'true')
})
})

View File

@ -0,0 +1,59 @@
'use client'
import type { ComponentPropsWithoutRef, KeyboardEvent } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
type AccessControlOptionCardProps = Omit<ComponentPropsWithoutRef<'div'>, 'onSelect'> & {
selected?: boolean
disabled?: boolean
onSelect?: () => void
}
export function AccessControlOptionCard({
selected = false,
disabled = false,
className,
onClick,
onKeyDown,
onSelect,
...props
}: AccessControlOptionCardProps) {
const interactive = Boolean(onSelect) && !disabled
const handleClick: ComponentPropsWithoutRef<'div'>['onClick'] = (event) => {
onClick?.(event)
if (!event.defaultPrevented && interactive)
onSelect?.()
}
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented || !interactive)
return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onSelect?.()
}
}
return (
<div
role={interactive ? 'button' : undefined}
tabIndex={interactive ? 0 : undefined}
aria-disabled={disabled || undefined}
aria-pressed={interactive ? selected : undefined}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
selected
? 'rounded-[10px] border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm'
: 'rounded-[10px] border border-components-option-card-option-border bg-components-option-card-option-bg',
interactive && 'cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
disabled && 'cursor-not-allowed opacity-50',
className,
)}
{...props}
/>
)
}

View File

@ -0,0 +1,607 @@
'use client'
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
import type { ReactNode } from 'react'
import type {
AccessControlAccount,
AccessControlGroup,
Subject,
SubjectAccount,
SubjectGroup,
} from '@/models/access-control'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxItem,
ComboboxItemText,
ComboboxList,
ComboboxStatus,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { useDebounce } from 'ahooks'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from '@/context/app-context'
import { SubjectType } from '@/models/access-control'
import { useSearchForWhiteListCandidates } from '@/service/access-control'
import Loading from '../loading'
export type AccessSubjectSelectionValue = {
groups: AccessControlGroup[]
members: AccessControlAccount[]
}
type AccessSubjectSelectionProps = {
selectedGroups: AccessControlGroup[]
selectedMembers: AccessControlAccount[]
onChange: (value: AccessSubjectSelectionValue) => void
}
type AccessSubjectAddButtonProps = AccessSubjectSelectionProps & {
disabled?: boolean
breadcrumbGroups?: AccessControlGroup[]
onBreadcrumbGroupsChange?: (groups: AccessControlGroup[]) => void
}
export function AccessSubjectAddButton({
selectedGroups,
selectedMembers,
onChange,
disabled,
breadcrumbGroups,
onBreadcrumbGroupsChange,
}: AccessSubjectAddButtonProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [keyword, setKeyword] = useState('')
const [internalBreadcrumbGroups, setInternalBreadcrumbGroups] = useState<AccessControlGroup[]>([])
const scrollRootRef = useRef<HTMLDivElement>(null)
const anchorRef = useRef<HTMLDivElement>(null)
const selectedGroupsForBreadcrumb = breadcrumbGroups ?? internalBreadcrumbGroups
const setSelectedGroupsForBreadcrumb = onBreadcrumbGroupsChange ?? setInternalBreadcrumbGroups
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({
keyword: debouncedKeyword,
groupId: lastAvailableGroup?.id,
resultsPerPage: 10,
}, open && !disabled)
const pages = data?.pages ?? []
const subjects = pages.flatMap(page => page.subjects ?? [])
const selectedSubjects = [
...selectedGroups.map(groupToSubject),
...selectedMembers.map(memberToSubject),
]
const hasResults = pages.length > 0 && subjects.length > 0
const shouldShowBreadcrumb = hasResults || selectedGroupsForBreadcrumb.length > 0
const hasMore = pages[pages.length - 1]?.hasMore ?? false
useEffect(() => {
let observer: IntersectionObserver | undefined
if (anchorRef.current) {
observer = new IntersectionObserver((entries) => {
if (entries[0]!.isIntersecting && !isLoading && !isFetchingNextPage && hasMore)
fetchNextPage()
}, { root: scrollRootRef.current, rootMargin: '20px' })
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [fetchNextPage, hasMore, isFetchingNextPage, isLoading])
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen && disabled)
return
if (!nextOpen)
setKeyword('')
setOpen(nextOpen)
}
const handleInputValueChange = (inputValue: string, details: ComboboxRootChangeEventDetails) => {
if (!disabled && details.reason !== 'item-press')
setKeyword(inputValue)
}
const handleValueChange = (nextSubjects: Subject[]) => {
const nextGroups: AccessControlGroup[] = []
const nextMembers: AccessControlAccount[] = []
for (const subject of nextSubjects) {
if (subject.subjectType === SubjectType.GROUP)
nextGroups.push((subject as SubjectGroup).groupData)
else
nextMembers.push((subject as SubjectAccount).accountData)
}
onChange({
groups: nextGroups,
members: nextMembers,
})
}
return (
<Combobox<Subject, true>
multiple
open={open}
value={selectedSubjects}
inputValue={keyword}
items={subjects}
disabled={disabled}
itemToStringLabel={getSubjectLabel}
itemToStringValue={getSubjectValue}
isItemEqualToValue={isSameSubject}
filter={null}
onOpenChange={handleOpenChange}
onInputValueChange={handleInputValueChange}
onValueChange={handleValueChange}
>
<ComboboxTrigger
aria-label={t('operation.add', { ns: 'common' })}
icon={false}
size="small"
disabled={disabled}
className="h-6 w-auto min-w-[52px] shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover"
>
<span className="inline-flex min-w-0 items-center justify-center gap-x-0.5 whitespace-nowrap">
<span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" />
<span className="shrink-0">{t('operation.add', { ns: 'common' })}</span>
</span>
</ComboboxTrigger>
<ComboboxContent
placement="bottom-end"
alignOffset={300}
popupClassName="relative flex max-h-[400px] w-[400px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-0 shadow-lg backdrop-blur-[5px]"
>
<div ref={scrollRootRef} className="min-h-0 overflow-y-auto">
<div className="sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span className="mr-0.5 i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<ComboboxInput
aria-label={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
placeholder={t('accessControlDialog.operateGroupAndMember.searchPlaceholder', { ns: 'app' })}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
</div>
{isLoading
? (
<ComboboxStatus className="p-1">
<Loading />
</ComboboxStatus>
)
: (
<>
{shouldShowBreadcrumb && (
<div className="flex h-7 items-center px-2 py-0.5">
<SelectedGroupsBreadCrumb
selectedGroupsForBreadcrumb={selectedGroupsForBreadcrumb}
onChange={setSelectedGroupsForBreadcrumb}
/>
</div>
)}
{hasResults
? (
<>
<ComboboxList className="max-h-none p-1">
{(subject: Subject) => (
<SubjectItem
key={getSubjectValue(subject)}
subject={subject}
selectedGroups={selectedGroups}
selectedMembers={selectedMembers}
onExpandGroup={group => setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])}
/>
)}
</ComboboxList>
{isFetchingNextPage && <Loading />}
<div ref={anchorRef} className="h-0" />
</>
)
: (
<ComboboxEmpty className="flex h-7 items-center justify-center px-2 py-0.5">
{t('accessControlDialog.operateGroupAndMember.noResult', { ns: 'app' })}
</ComboboxEmpty>
)}
</>
)}
</div>
</ComboboxContent>
</Combobox>
)
}
type AccessSubjectSelectionListProps = AccessSubjectSelectionProps & {
loading?: boolean
className?: string
}
export function AccessSubjectSelectionList({
selectedGroups,
selectedMembers,
onChange,
loading,
className,
}: AccessSubjectSelectionListProps) {
return (
<div className={cn('flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2', className)}>
{loading
? <Loading />
: (
<RenderGroupsAndMembers
selectedGroups={selectedGroups}
selectedMembers={selectedMembers}
onChange={onChange}
/>
)}
</div>
)
}
function RenderGroupsAndMembers({
selectedGroups,
selectedMembers,
onChange,
}: AccessSubjectSelectionProps) {
const { t } = useTranslation()
if (selectedGroups.length <= 0 && selectedMembers.length <= 0) {
return (
<div className="px-2 pt-5 pb-1.5">
<p className="text-center system-xs-regular text-text-tertiary">
{t('accessControlDialog.noGroupsOrMembers', { ns: 'app' })}
</p>
</div>
)
}
return (
<>
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">
{t('accessControlDialog.groups', { ns: 'app', count: selectedGroups.length ?? 0 })}
</p>
<div className="flex flex-row flex-wrap gap-1">
{selectedGroups.map(group => (
<SelectedGroupItem
key={group.id}
group={group}
selectedGroups={selectedGroups}
selectedMembers={selectedMembers}
onChange={onChange}
/>
))}
</div>
<p className="sticky top-0 system-2xs-medium-uppercase text-text-tertiary">
{t('accessControlDialog.members', { ns: 'app', count: selectedMembers.length ?? 0 })}
</p>
<div className="flex flex-row flex-wrap gap-1">
{selectedMembers.map(member => (
<SelectedMemberItem
key={member.id}
member={member}
selectedGroups={selectedGroups}
selectedMembers={selectedMembers}
onChange={onChange}
/>
))}
</div>
</>
)
}
function groupToSubject(group: AccessControlGroup): SubjectGroup {
return {
subjectId: group.id,
subjectType: SubjectType.GROUP,
groupData: group,
}
}
function memberToSubject(member: AccessControlAccount): SubjectAccount {
return {
subjectId: member.id,
subjectType: SubjectType.ACCOUNT,
accountData: member,
}
}
function getSubjectLabel(subject: Subject) {
if (subject.subjectType === SubjectType.GROUP)
return (subject as SubjectGroup).groupData.name
return (subject as SubjectAccount).accountData.name
}
function getSubjectValue(subject: Subject) {
return `${subject.subjectType}:${subject.subjectId}`
}
function isSameSubject(item: Subject, value: Subject) {
return item.subjectId === value.subjectId && item.subjectType === value.subjectType
}
function SubjectItem({
subject,
selectedGroups,
selectedMembers,
onExpandGroup,
}: {
subject: Subject
selectedGroups: AccessControlGroup[]
selectedMembers: AccessControlAccount[]
onExpandGroup: (group: AccessControlGroup) => void
}) {
if (subject.subjectType === SubjectType.GROUP) {
return (
<GroupItem
group={(subject as SubjectGroup).groupData}
subject={subject}
selectedGroups={selectedGroups}
onExpandGroup={onExpandGroup}
/>
)
}
return (
<MemberItem
member={(subject as SubjectAccount).accountData}
subject={subject}
selectedMembers={selectedMembers}
/>
)
}
function SelectedGroupsBreadCrumb({
selectedGroupsForBreadcrumb,
onChange,
}: {
selectedGroupsForBreadcrumb: AccessControlGroup[]
onChange: (groups: AccessControlGroup[]) => void
}) {
const { t } = useTranslation()
const handleBreadCrumbClick = (index: number) => {
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
onChange(newGroups)
}
const handleReset = () => {
onChange([])
}
const hasBreadcrumb = selectedGroupsForBreadcrumb.length > 0
return (
<div className="flex h-7 items-center gap-x-0.5 px-2 py-0.5">
{hasBreadcrumb
? (
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={handleReset}
>
{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}
</button>
)
: (
<span className="system-xs-regular text-text-tertiary">{t('accessControlDialog.operateGroupAndMember.allMembers', { ns: 'app' })}</span>
)}
{selectedGroupsForBreadcrumb.map((group, index) => {
const isLastGroup = index === selectedGroupsForBreadcrumb.length - 1
return (
<div key={group.id} className="flex items-center gap-x-0.5 system-xs-regular text-text-tertiary">
<span>/</span>
{isLastGroup
? <span>{group.name}</span>
: (
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 text-left system-xs-regular text-text-accent focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
onClick={() => handleBreadCrumbClick(index)}
>
{group.name}
</button>
)}
</div>
)
})}
</div>
)
}
type GroupItemProps = {
group: AccessControlGroup
subject: Subject
selectedGroups: AccessControlGroup[]
onExpandGroup: (group: AccessControlGroup) => void
}
function GroupItem({ group, subject, selectedGroups, onExpandGroup }: GroupItemProps) {
const { t } = useTranslation()
const isChecked = selectedGroups.some(selectedGroup => selectedGroup.id === group.id)
return (
<div className="flex items-center gap-2 rounded-lg hover:bg-state-base-hover">
<ComboboxBaseItem subject={subject}>
<SelectionBox checked={isChecked} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="flex size-full items-center justify-center bg-[image:var(--color-access-app-icon-mask-bg)]">
<span className="i-ri-organization-chart h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{group.name}</span>
<span className="system-xs-regular text-text-tertiary">{group.groupSize}</span>
</ComboboxItemText>
</ComboboxBaseItem>
<Button
size="small"
disabled={isChecked}
variant="ghost-accent"
className="mr-1 flex shrink-0 items-center justify-between px-1.5 py-1"
onPointerDown={event => event.preventDefault()}
onClick={() => onExpandGroup(group)}
>
<span className="px-[3px]">{t('accessControlDialog.operateGroupAndMember.expand', { ns: 'app' })}</span>
<span className="i-ri-arrow-right-s-line size-4" aria-hidden="true" />
</Button>
</div>
)
}
type MemberItemProps = {
member: AccessControlAccount
subject: Subject
selectedMembers: AccessControlAccount[]
}
function MemberItem({ member, subject, selectedMembers }: MemberItemProps) {
const currentUser = useSelector(s => s.userProfile)
const { t } = useTranslation()
const isChecked = selectedMembers.some(selectedMember => selectedMember.id === member.id)
return (
<ComboboxBaseItem subject={subject} className="pr-3">
<SelectionBox checked={isChecked} />
<ComboboxItemText className="flex grow items-center px-0">
<div className="mr-2 size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="flex size-full items-center justify-center bg-[image:var(--color-access-app-icon-mask-bg)]">
<Avatar size="xxs" avatar={null} name={member.name} />
</div>
</div>
<span className="mr-1 system-sm-medium text-text-secondary">{member.name}</span>
{currentUser.email === member.email && (
<span className="system-xs-regular text-text-tertiary">
(
{t('you', { ns: 'common' })}
)
</span>
)}
</ComboboxItemText>
<span className="system-xs-regular text-text-quaternary">{member.email}</span>
</ComboboxBaseItem>
)
}
type ComboboxBaseItemProps = {
className?: string
subject: Subject
children: ReactNode
}
function ComboboxBaseItem({ children, className, subject }: ComboboxBaseItemProps) {
return (
<ComboboxItem
value={subject}
className={cn(
'mx-0 flex min-h-8 grow grid-cols-none items-center gap-2 rounded-lg p-1 pl-2',
className,
)}
>
{children}
</ComboboxItem>
)
}
function SelectionBox({ checked }: { checked: boolean }) {
return (
<span
aria-hidden="true"
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3',
checked
? 'bg-components-checkbox-bg text-components-checkbox-icon'
: 'border border-components-checkbox-border bg-components-checkbox-bg-unchecked',
)}
>
{checked && <span className="i-ri-check-line size-3" />}
</span>
)
}
type SelectedGroupItemProps = AccessSubjectSelectionProps & {
group: AccessControlGroup
}
function SelectedGroupItem({
group,
selectedGroups,
selectedMembers,
onChange,
}: SelectedGroupItemProps) {
const handleRemoveGroup = () => {
onChange({
groups: selectedGroups.filter(selectedGroup => selectedGroup.id !== group.id),
members: selectedMembers,
})
}
return (
<SelectedBaseItem
icon={<span className="i-ri-organization-chart h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0" aria-hidden="true" />}
onRemove={handleRemoveGroup}
>
<p className="system-xs-regular text-text-primary">{group.name}</p>
<p className="system-xs-regular text-text-tertiary">{group.groupSize}</p>
</SelectedBaseItem>
)
}
type SelectedMemberItemProps = AccessSubjectSelectionProps & {
member: AccessControlAccount
}
function SelectedMemberItem({
member,
selectedGroups,
selectedMembers,
onChange,
}: SelectedMemberItemProps) {
const handleRemoveMember = () => {
onChange({
groups: selectedGroups,
members: selectedMembers.filter(selectedMember => selectedMember.id !== member.id),
})
}
return (
<SelectedBaseItem
icon={<Avatar size="xxs" avatar={null} name={member.name} />}
onRemove={handleRemoveMember}
>
<p className="system-xs-regular text-text-primary">{member.name}</p>
</SelectedBaseItem>
)
}
type SelectedBaseItemProps = {
icon: ReactNode
children: ReactNode
onRemove?: () => void
}
function SelectedBaseItem({ icon, onRemove, children }: SelectedBaseItemProps) {
const { t } = useTranslation()
return (
<div className="group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs">
<div className="size-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid">
<div className="flex size-full items-center justify-center bg-[image:var(--color-access-app-icon-mask-bg)]">
{icon}
</div>
</div>
{children}
<button
type="button"
className="flex size-4 cursor-pointer items-center justify-center border-none bg-transparent p-0 focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden"
aria-label={t('operation.remove', { ns: 'common' })}
onClick={onRemove}
>
<span className="i-ri-close-circle-fill h-[14px] w-[14px] text-text-quaternary" aria-hidden="true" />
</button>
</div>
)
}

View File

@ -3,7 +3,6 @@ import type { InstalledApp as InstalledAppType } from '@/models/explore'
import { render, screen, waitFor } from '@testing-library/react'
import { useWebAppStore } from '@/context/web-app-context'
import useDocumentTitle from '@/hooks/use-document-title'
import { AccessMode } from '@/models/access-control'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore'
@ -13,9 +12,6 @@ import InstalledApp from '../index'
vi.mock('@/context/web-app-context', () => ({
useWebAppStore: vi.fn(),
}))
vi.mock('@/hooks/use-document-title', () => ({
default: vi.fn(),
}))
vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: vi.fn(),
}))
@ -365,20 +361,6 @@ describe('InstalledApp', () => {
})
describe('Effects', () => {
it('should set document title to installed app name', () => {
render(<InstalledApp id="installed-app-123" />)
expect(useDocumentTitle).toHaveBeenCalledWith('Test App')
})
it('should not set document title when installedApp is not found', () => {
setupMocks([])
render(<InstalledApp id="nonexistent-app" />)
expect(useDocumentTitle).not.toHaveBeenCalled()
})
it('should update app info when installedApp is available', async () => {
render(<InstalledApp id="installed-app-123" />)

View File

@ -7,22 +7,11 @@ import ChatWithHistory from '@/app/components/base/chat/chat-with-history'
import Loading from '@/app/components/base/loading'
import TextGenerationApp from '@/app/components/share/text-generation'
import { useWebAppStore } from '@/context/web-app-context'
import useDocumentTitle from '@/hooks/use-document-title'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGetInstalledAppAccessModeByAppId, useGetInstalledAppMeta, useGetInstalledAppParams, useGetInstalledApps } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
import AppUnavailable from '../../base/app-unavailable'
const InstalledAppDocumentTitle = ({
title,
}: {
title: string
}) => {
useDocumentTitle(title)
return null
}
const InstalledApp = ({
id,
}: {
@ -30,10 +19,6 @@ const InstalledApp = ({
}) => {
const { data, isPending: isPendingInstalledApps, isFetching: isFetchingInstalledApps } = useGetInstalledApps()
const installedApp = data?.installed_apps?.find(item => item.id === id)
const installedAppDocumentTitle = installedApp
? <InstalledAppDocumentTitle title={installedApp.app.name} />
: null
const updateAppInfo = useWebAppStore(s => s.updateAppInfo)
const updateWebAppAccessMode = useWebAppStore(s => s.updateWebAppAccessMode)
const updateAppParams = useWebAppStore(s => s.updateAppParams)
@ -79,52 +64,37 @@ const InstalledApp = ({
if (appParamsError) {
return (
<>
{installedAppDocumentTitle}
<div className="flex h-full items-center justify-center">
<AppUnavailable unknownReason={appParamsError.message} />
</div>
</>
<div className="flex h-full items-center justify-center">
<AppUnavailable unknownReason={appParamsError.message} />
</div>
)
}
if (appMetaError) {
return (
<>
{installedAppDocumentTitle}
<div className="flex h-full items-center justify-center">
<AppUnavailable unknownReason={appMetaError.message} />
</div>
</>
<div className="flex h-full items-center justify-center">
<AppUnavailable unknownReason={appMetaError.message} />
</div>
)
}
if (useCanAccessAppError) {
return (
<>
{installedAppDocumentTitle}
<div className="flex h-full items-center justify-center">
<AppUnavailable unknownReason={useCanAccessAppError.message} />
</div>
</>
<div className="flex h-full items-center justify-center">
<AppUnavailable unknownReason={useCanAccessAppError.message} />
</div>
)
}
if (webAppAccessModeError) {
return (
<>
{installedAppDocumentTitle}
<div className="flex h-full items-center justify-center">
<AppUnavailable unknownReason={webAppAccessModeError.message} />
</div>
</>
<div className="flex h-full items-center justify-center">
<AppUnavailable unknownReason={webAppAccessModeError.message} />
</div>
)
}
if (userCanAccessApp && !userCanAccessApp.result) {
return (
<>
{installedAppDocumentTitle}
<div className="flex h-full flex-col items-center justify-center gap-y-2">
<AppUnavailable className="size-auto" code={403} unknownReason="no permission." />
</div>
</>
<div className="flex h-full flex-col items-center justify-center gap-y-2">
<AppUnavailable className="size-auto" code={403} unknownReason="no permission." />
</div>
)
}
if (
@ -133,12 +103,9 @@ const InstalledApp = ({
|| (installedApp && (isPendingAppParams || isPendingAppMeta || isPendingWebAppAccessMode))
) {
return (
<>
{installedAppDocumentTitle}
<div className="flex h-full items-center justify-center">
<Loading />
</div>
</>
<div className="flex h-full items-center justify-center">
<Loading />
</div>
)
}
if (!installedApp) {
@ -149,20 +116,17 @@ const InstalledApp = ({
)
}
return (
<>
{installedAppDocumentTitle}
<div className="h-full bg-background-default py-2 pr-2 pl-0 sm:p-2">
{installedApp?.app.mode !== AppModeEnum.COMPLETION && installedApp?.app.mode !== AppModeEnum.WORKFLOW && (
<ChatWithHistory installedAppInfo={installedApp} className="overflow-hidden rounded-2xl shadow-md" />
)}
{installedApp?.app.mode === AppModeEnum.COMPLETION && (
<TextGenerationApp isInstalledApp installedAppInfo={installedApp} />
)}
{installedApp?.app.mode === AppModeEnum.WORKFLOW && (
<TextGenerationApp isWorkflow isInstalledApp installedAppInfo={installedApp} />
)}
</div>
</>
<div className="h-full bg-background-default py-2 pr-2 pl-0 sm:p-2">
{installedApp?.app.mode !== AppModeEnum.COMPLETION && installedApp?.app.mode !== AppModeEnum.WORKFLOW && (
<ChatWithHistory installedAppInfo={installedApp} className="overflow-hidden rounded-2xl shadow-md" />
)}
{installedApp?.app.mode === AppModeEnum.COMPLETION && (
<TextGenerationApp isInstalledApp installedAppInfo={installedApp} />
)}
{installedApp?.app.mode === AppModeEnum.WORKFLOW && (
<TextGenerationApp isWorkflow isInstalledApp installedAppInfo={installedApp} />
)}
</div>
)
}
export default React.memo(InstalledApp)

View File

@ -2,7 +2,7 @@ import type { AnchorHTMLAttributes, ReactElement } from 'react'
import { fireEvent, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import Header from '../index'
import { Header } from '../index'
function createMockComponent(testId: string) {
return () => <div data-testid={testId} />
@ -44,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', () => {
@ -216,6 +223,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

@ -28,8 +28,6 @@ vi.mock('@/context/app-context', () => ({
}))
vi.mock('@remixicon/react', () => ({
RiBook2Fill: () => <div data-testid="active-icon" />,
RiBook2Line: () => <div data-testid="inactive-icon" />,
RiArrowDownSLine: () => <div data-testid="arrow-down-icon" />,
RiArrowRightSLine: () => <div data-testid="arrow-right-icon" />,
RiAddLine: () => <div data-testid="add-icon" />,
@ -141,6 +139,32 @@ describe('DatasetNav', () => {
render(<DatasetNav />)
expect(screen.getByText('common.menus.datasets')).toBeInTheDocument()
})
it('should render when dataset icon info is missing', () => {
const datasetWithoutIcon = {
...mockDataset,
icon_info: null,
}
vi.mocked(useDatasetDetail).mockReturnValue({
data: datasetWithoutIcon,
} as unknown as ReturnType<typeof useDatasetDetail>)
vi.mocked(useDatasetList).mockReturnValue({
data: {
pages: [
{
data: [datasetWithoutIcon],
},
],
},
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetchingNextPage: false,
} as unknown as ReturnType<typeof useDatasetList>)
render(<DatasetNav />)
expect(screen.getByRole('button', { name: /Test Dataset/i })).toBeInTheDocument()
})
})
describe('Navigation Items logic', () => {

View File

@ -1,19 +1,59 @@
'use client'
import type { NavItem } from '../nav/nav-selector'
import type { DataSet } from '@/models/datasets'
import {
RiBook2Fill,
RiBook2Line,
} from '@remixicon/react'
import type { DataSet, IconInfo } from '@/models/datasets'
import { flatten } from 'es-toolkit/compat'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams, useRouter } from '@/next/navigation'
import { useDatasetDetail, useDatasetList } from '@/service/knowledge/use-dataset'
import { basePath } from '@/utils/var'
import Nav from '../nav'
const DEFAULT_DATASET_ICON_INFO = {
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
} satisfies IconInfo
function datasetLink(dataset: DataSet) {
const isPipelineUnpublished = dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
const link = isPipelineUnpublished
? `/datasets/${dataset.id}/pipeline`
: `/datasets/${dataset.id}/documents`
return dataset.provider === 'external'
? `/datasets/${dataset.id}/hitTesting`
: link
}
function currentDatasetNavItem(dataset: DataSet) {
const iconInfo = dataset.icon_info ?? DEFAULT_DATASET_ICON_INFO
return {
id: dataset.id,
name: dataset.name,
icon: iconInfo.icon,
icon_type: iconInfo.icon_type,
icon_background: iconInfo.icon_background ?? null,
icon_url: iconInfo.icon_url ?? null,
} satisfies Omit<NavItem, 'link'>
}
function datasetNavItem(dataset: DataSet) {
const iconInfo = dataset.icon_info ?? DEFAULT_DATASET_ICON_INFO
return {
id: dataset.id,
name: dataset.name,
link: datasetLink(dataset),
icon: iconInfo.icon,
icon_type: iconInfo.icon_type,
icon_background: iconInfo.icon_background ?? null,
icon_url: iconInfo.icon_url ?? null,
} satisfies NavItem
}
const DatasetNav = () => {
const { t } = useTranslation()
const router = useRouter()
@ -30,62 +70,23 @@ const DatasetNav = () => {
})
const datasetItems = flatten(datasetList?.pages.map(datasetData => datasetData.data))
const curNav = useMemo(() => {
if (!currentDataset)
return
return {
id: currentDataset.id,
name: currentDataset.name,
icon: currentDataset.icon_info.icon,
icon_type: currentDataset.icon_info.icon_type,
icon_background: currentDataset.icon_info.icon_background,
icon_url: currentDataset.icon_info.icon_url,
} as Omit<NavItem, 'link'>
}, [currentDataset?.id, currentDataset?.name, currentDataset?.icon_info])
const curNav = currentDataset ? currentDatasetNavItem(currentDataset) : undefined
const navigationItems = datasetItems.map(datasetNavItem)
const runtimeMode = currentDataset?.runtime_mode
const createRoute = runtimeMode === 'rag_pipeline'
? `${basePath}/datasets/create-from-pipeline`
: `${basePath}/datasets/create`
const getDatasetLink = useCallback((dataset: DataSet) => {
const isPipelineUnpublished = dataset.runtime_mode === 'rag_pipeline' && !dataset.is_published
const link = isPipelineUnpublished
? `/datasets/${dataset.id}/pipeline`
: `/datasets/${dataset.id}/documents`
return dataset.provider === 'external'
? `/datasets/${dataset.id}/hitTesting`
: link
}, [])
const navigationItems = useMemo(() => {
return datasetItems.map((dataset) => {
const link = getDatasetLink(dataset)
return {
id: dataset.id,
name: dataset.name,
link,
icon: dataset.icon_info.icon,
icon_type: dataset.icon_info.icon_type,
icon_background: dataset.icon_info.icon_background,
icon_url: dataset.icon_info.icon_url,
}
}) as NavItem[]
}, [datasetItems, getDatasetLink])
const createRoute = useMemo(() => {
const runtimeMode = currentDataset?.runtime_mode
if (runtimeMode === 'rag_pipeline')
return `${basePath}/datasets/create-from-pipeline`
else
return `${basePath}/datasets/create`
}, [currentDataset?.runtime_mode])
const handleLoadMore = useCallback(() => {
if (hasNextPage)
fetchNextPage()
}, [hasNextPage, fetchNextPage])
function handleLoadMore() {
if (hasNextPage && !isFetchingNextPage)
void fetchNextPage()
}
return (
<Nav
isApp={false}
icon={<RiBook2Line className="size-4" />}
activeIcon={<RiBook2Fill className="size-4" />}
icon={<span aria-hidden className="i-ri-book-2-line size-4" />}
activeIcon={<span aria-hidden className="i-ri-book-2-fill size-4" />}
text={t('menus.datasets', { ns: 'common' })}
activeSegment="datasets"
link="/datasets"

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,12 +37,14 @@ 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 logoLabel = isBrandingEnabled && systemFeatures.branding.application_title ? systemFeatures.branding.application_title : 'Dify'
const renderLogo = () => (
@ -75,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>
)
@ -102,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

@ -1,7 +1,41 @@
import {
formatWorkflowRunIdentifier,
getKeyboardKeyCodeBySystem,
} from '../common'
const setUserAgent = (userAgent: string) => {
Object.defineProperty(navigator, 'userAgent', {
value: userAgent,
configurable: true,
})
}
describe('getKeyboardKeyCodeBySystem', () => {
const originalUserAgent = navigator.userAgent
afterEach(() => {
setUserAgent(originalUserAgent)
})
it('should map ctrl to meta on macOS', () => {
setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')
expect(getKeyboardKeyCodeBySystem('ctrl')).toBe('meta')
})
it('should keep ctrl on non-macOS', () => {
setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')
expect(getKeyboardKeyCodeBySystem('ctrl')).toBe('ctrl')
})
it('should keep unmapped keys on macOS', () => {
setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')
expect(getKeyboardKeyCodeBySystem('shift')).toBe('shift')
})
})
describe('formatWorkflowRunIdentifier', () => {
it('should return fallback text when finishedAt is undefined', () => {
expect(formatWorkflowRunIdentifier()).toBe(' (Running)')

View File

@ -1,3 +1,16 @@
const MAC_PLATFORM_PATTERN = /mac/i
const specialKeysCodeMap: Record<string, string | undefined> = {
ctrl: 'meta',
}
export const getKeyboardKeyCodeBySystem = (key: string) => {
if (typeof navigator !== 'undefined' && MAC_PLATFORM_PATTERN.test(navigator.userAgent))
return specialKeysCodeMap[key] || key
return key
}
/**
* Format workflow run identifier using finished_at timestamp
* @param finishedAt - Unix timestamp in seconds

View File

@ -35,5 +35,7 @@ export const userProfileQueryOptions = () =>
},
}
},
staleTime: 0,
gcTime: 0,
retry: (failureCount, error) => !isLegacyBase401(error) && failureCount < 3,
})

View File

@ -75,5 +75,7 @@ export const serverUserProfileQueryOptions = () =>
},
}
},
staleTime: 0,
gcTime: 0,
retry: 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,233 @@
'use client'
import type { App } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxInputGroup,
ComboboxItem,
ComboboxItemText,
ComboboxList,
ComboboxTrigger,
} from '@langgenius/dify-ui/combobox'
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { consoleQuery } from '@/service/client'
import { TitleTooltip } from './title-tooltip'
const SOURCE_APP_PAGE_SIZE = 20
const SOURCE_APP_PICKER_SKELETON_KEYS = ['first-source-app', 'second-source-app', 'third-source-app']
export type SourceAppPickerValue = Pick<App, 'id' | 'name'> & Partial<Pick<App, 'icon_type' | 'icon' | 'icon_background' | 'icon_url'>>
function sourceAppSearchText(app: App) {
return `${app.name} ${app.id} ${app.mode}`.toLowerCase()
}
function SourceAppTrigger({ open, app }: {
open: boolean
app?: SourceAppPickerValue
}) {
const { t } = useTranslation('deployments')
return (
<span
className={cn(
'group flex h-10 cursor-pointer items-center gap-2 rounded-lg border border-transparent bg-components-input-bg-normal px-3 text-left hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
open && 'border-components-input-border-active bg-components-input-bg-active shadow-xs',
app && 'pl-2',
)}
>
{app && (
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
)}
<TitleTooltip content={app?.name}>
<span
className={cn(
'min-w-0 grow truncate',
app
? 'system-sm-medium text-components-input-text-filled'
: 'system-sm-regular text-components-input-text-placeholder',
)}
>
{app?.name ?? t('createModal.appPickerPlaceholder')}
</span>
</TitleTooltip>
<span
className={cn(
'i-ri-arrow-down-s-line size-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
open && 'text-text-secondary',
)}
aria-hidden="true"
/>
</span>
)
}
function SourceAppOption({ app }: {
app: App
}) {
const { t } = useTranslation('deployments')
const modeLabel = t(`appMode.${app.mode}`, { defaultValue: app.mode })
return (
<ComboboxItem
value={app}
className="mx-0 grid-cols-[minmax(0,1fr)_auto] gap-3 py-1 pr-3 pl-2"
>
<ComboboxItemText className="flex min-w-0 items-center gap-3 px-0">
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<TitleTooltip content={`${app.name} (${app.id})`}>
<span className="flex min-w-0 grow items-center gap-1 truncate system-sm-medium text-components-input-text-filled">
<span className="truncate">{app.name}</span>
<span className="shrink-0 text-text-tertiary">
(
{app.id.slice(0, 8)}
)
</span>
</span>
</TitleTooltip>
</ComboboxItemText>
<span className="shrink-0 system-2xs-medium-uppercase text-text-tertiary">{modeLabel}</span>
</ComboboxItem>
)
}
function SourceAppPickerSkeleton() {
return (
<div className="flex flex-col gap-2 px-3 py-3">
{SOURCE_APP_PICKER_SKELETON_KEYS.map(key => (
<SkeletonRow key={key} className="h-7 gap-3">
<SkeletonRectangle className="my-0 size-5 animate-pulse rounded-md" />
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
</SkeletonRow>
))}
</div>
)
}
export function SourceAppPicker({ value, onChange, ariaLabel }: {
value?: SourceAppPickerValue
onChange: (app: App) => void
ariaLabel?: string
}) {
const { t } = useTranslation('deployments')
const [isShow, setIsShow] = useState(false)
const [searchText, setSearchText] = useState('')
const {
data,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = useInfiniteQuery({
...consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
page: Number(pageParam),
limit: SOURCE_APP_PAGE_SIZE,
name: searchText,
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
})
const apps = data?.pages.flatMap(page => page.data) ?? []
return (
<Combobox<App>
items={apps}
open={isShow}
inputValue={searchText}
onOpenChange={setIsShow}
onInputValueChange={setSearchText}
onValueChange={(app) => {
if (!app)
return
onChange(app)
setIsShow(false)
}}
itemToStringLabel={app => app?.name ?? ''}
itemToStringValue={app => app?.id ?? ''}
filter={(app, query) => sourceAppSearchText(app).includes(query.toLowerCase())}
disabled={false}
>
<ComboboxTrigger
aria-label={ariaLabel ?? t('createModal.sourceApp')}
icon={false}
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-open:bg-transparent"
>
<SourceAppTrigger open={isShow} app={value} />
</ComboboxTrigger>
<ComboboxContent
placement="bottom-start"
sideOffset={4}
popupClassName="border-0 bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="relative flex max-h-100 min-h-20 w-89 flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span className="i-ri-search-line size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
<ComboboxInput
aria-label={t('createModal.appSearchPlaceholder')}
placeholder={t('createModal.appSearchPlaceholder')}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
/>
</ComboboxInputGroup>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-1">
{(isLoading || isFetchingNextPage) && apps.length === 0 && <SourceAppPickerSkeleton />}
<ComboboxList className="max-h-none p-0">
{(app: App) => (
<SourceAppOption key={app.id} app={app} />
)}
</ComboboxList>
{!(isLoading || isFetchingNextPage) && (
<ComboboxEmpty>
{t('createModal.appSearchEmpty')}
</ComboboxEmpty>
)}
{hasNextPage && (
<div className="flex justify-center px-3 py-2">
<Button
type="button"
size="small"
disabled={isFetchingNextPage}
onClick={() => {
void fetchNextPage()
}}
>
{isFetchingNextPage ? t('common.loading') : t('createModal.loadMoreApps')}
</Button>
</div>
)}
</div>
</div>
</ComboboxContent>
</Combobox>
)
}

View File

@ -0,0 +1,64 @@
'use client'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerPopup,
DrawerPortal,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import {
closeDeployDrawerAtom,
deployDrawerAppInstanceIdAtom,
deployDrawerEnvironmentIdAtom,
deployDrawerOpenAtom,
deployDrawerReleaseIdAtom,
} from '../store'
import { DeployForm } from './deploy-drawer/form'
export function DeployDrawer() {
const { t } = useTranslation('deployments')
const open = useAtomValue(deployDrawerOpenAtom)
const drawerAppInstanceId = useAtomValue(deployDrawerAppInstanceIdAtom)
const drawerEnvironmentId = useAtomValue(deployDrawerEnvironmentIdAtom)
const drawerReleaseId = useAtomValue(deployDrawerReleaseIdAtom)
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
const formKey = `${drawerAppInstanceId ?? 'none'}-${drawerEnvironmentId ?? 'any'}-${drawerReleaseId ?? 'new'}-${open ? '1' : '0'}`
return (
<Drawer
open={open}
modal
swipeDirection="right"
onOpenChange={next => !next && closeDeployDrawer()}
>
<DrawerPortal>
<DrawerBackdrop />
<DrawerViewport>
<DrawerPopup className="data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-[640px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-[0.5px]">
<DrawerCloseButton
aria-label={t('deployDrawer.close')}
className="absolute top-4 right-5 size-6 rounded-md"
/>
<DrawerContent className="flex min-h-0 flex-1 flex-col bg-components-panel-bg p-0 pb-0">
{!drawerAppInstanceId
? <div className="p-6 text-text-tertiary">{t('deployDrawer.notFound')}</div>
: (
<DeployForm
key={formKey}
appInstanceId={drawerAppInstanceId}
lockedEnvId={drawerEnvironmentId}
presetReleaseId={drawerReleaseId}
/>
)}
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}

View File

@ -0,0 +1,290 @@
'use client'
import type {
CredentialSlot,
Environment,
EnvVarSlot,
Release,
} from '@dify/contracts/enterprise/types.gen'
import type { EnvVarValues } from '../env-var-bindings-utils'
import type { RuntimeCredentialBindingSelections } from '../runtime-credential-bindings-utils'
import { DrawerDescription, DrawerTitle } from '@langgenius/dify-ui/drawer'
import { useTranslation } from 'react-i18next'
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { environmentBackend, environmentMode, environmentName } from '../../environment'
import { formatDate, releaseCommit, releaseLabel } from '../../release'
import { DeploymentStateMessage } from '../empty-state'
import { EnvVarBindingsPanel } from '../env-var-bindings'
import { RuntimeCredentialBindingsPanel } from '../runtime-credential-bindings'
import {
DeploymentSelect,
EnvironmentRow,
Field,
} from './select'
export type EnvironmentOption = Environment & { id: string }
const DEPLOY_FORM_FIELD_SKELETON_KEYS = ['environment', 'release']
const DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME = 'max-h-none overflow-visible'
function environmentOptionLabel(env: EnvironmentOption, t: ReturnType<typeof useTranslation<'deployments'>>['t']) {
const description = env.description?.trim()
if (description)
return `${environmentName(env)} · ${description}`
return `${environmentName(env)} · ${t(environmentMode(env) === 'isolated' ? 'mode.isolated' : 'mode.shared')} · ${environmentBackend(env).toUpperCase()}`
}
function BindingOptionsPanel({
slots,
selections,
isLoading,
hasError,
bindingCountLabel,
onChange,
}: {
slots: CredentialSlot[]
selections: RuntimeCredentialBindingSelections
isLoading: boolean
hasError: boolean
bindingCountLabel: string
onChange: (slot: string, value: string) => void
}) {
const { t } = useTranslation('deployments')
if (isLoading) {
return (
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
</div>
)
}
if (hasError) {
return (
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4 system-sm-regular text-text-destructive">
{t('deployDrawer.bindingOptionsFailed')}
</div>
)
}
return (
<RuntimeCredentialBindingsPanel
slots={slots}
selections={selections}
title={t('deployDrawer.runtimeCredentials')}
hint={t('deployDrawer.bindingSelectionHint')}
requiredLabel={t('deployDrawer.requiredBinding')}
noBindingRequiredLabel={t('deployDrawer.noBindingRequired')}
noCredentialCandidatesLabel={t('deployDrawer.noCredentialCandidates')}
selectCredentialLabel={t('deployDrawer.selectCredential')}
missingRequiredLabel={t('deployDrawer.missingRequiredBinding')}
bindingCountLabel={bindingCountLabel}
listClassName={DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME}
onChange={onChange}
/>
)
}
export function DeployFormSkeleton() {
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-divider-subtle px-6 py-5 pr-14">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-5 w-44 animate-pulse" />
<SkeletonRectangle className="h-3 w-72 animate-pulse" />
</SkeletonContainer>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="flex flex-col gap-5">
{DEPLOY_FORM_FIELD_SKELETON_KEYS.map(key => (
<SkeletonContainer key={key} className="gap-2">
<SkeletonRectangle className="h-3 w-24 animate-pulse" />
<SkeletonRectangle className="my-0 h-9 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
))}
<div className="rounded-xl border border-divider-subtle bg-background-default-subtle px-3 py-4">
<SkeletonContainer className="gap-2">
<SkeletonRectangle className="h-3 w-32 animate-pulse" />
<SkeletonRectangle className="h-3 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-8 w-full animate-pulse rounded-lg" />
</SkeletonContainer>
</div>
</div>
</div>
<SkeletonRow className="shrink-0 justify-end border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<SkeletonRectangle className="my-0 h-8 w-18 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-8 w-22 animate-pulse rounded-lg" />
</SkeletonRow>
</div>
)
}
export function DeployFormHeader() {
const { t } = useTranslation('deployments')
return (
<div className="shrink-0 border-b border-divider-subtle px-6 py-5 pr-14">
<DrawerTitle className="title-xl-semi-bold text-text-primary">
{t('deployDrawer.title')}
</DrawerTitle>
<DrawerDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('deployDrawer.description')}
</DrawerDescription>
</div>
)
}
export function ReleaseField({
displayedRelease,
isExistingRelease,
releases,
selectedReleaseId,
onSelectRelease,
}: {
displayedRelease?: Release
isExistingRelease: boolean
releases: Release[]
selectedReleaseId: string
onSelectRelease: (releaseId: string) => void
}) {
const { t } = useTranslation('deployments')
return (
<Field label={t('deployDrawer.releaseLabel')}>
{isExistingRelease && displayedRelease
? (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-mono system-sm-semibold text-text-primary">{releaseLabel(displayedRelease)}</span>
<span className="shrink-0 system-xs-regular text-text-tertiary">·</span>
<span className="shrink-0 font-mono system-xs-regular text-text-tertiary">{releaseCommit(displayedRelease)}</span>
</div>
<span className="shrink-0 system-xs-regular text-text-quaternary">{formatDate(displayedRelease.createdAt)}</span>
</div>
<span className="system-xs-regular text-text-tertiary">
{t('deployDrawer.existingReleaseHint')}
</span>
</div>
)
: releases.length === 0
? (
<DeploymentStateMessage variant="compact">
{t('deployDrawer.noReleaseAvailable')}
</DeploymentStateMessage>
)
: (
<DeploymentSelect
value={selectedReleaseId}
onChange={onSelectRelease}
options={releases.filter(release => release.id).map(release => ({
value: release.id!,
label: `${releaseLabel(release)} · ${releaseCommit(release)}`,
}))}
placeholder={t('deployDrawer.selectRelease')}
/>
)}
</Field>
)
}
export function EnvironmentField({
environments,
lockedEnv,
lockedEnvId,
selectedEnvironmentId,
onSelectEnvironment,
}: {
environments: EnvironmentOption[]
lockedEnv?: EnvironmentOption
lockedEnvId?: string
selectedEnvironmentId: string
onSelectEnvironment: (environmentId: string) => void
}) {
const { t } = useTranslation('deployments')
return (
<Field
label={t('deployDrawer.targetEnv')}
hint={lockedEnvId ? t('deployDrawer.lockedHint') : undefined}
>
{lockedEnv
? <EnvironmentRow env={lockedEnv} />
: environments.length === 0
? (
<DeploymentStateMessage variant="compact">
{t('deployDrawer.noNewEnvironmentAvailable')}
</DeploymentStateMessage>
)
: (
<DeploymentSelect
value={selectedEnvironmentId}
onChange={onSelectEnvironment}
options={environments.filter(env => env.id).map(env => ({
value: env.id!,
label: environmentOptionLabel(env, t),
}))}
placeholder={t('deployDrawer.selectEnv')}
/>
)}
</Field>
)
}
export function DeploymentBindingsSection({
bindingSlots,
bindingSelections,
bindingOptionsLoading,
bindingOptionsError,
envVarSlots,
envVarValues,
onBindingChange,
onEnvVarChange,
}: {
bindingSlots: CredentialSlot[]
bindingSelections: RuntimeCredentialBindingSelections
bindingOptionsLoading: boolean
bindingOptionsError: boolean
envVarSlots: EnvVarSlot[]
envVarValues: EnvVarValues
onBindingChange: (slot: string, value: string) => void
onEnvVarChange: (key: string, value: string) => void
}) {
const { t } = useTranslation('deployments')
return (
<>
<BindingOptionsPanel
slots={bindingSlots}
selections={bindingSelections}
isLoading={bindingOptionsLoading}
hasError={bindingOptionsError}
bindingCountLabel={t('deployDrawer.bindingCount', { count: bindingSlots.length })}
onChange={onBindingChange}
/>
{!bindingOptionsLoading && !bindingOptionsError && (
<EnvVarBindingsPanel
slots={envVarSlots}
values={envVarValues}
title={t('deployDrawer.envVars')}
hint={t('deployDrawer.envVarHint')}
requiredLabel={t('deployDrawer.requiredBinding')}
envVarPlaceholder={t('deployDrawer.envVarPlaceholder')}
envVarCountLabel={t('deployDrawer.envVarCount', { count: envVarSlots.length })}
missingRequiredLabel={t('deployDrawer.missingRequiredEnvVar')}
listClassName={DEPLOY_DRAWER_BINDING_LIST_CLASS_NAME}
showMissingRequired
onChange={onEnvVarChange}
/>
)}
</>
)
}

View File

@ -0,0 +1,261 @@
'use client'
import type {
EnvVarSlot,
Release,
} from '@dify/contracts/enterprise/types.gen'
import type { EnvVarValues } from '../env-var-bindings-utils'
import type { RuntimeCredentialBindingSelections } from '../runtime-credential-bindings-utils'
import type { EnvironmentOption } from './form-sections'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { skipToken, useMutation, useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { DEPLOYMENT_PAGE_SIZE } from '../../data'
import { createDeploymentIdempotencyKey } from '../../idempotency'
import { isAvailableDeploymentTarget } from '../../runtime-status'
import { closeDeployDrawerAtom } from '../../store'
import {
hasEnvVarSlotKey,
hasMissingRequiredEnvVarValue,
selectedDeploymentEnvVars,
} from '../env-var-bindings-utils'
import {
hasMissingRequiredRuntimeCredentialBinding,
runtimeCredentialSlotKey,
selectedDeploymentRuntimeCredentials,
selectedRuntimeCredentialSelections,
} from '../runtime-credential-bindings-utils'
import {
DeployFormHeader,
DeployFormSkeleton,
DeploymentBindingsSection,
EnvironmentField,
ReleaseField,
} from './form-sections'
type DeployFormProps = {
appInstanceId: string
lockedEnvId?: string
presetReleaseId?: string
}
type DeployReadyFormProps = DeployFormProps & {
environments: EnvironmentOption[]
releases: Release[]
defaultReleaseId?: string
}
type BindingSelections = RuntimeCredentialBindingSelections
function requiredSlotEnvVarSlot(slot: NonNullable<Release['requiredSlots']>[number]): EnvVarSlot | undefined {
if (slot.type !== 'SLOT_TYPE_ENV_VAR')
return undefined
const key = slot.name?.trim()
return key ? { key } : undefined
}
function DeployReadyForm({
appInstanceId,
environments,
releases,
defaultReleaseId,
lockedEnvId,
presetReleaseId,
}: DeployReadyFormProps) {
const { t } = useTranslation('deployments')
const closeDeployDrawer = useSetAtom(closeDeployDrawerAtom)
const startDeploy = useMutation(consoleQuery.enterprise.deploymentService.deploy.mutationOptions())
const presetRelease = presetReleaseId ? releases.find(r => r.id === presetReleaseId) : undefined
const displayedRelease: Release | undefined = presetRelease ?? (presetReleaseId ? { id: presetReleaseId } : undefined)
const isExistingRelease = Boolean(presetReleaseId)
const [selectedEnvId, setSelectedEnvId] = useState<string>(
() => lockedEnvId ?? environments[0]?.id ?? '',
)
const selectedEnvironmentId = selectedEnvId || lockedEnvId || environments[0]?.id || ''
const selectedEnvironment = environments.find(env => env.id === selectedEnvironmentId)
const [selectedReleaseId, setSelectedReleaseId] = useState<string>(
() => displayedRelease?.id ?? defaultReleaseId ?? '',
)
const selectedRelease = releases.find(release => release.id === selectedReleaseId)
const targetRelease = displayedRelease ?? selectedRelease
const targetReleaseId = targetRelease?.id ?? selectedReleaseId
const hasSelectedEnvironment = Boolean(selectedEnvironmentId && selectedEnvironment)
const shouldLoadBindingOptions = Boolean(appInstanceId && targetReleaseId && hasSelectedEnvironment)
const bindingOptions = useQuery(consoleQuery.enterprise.releaseService.listReleaseCredentialCandidates.queryOptions({
input: shouldLoadBindingOptions
? {
params: {
releaseId: targetReleaseId,
},
}
: skipToken,
}))
const bindingSlots = bindingOptions.data?.slots?.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
const envVarSlots = targetRelease?.requiredSlots
?.map(requiredSlotEnvVarSlot)
.filter((slot): slot is EnvVarSlot => hasEnvVarSlotKey(slot)) ?? []
const [manualBindings, setManualBindings] = useState<BindingSelections>({})
const [envVarValues, setEnvVarValues] = useState<EnvVarValues>({})
const selectedBindings = selectedRuntimeCredentialSelections(bindingSlots, manualBindings)
const deploymentCredentials = selectedDeploymentRuntimeCredentials(bindingSlots, selectedBindings)
const deploymentEnvVars = selectedDeploymentEnvVars(envVarSlots, envVarValues)
const bindingOptionsLoading = Boolean(targetReleaseId && hasSelectedEnvironment && (bindingOptions.isLoading || bindingOptions.isFetching))
const bindingOptionsReady = Boolean(targetReleaseId && hasSelectedEnvironment && bindingOptions.data && !bindingOptionsLoading && !bindingOptions.isError)
const requiredBindingsReady = bindingSlots.every(slot => !hasMissingRequiredRuntimeCredentialBinding(slot, selectedBindings[runtimeCredentialSlotKey(slot)]))
const requiredEnvVarsReady = envVarSlots.every(slot => !hasMissingRequiredEnvVarValue(slot, envVarValues))
const isSubmitting = startDeploy.isPending
const canDeploy = Boolean(
selectedEnvironmentId
&& selectedEnvironment
&& targetReleaseId
&& bindingOptionsReady
&& requiredBindingsReady
&& requiredEnvVarsReady
&& !isSubmitting,
)
const lockedEnv = lockedEnvId ? environments.find(e => e.id === lockedEnvId) : undefined
const submitLabel = isSubmitting ? t('deployDrawer.deploying') : t('deployDrawer.deploy')
const handleDeploy = () => {
if (!canDeploy || !targetReleaseId)
return
const idempotencyKey = createDeploymentIdempotencyKey()
startDeploy.mutate(
{
params: {
appInstanceId,
environmentId: selectedEnvironmentId,
},
body: {
appInstanceId,
environmentId: selectedEnvironmentId,
releaseId: targetReleaseId,
credentials: deploymentCredentials,
envVars: deploymentEnvVars.length > 0 ? deploymentEnvVars : undefined,
idempotencyKey,
},
},
{
onSuccess: () => {
closeDeployDrawer()
},
onError: () => {
toast.error(t('deployDrawer.deployFailed'))
},
},
)
}
return (
<div className="flex min-h-0 flex-1 flex-col">
<DeployFormHeader />
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">
<div className="flex flex-col gap-5">
<ReleaseField
displayedRelease={displayedRelease}
isExistingRelease={isExistingRelease}
releases={releases}
selectedReleaseId={selectedReleaseId}
onSelectRelease={setSelectedReleaseId}
/>
<EnvironmentField
environments={environments}
lockedEnv={lockedEnv}
lockedEnvId={lockedEnvId}
selectedEnvironmentId={selectedEnvironmentId}
onSelectEnvironment={setSelectedEnvId}
/>
{targetReleaseId && hasSelectedEnvironment && (
<DeploymentBindingsSection
bindingSlots={bindingSlots}
bindingSelections={selectedBindings}
bindingOptionsLoading={bindingOptionsLoading}
bindingOptionsError={bindingOptions.isError}
envVarSlots={envVarSlots}
envVarValues={envVarValues}
onBindingChange={(slot, value) => setManualBindings(prev => ({ ...prev, [slot]: value }))}
onEnvVarChange={(key, value) => setEnvVarValues(prev => ({ ...prev, [key]: value }))}
/>
)}
</div>
</div>
<div className="flex shrink-0 justify-end gap-2 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<Button type="button" variant="secondary" onClick={closeDeployDrawer}>
{t('deployDrawer.cancel')}
</Button>
<Button variant="primary" disabled={!canDeploy} onClick={handleDeploy}>
{submitLabel}
</Button>
</div>
</div>
)
}
export function DeployForm({
appInstanceId,
lockedEnvId,
presetReleaseId,
}: DeployFormProps) {
const { t } = useTranslation('deployments')
const releaseHistoryQuery = useQuery(consoleQuery.enterprise.releaseService.listReleases.queryOptions({
input: {
params: { appInstanceId },
query: {
pageNumber: 1,
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
},
},
}))
const runtimeInstancesQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
}))
if (releaseHistoryQuery.isLoading || runtimeInstancesQuery.isLoading) {
return <DeployFormSkeleton />
}
if (releaseHistoryQuery.isError || runtimeInstancesQuery.isError) {
return (
<div className="p-4 system-sm-regular text-text-destructive">
{t('common.loadFailed')}
</div>
)
}
const runtimeRows = runtimeInstancesQuery.data?.data ?? []
const selectableEnvironmentRows = runtimeRows
.filter(row => lockedEnvId ? Boolean(row.environment?.id) : isAvailableDeploymentTarget(row))
const environments = selectableEnvironmentRows
.map(row => row.environment)
.filter((environment): environment is EnvironmentOption => Boolean(environment?.id))
const releases = releaseHistoryQuery.data?.data?.filter(release => release.id) ?? []
const defaultReleaseId = releases[0]?.id
const formKey = `${appInstanceId}-${lockedEnvId ?? 'any'}-${presetReleaseId ?? 'new'}-${defaultReleaseId ?? 'none'}`
return (
<DeployReadyForm
key={formKey}
appInstanceId={appInstanceId}
environments={environments}
releases={releases}
defaultReleaseId={defaultReleaseId}
lockedEnvId={lockedEnvId}
presetReleaseId={presetReleaseId}
/>
)
}

View File

@ -0,0 +1,134 @@
'use client'
import type { Environment } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import { environmentBackend, environmentHealth, environmentMode, environmentName } from '../../environment'
import { ModeBadge } from '../status-badge'
import { TitleTooltip } from '../title-tooltip'
type EnvironmentOption = Environment & {
disabled?: boolean
}
export function Field({ label, hint, children }: {
label: string
hint?: string
children: React.ReactNode
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="system-xs-medium-uppercase text-text-tertiary">{label}</div>
{hint && <span className="system-xs-regular text-text-quaternary">{hint}</span>}
</div>
{children}
</div>
)
}
type SelectOption = {
value: string
label: string
disabled?: boolean
disabledReason?: string
}
type SelectProps = {
value: string
onChange: (value: string) => void
options: SelectOption[]
placeholder?: string
}
export function DeploymentSelect({ value, onChange, options, placeholder }: SelectProps) {
const { t } = useTranslation('deployments')
const selectedOption = options.find(option => option.value === value)
return (
<Select
value={value || null}
onValueChange={(next) => {
if (!next)
return
onChange(next)
}}
disabled={options.length === 0}
>
<SelectTrigger
className={cn(
'h-8 min-w-0 border border-components-input-border-active px-2 text-left system-sm-medium',
!selectedOption && 'text-text-quaternary',
)}
>
{selectedOption?.label ?? placeholder ?? t('deployDrawer.defaultSelect')}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{options.map(opt => (
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.disabled}
>
<TitleTooltip content={opt.disabled ? opt.disabledReason : undefined}>
<SelectItemText>{opt.label}</SelectItemText>
</TitleTooltip>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
function EnvironmentHealthDot({ health }: {
health: ReturnType<typeof environmentHealth>
}) {
const { t } = useTranslation('deployments')
const label = t(health === 'ready' ? 'health.ready' : 'health.degraded')
return (
<Tooltip>
<TooltipTrigger
render={(
<span
aria-label={label}
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-full',
health === 'ready' ? 'bg-util-colors-green-green-50' : 'bg-util-colors-warning-warning-50',
)}
>
<span
aria-hidden
className={cn(
'size-1.5 rounded-full',
health === 'ready' ? 'bg-util-colors-green-green-500' : 'bg-util-colors-warning-warning-500',
)}
/>
</span>
)}
/>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}
export function EnvironmentRow({ env }: { env: EnvironmentOption }) {
const summary = env.description?.trim() || environmentBackend(env).toUpperCase()
const health = environmentHealth(env)
return (
<div className="flex items-center justify-between gap-3 rounded-lg border border-components-panel-border bg-components-panel-bg-blur px-3 py-2">
<div className="flex min-w-0 flex-col gap-1">
<div className="flex min-w-0 items-center gap-2">
<EnvironmentHealthDot health={health} />
<span className="truncate system-sm-semibold text-text-primary">{environmentName(env)}</span>
<ModeBadge mode={environmentMode(env)} />
</div>
<span className="line-clamp-1 system-xs-regular text-text-tertiary">{summary}</span>
</div>
</div>
)
}

View File

@ -0,0 +1,143 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
type DeploymentEmptyStateVariant = 'page' | 'list' | 'section' | 'compact'
type DeploymentStateMessageVariant = 'page' | 'list' | 'section' | 'compact' | 'embedded'
type DeploymentEmptyStateAlign = 'center' | 'start'
type DeploymentEmptyStateProps = {
icon?: string
title: ReactNode
description?: ReactNode
action?: ReactNode
variant?: DeploymentEmptyStateVariant
align?: DeploymentEmptyStateAlign
className?: string
}
type DeploymentStateMessageProps = {
children: ReactNode
variant?: DeploymentStateMessageVariant
className?: string
}
type DeploymentNoticeStateProps = {
children: ReactNode
icon?: string
className?: string
}
const emptyStateContainerClassNames: Record<DeploymentEmptyStateVariant, string> = {
page: 'col-span-full min-h-80 rounded-xl border border-divider-subtle bg-background-default-subtle px-6 py-12',
list: 'min-h-60 rounded-lg border border-divider-subtle bg-background-default-subtle px-6 py-12',
section: 'min-h-36 rounded-lg border border-divider-subtle bg-background-default-subtle px-6 py-8',
compact: 'min-h-14 rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3',
}
const stateMessageClassNames: Record<DeploymentStateMessageVariant, string> = {
page: 'col-span-full flex min-h-80 items-center justify-center rounded-xl border border-dashed border-divider-subtle bg-background-default-subtle px-6 py-12 text-center system-sm-regular text-text-tertiary',
list: 'flex min-h-36 items-center justify-center rounded-lg border border-dashed border-divider-subtle bg-background-default-subtle px-6 py-12 text-center system-sm-regular text-text-tertiary',
section: 'flex min-h-24 items-center justify-center rounded-lg border border-dashed border-divider-subtle bg-background-default-subtle px-4 py-6 text-center system-sm-regular text-text-tertiary',
compact: 'rounded-lg border border-dashed border-divider-subtle bg-background-default-subtle px-3 py-3 system-sm-regular text-text-tertiary',
embedded: 'px-4 py-10 text-center system-sm-regular text-text-tertiary',
}
export function DeploymentEmptyState({
icon,
title,
description,
action,
variant = 'list',
align,
className,
}: DeploymentEmptyStateProps) {
const effectiveAlign = align ?? (variant === 'compact' ? 'start' : 'center')
const isLarge = variant === 'page' || variant === 'list'
const hasDescription = Boolean(description)
const hasAction = Boolean(action)
const hasIcon = Boolean(icon)
return (
<div
data-slot="deployment-empty-state"
className={cn(
'flex flex-col justify-center border-dashed',
effectiveAlign === 'center' ? 'items-center text-center' : 'items-start text-left',
emptyStateContainerClassNames[variant],
className,
)}
>
{hasIcon && (
<span
className={cn(
'flex items-center justify-center border border-components-panel-border bg-background-default-subtle text-text-tertiary',
variant === 'compact' ? 'mb-2 size-8 rounded-lg' : 'mb-4',
isLarge && 'size-11 rounded-xl',
variant === 'section' && 'size-10 rounded-lg bg-background-section-burn',
)}
>
<span
className={cn(
icon,
isLarge ? 'size-5' : variant === 'section' ? 'size-4.5' : 'size-4',
)}
aria-hidden="true"
/>
</span>
)}
<div
className={cn(
isLarge
? 'system-md-semibold text-text-primary'
: variant === 'compact' && !hasIcon && !hasDescription
? 'system-sm-regular text-text-tertiary'
: 'system-sm-medium text-text-secondary',
)}
>
{title}
</div>
{hasDescription && (
<p
className={cn(
'mt-1 max-w-120 text-text-tertiary',
isLarge ? 'system-sm-regular' : 'system-xs-regular',
)}
>
{description}
</p>
)}
{hasAction && (
<div className={isLarge ? 'mt-5' : variant === 'compact' ? 'mt-3' : 'mt-4'}>
{action}
</div>
)}
</div>
)
}
export function DeploymentStateMessage({
children,
variant = 'list',
className,
}: DeploymentStateMessageProps) {
return (
<div className={cn(stateMessageClassNames[variant], className)}>
{children}
</div>
)
}
export function DeploymentNoticeState({
children,
icon = 'i-ri-information-line',
className,
}: DeploymentNoticeStateProps) {
return (
<div className={cn('flex min-h-9 items-start gap-1.5 rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-2 system-xs-regular text-text-tertiary', className)}>
<span className={cn(icon, 'mt-0.5 size-3.5 shrink-0 text-text-quaternary')} aria-hidden="true" />
<span className="min-w-0">{children}</span>
</div>
)
}

View File

@ -0,0 +1,42 @@
import type {
EnvVarInput,
EnvVarSlot,
} from '@dify/contracts/enterprise/types.gen'
export type EnvVarValues = Record<string, string>
export function envVarSlotKey(slot: EnvVarSlot) {
return slot.key?.trim() ?? ''
}
export function hasEnvVarSlotKey(slot?: EnvVarSlot) {
return Boolean(slot && envVarSlotKey(slot))
}
export function hasMissingRequiredEnvVarValue(slot: EnvVarSlot, values: EnvVarValues) {
const key = envVarSlotKey(slot)
return !key || !values[key]?.trim()
}
export function selectedDeploymentEnvVars(
slots: EnvVarSlot[],
values: EnvVarValues,
): EnvVarInput[] {
return slots
.map((slot): EnvVarInput | undefined => {
const key = envVarSlotKey(slot)
if (!key)
return undefined
const value = values[key]
if (!value?.trim())
return undefined
return {
key,
value,
}
})
.filter((envVar): envVar is EnvVarInput => Boolean(envVar))
}

View File

@ -0,0 +1,102 @@
'use client'
import type { EnvVarSlot } from '@dify/contracts/enterprise/types.gen'
import type { EnvVarValues } from './env-var-bindings-utils'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import {
envVarSlotKey,
hasMissingRequiredEnvVarValue,
} from './env-var-bindings-utils'
import { TitleTooltip } from './title-tooltip'
type EnvVarBindingsPanelProps = {
slots: EnvVarSlot[]
values: EnvVarValues
title: string
hint: string
requiredLabel: string
envVarPlaceholder: string
envVarCountLabel?: string
missingRequiredLabel?: string
showMissingRequired?: boolean
onChange: (key: string, value: string) => void
className?: string
listClassName?: string
}
function envVarInputId(index: number, key: string) {
const safeKey = key.replace(/[^\w-]/g, '-')
return `env-var-binding-${index}-${safeKey}`
}
export function EnvVarBindingsPanel({
slots,
values,
title,
hint,
requiredLabel,
envVarPlaceholder,
envVarCountLabel,
missingRequiredLabel,
showMissingRequired = false,
onChange,
className,
listClassName,
}: EnvVarBindingsPanelProps) {
if (slots.length === 0)
return null
return (
<div className={cn('overflow-hidden rounded-xl border border-divider-subtle bg-background-default-subtle', className)}>
<div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
<div className="system-xs-medium-uppercase text-text-tertiary">{title}</div>
<span className="shrink-0 rounded-md bg-background-default px-1.5 py-0.5 system-2xs-medium text-text-quaternary">
{envVarCountLabel ?? slots.length}
</span>
</div>
<span className="system-xs-regular text-text-quaternary">{hint}</span>
</div>
<div className={cn('max-h-[min(360px,34dvh)] overflow-y-auto border-t border-divider-subtle', listClassName)}>
{slots.map((slot, index) => {
const key = envVarSlotKey(slot)
const inputId = envVarInputId(index, key)
const missing = showMissingRequired && hasMissingRequiredEnvVarValue(slot, values)
return (
<div key={key} className="flex min-w-0 flex-col gap-2 border-b border-divider-subtle px-3 py-3 last:border-b-0">
<div className="flex min-w-0 flex-col gap-2.5">
<div className="flex min-w-0 items-center gap-1.5">
<TitleTooltip content={key}>
<label className="truncate font-mono system-sm-semibold text-text-primary" htmlFor={inputId}>
{key}
</label>
</TitleTooltip>
<span className="shrink-0 rounded-md bg-background-default px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{requiredLabel}
</span>
</div>
<Input
id={inputId}
value={values[key] ?? ''}
onChange={event => onChange(key, event.target.value)}
placeholder={envVarPlaceholder}
autoComplete="off"
required
className="h-8"
/>
</div>
{missing && missingRequiredLabel && (
<div className="system-xs-regular text-text-destructive">
{missingRequiredLabel}
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@ -0,0 +1,118 @@
import type {
CredentialCandidate,
CredentialSelectionInput,
CredentialSlot,
} from '@dify/contracts/enterprise/types.gen'
export type RuntimeCredentialBindingSelections = Record<string, string>
export type RuntimeCredentialSelectOption = {
value: string
label: string
}
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
azure_openai: 'Azure OpenAI',
bedrock: 'Amazon Bedrock',
gemini: 'Gemini',
google: 'Google',
openai: 'OpenAI',
vertex_ai: 'Vertex AI',
volcengine_maas: 'Volcengine',
}
function providerSlug(providerId?: string) {
const parts = providerId?.split('/').filter(Boolean) ?? []
return parts[parts.length - 1] ?? ''
}
function titleCaseProviderName(value: string) {
return value
.split(/[-_]/)
.filter(Boolean)
.map(part => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
.join(' ')
}
export function runtimeCredentialSlotKey(slot: CredentialSlot) {
return [slot.providerId ?? '', slot.category ?? ''].join(':')
}
export function runtimeCredentialProviderName(providerId?: string) {
const slug = providerSlug(providerId)
if (!slug)
return ''
return PROVIDER_DISPLAY_NAMES[slug.toLowerCase()] ?? titleCaseProviderName(slug)
}
function runtimeCredentialCandidateLabel(candidate: CredentialCandidate) {
const fallback = candidate.credentialId ?? ''
const rawLabel = candidate.displayName?.trim() || fallback
const providerId = candidate.providerId?.trim()
if (!providerId)
return rawLabel
const providerSuffixes = [
` · ${providerId}`,
` - ${providerId}`,
` (${providerId})`,
]
const label = providerSuffixes.reduce((nextLabel, suffix) => {
return nextLabel.endsWith(suffix)
? nextLabel.slice(0, -suffix.length).trim()
: nextLabel
}, rawLabel)
return label || fallback
}
export function runtimeCredentialCandidateOptions(slot: CredentialSlot): RuntimeCredentialSelectOption[] {
return (slot.candidates ?? [])
.filter(candidate => candidate.credentialId)
.map(candidate => ({
value: candidate.credentialId!,
label: runtimeCredentialCandidateLabel(candidate),
}))
}
export function hasMissingRequiredRuntimeCredentialBinding(_slot: CredentialSlot, selectedValue?: string) {
return !selectedValue
}
export function selectedRuntimeCredentialSelections(
slots: CredentialSlot[],
manualBindings: RuntimeCredentialBindingSelections,
): RuntimeCredentialBindingSelections {
const next: RuntimeCredentialBindingSelections = {}
for (const slot of slots) {
const slotKey = runtimeCredentialSlotKey(slot)
const candidates = runtimeCredentialCandidateOptions(slot)
const existing = manualBindings[slotKey]
if (existing && candidates.some(candidate => candidate.value === existing))
next[slotKey] = existing
else if (candidates.length === 1 && candidates[0])
next[slotKey] = candidates[0].value
}
return next
}
export function selectedDeploymentRuntimeCredentials(
slots: CredentialSlot[],
selections: RuntimeCredentialBindingSelections,
): CredentialSelectionInput[] {
return slots
.map((slot): CredentialSelectionInput | undefined => {
const slotKey = runtimeCredentialSlotKey(slot)
const selectedValue = selections[slotKey]
if (!slotKey || !selectedValue)
return undefined
return {
providerId: slot.providerId,
category: slot.category,
credentialId: selectedValue,
}
})
.filter((binding): binding is CredentialSelectionInput => Boolean(binding))
}

View File

@ -0,0 +1,194 @@
'use client'
import type {
CredentialSlot,
} from '@dify/contracts/enterprise/types.gen'
import type {
RuntimeCredentialBindingSelections,
RuntimeCredentialSelectOption,
} from './runtime-credential-bindings-utils'
import { cn } from '@langgenius/dify-ui/cn'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { useTranslation } from 'react-i18next'
import {
hasMissingRequiredRuntimeCredentialBinding,
runtimeCredentialCandidateOptions,
runtimeCredentialProviderName,
runtimeCredentialSlotKey,
} from './runtime-credential-bindings-utils'
import { TitleTooltip } from './title-tooltip'
type RuntimeCredentialBindingsPanelProps = {
slots: CredentialSlot[]
selections: RuntimeCredentialBindingSelections
title: string
hint: string
requiredLabel: string
noBindingRequiredLabel: string
noCredentialCandidatesLabel: string
selectCredentialLabel: string
missingRequiredLabel: string
bindingCountLabel?: string
showMissingRequired?: boolean
onChange: (slotKey: string, value: string) => void
className?: string
listClassName?: string
}
function RuntimeCredentialSelect({
ariaLabel,
value,
options,
placeholder,
onChange,
}: {
ariaLabel: string
value: string
options: RuntimeCredentialSelectOption[]
placeholder: string
onChange: (value: string) => void
}) {
const selectedOption = options.find(option => option.value === value)
return (
<Select
value={value || null}
onValueChange={(next) => {
if (!next)
return
onChange(next)
}}
disabled={options.length === 0}
>
<SelectTrigger
aria-label={ariaLabel}
className={cn(
'h-8 min-w-0 border border-divider-subtle px-2 text-left system-sm-medium hover:border-components-input-border-hover focus:border-components-input-border-active',
!selectedOption && 'text-text-quaternary',
)}
>
{selectedOption?.label ?? placeholder}
</SelectTrigger>
<SelectContent popupClassName="w-(--anchor-width)">
{options.map(option => (
<SelectItem key={option.value} value={option.value}>
<TitleTooltip content={option.label}>
<SelectItemText>{option.label}</SelectItemText>
</TitleTooltip>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
)
}
export function RuntimeCredentialBindingsPanel({
slots,
selections,
title,
hint,
requiredLabel,
noBindingRequiredLabel,
noCredentialCandidatesLabel,
selectCredentialLabel,
missingRequiredLabel,
bindingCountLabel,
showMissingRequired = false,
onChange,
className,
listClassName,
}: RuntimeCredentialBindingsPanelProps) {
const { t } = useTranslation('plugin')
return (
<div className={cn('overflow-hidden rounded-xl border border-divider-subtle bg-background-default-subtle', className)}>
<div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
<div className="system-xs-medium-uppercase text-text-tertiary">{title}</div>
{slots.length > 0 && (
<span className="shrink-0 rounded-md bg-background-default px-1.5 py-0.5 system-2xs-medium text-text-quaternary">
{bindingCountLabel ?? slots.length}
</span>
)}
</div>
<span className="system-xs-regular text-text-quaternary">{hint}</span>
</div>
{slots.length === 0
? (
<div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{noBindingRequiredLabel}
</div>
)
: (
<div className={cn('max-h-[min(360px,34dvh)] overflow-y-auto border-t border-divider-subtle', listClassName)}>
{slots.map((slot) => {
const slotKey = runtimeCredentialSlotKey(slot)
const candidates = runtimeCredentialCandidateOptions(slot)
const selectedValue = selections[slotKey] ?? ''
const missing = showMissingRequired && hasMissingRequiredRuntimeCredentialBinding(slot, selectedValue)
const slotName = runtimeCredentialProviderName(slot.providerId) || slotKey
const categoryLabel = slot.category === 'PLUGIN_CATEGORY_MODEL'
? t('categorySingle.model')
: slot.category === 'PLUGIN_CATEGORY_TOOL'
? t('categorySingle.tool')
: undefined
return (
<div key={slotKey} className="flex flex-col gap-2 border-b border-divider-subtle px-3 py-3 last:border-b-0">
<div className="flex min-w-0 flex-col gap-2.5">
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex min-w-0 items-center gap-1.5">
<TitleTooltip content={slotName}>
<span className="truncate system-sm-semibold text-text-primary">
{slotName}
</span>
</TitleTooltip>
</div>
<div className="flex flex-wrap items-center gap-1.5">
{categoryLabel && (
<span className="shrink-0 rounded-md bg-util-colors-blue-light-blue-light-50 px-1.5 py-0.5 system-2xs-medium-uppercase text-util-colors-blue-blue-600">
{categoryLabel}
</span>
)}
<span className="shrink-0 rounded-md bg-background-default px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{requiredLabel}
</span>
</div>
</div>
{candidates.length === 0
? (
<div className="rounded-lg border border-divider-subtle bg-background-default px-2 py-1.5 system-sm-regular text-text-quaternary">
{noCredentialCandidatesLabel}
</div>
)
: (
<RuntimeCredentialSelect
ariaLabel={slotName}
value={selectedValue}
onChange={value => onChange(slotKey, value)}
options={candidates}
placeholder={selectCredentialLabel}
/>
)}
</div>
{missing && (
<div className="system-xs-regular text-text-destructive">
{missingRequiredLabel}
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,38 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
type EnvironmentMode = 'shared' | 'isolated'
type EnvironmentHealth = 'ready' | 'degraded'
const baseBadge = 'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 system-xs-medium whitespace-nowrap'
export function ModeBadge({ mode, className }: {
mode: EnvironmentMode
className?: string
}) {
const { t } = useTranslation('deployments')
const style = mode === 'shared'
? 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700'
: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700'
return (
<span className={cn(baseBadge, style, className)}>
{t(mode === 'shared' ? 'mode.shared' : 'mode.isolated')}
</span>
)
}
export function HealthBadge({ health, className }: {
health: EnvironmentHealth
className?: string
}) {
const { t } = useTranslation('deployments')
const style = health === 'ready'
? 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700'
: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700'
return (
<span className={cn(baseBadge, style, className)}>
{t(health === 'ready' ? 'health.ready' : 'health.degraded')}
</span>
)
}

View File

@ -0,0 +1,22 @@
'use client'
import type { ReactElement, ReactNode } from 'react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
export function TitleTooltip({
children,
content,
}: {
children: ReactElement
content?: ReactNode
}) {
if (!content)
return children
return (
<Tooltip>
<TooltipTrigger render={children} />
<TooltipContent>{content}</TooltipContent>
</Tooltip>
)
}

View File

@ -0,0 +1,48 @@
'use client'
import { useTranslation } from 'react-i18next'
import Uploader from '@/app/components/app/create-from-dsl-modal/uploader'
import { StepShell } from './layout'
export function DslStep({
dslFile,
isReadingDsl,
readError,
onDslFileChange,
}: {
dslFile?: File
isReadingDsl: boolean
readError: boolean
onDslFileChange: (file?: File) => void
}) {
const { t } = useTranslation('deployments')
return (
<StepShell title={t('createGuide.dsl.title')} description={t('createGuide.dsl.description')} hideHeader>
<div className="flex flex-col gap-4 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-5">
<div className="flex items-start gap-3">
<span className="mt-0.5 i-ri-upload-cloud-2-line size-5 shrink-0 text-text-tertiary" aria-hidden="true" />
<div className="flex min-w-0 flex-col gap-1">
<div className="system-sm-semibold text-text-primary">{t('createGuide.dsl.dropTitle')}</div>
<div className="system-sm-regular text-text-tertiary">{t('createGuide.dsl.dropDescription')}</div>
</div>
</div>
<Uploader
className="mt-0"
file={dslFile}
updateFile={onDslFileChange}
/>
{isReadingDsl && (
<div className="system-xs-regular text-text-tertiary">
{t('createGuide.dsl.reading')}
</div>
)}
{readError && (
<div className="system-xs-regular text-text-destructive">
{t('createGuide.dsl.readFailed')}
</div>
)}
</div>
</StepShell>
)
}

View File

@ -0,0 +1,73 @@
'use client'
import { useTranslation } from 'react-i18next'
import Link from '@/next/link'
import { GuideActions, GuideCard, GuideFrame } from './layout'
import { CreationSections } from './source-release-sections'
import { TargetReviewSections } from './target-step'
import { useCreateDeploymentGuide } from './use-create-deployment-guide'
export function CreateDeploymentGuide() {
const { t } = useTranslation('deployments')
const {
canContinue,
canSkipDeployment,
creationSectionsProps,
handleBack,
handlePrimaryAction,
handleSkipDeployment,
isDeploying,
isSkippingDeployment,
showTargetConfiguration,
step,
targetReviewSectionsProps,
} = useCreateDeploymentGuide()
const guideContent = (
<>
{showTargetConfiguration
? (
<div className="flex flex-col gap-7 pb-4">
<TargetReviewSections {...targetReviewSectionsProps} />
</div>
)
: (
<CreationSections {...creationSectionsProps} />
)}
</>
)
return (
<div className="fixed inset-0 z-50 bg-background-overlay-backdrop p-4 backdrop-blur-[6px]">
<div className="mx-auto h-full w-full max-w-[1120px] overflow-hidden rounded-2xl border border-effects-highlight bg-background-default-subtle">
<main className="relative flex h-full min-w-0 grow flex-col overflow-hidden">
<Link
href="/deployments"
aria-label={t('createGuide.nav.back')}
className="absolute top-3 right-3 z-50 flex h-9 w-9 cursor-pointer items-center justify-center rounded-[10px] bg-components-button-tertiary-bg hover:bg-components-button-tertiary-bg-hover"
>
<span aria-hidden="true" className="i-ri-close-large-line h-3.5 w-3.5 text-components-button-tertiary-text" />
</Link>
<GuideFrame activeStep={step}>
<GuideCard
actions={(
<GuideActions
canContinue={canContinue}
canSkipDeployment={canSkipDeployment}
isDeploying={isDeploying}
isSkippingDeployment={isSkippingDeployment}
step={step}
onBack={handleBack}
onPrimaryAction={handlePrimaryAction}
onSkipDeployment={handleSkipDeployment}
/>
)}
>
{guideContent}
</GuideCard>
</GuideFrame>
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,254 @@
'use client'
import type { ReactNode } from 'react'
import type { GuideStep } from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useTranslation } from 'react-i18next'
import { TitleTooltip } from '../components/title-tooltip'
const GUIDE_PROGRESS_STEPS: GuideStep[] = ['source', 'release', 'target']
function GuideStepIntro({ activeStep }: {
activeStep: GuideStep
}) {
const { t } = useTranslation('deployments')
let title: string
let description: string
if (activeStep === 'source') {
title = t('createGuide.source.title')
description = t('createGuide.method.description')
}
else if (activeStep === 'release') {
title = t('createGuide.release.title')
description = t('createGuide.release.description')
}
else if (activeStep === 'target') {
title = t('createGuide.target.title')
description = t('createGuide.target.description')
}
else {
return null
}
return (
<div className="pb-4">
<h2 className="system-md-semibold text-text-primary">{title}</h2>
<p className="mt-1 max-w-150 system-sm-regular text-text-tertiary">{description}</p>
</div>
)
}
function GuideProgress({ activeStep }: {
activeStep: GuideStep
}) {
const { t } = useTranslation('deployments')
const activeIndex = GUIDE_PROGRESS_STEPS.indexOf(activeStep)
return (
<ol className="grid grid-cols-3 gap-1.5">
{GUIDE_PROGRESS_STEPS.map((step, index) => {
const isActive = step === activeStep
const isComplete = index < activeIndex
const label = t(`createGuide.steps.${step}`)
return (
<TitleTooltip key={step} content={label}>
<li
aria-current={isActive ? 'step' : undefined}
className={cn(
'flex min-w-0 items-start gap-1.5 px-1 py-1.5 system-xs-medium sm:items-center sm:gap-2 sm:px-2',
isActive
? 'text-text-primary'
: isComplete
? 'text-text-secondary'
: 'text-text-quaternary',
)}
>
<span
aria-hidden
className={cn(
'mt-1 size-2 shrink-0 rounded-full border-[1.5px] sm:mt-0',
isActive
? 'border-text-primary bg-text-primary'
: isComplete
? 'border-text-secondary bg-text-secondary'
: 'border-text-quaternary bg-transparent',
)}
/>
<span className="line-clamp-2 min-w-0 leading-4">{label}</span>
</li>
</TitleTooltip>
)
})}
</ol>
)
}
function GuideProgressSummary({ activeStep }: {
activeStep: GuideStep
}) {
const { t } = useTranslation('deployments')
const activeIndex = GUIDE_PROGRESS_STEPS.indexOf(activeStep)
const activeStepNumber = activeIndex + 1
let activeStepLabel: string
if (activeStep === 'source')
activeStepLabel = t('createGuide.steps.source')
else if (activeStep === 'release')
activeStepLabel = t('createGuide.steps.release')
else if (activeStep === 'target')
activeStepLabel = t('createGuide.steps.target')
else
return null
if (activeIndex < 0)
return null
return (
<div className="flex w-full min-w-0 flex-col gap-2">
<div className="flex min-w-0 items-baseline justify-between gap-3">
<span className="truncate system-sm-medium text-text-secondary">{activeStepLabel}</span>
<span className="shrink-0 system-xs-regular text-text-quaternary">
{activeStepNumber}
/
{GUIDE_PROGRESS_STEPS.length}
</span>
</div>
<div className="grid grid-cols-3 gap-1" aria-hidden="true">
{GUIDE_PROGRESS_STEPS.map((step, index) => (
<span
key={step}
className={cn(
'h-1 rounded-full',
index <= activeIndex ? 'bg-text-primary' : 'bg-divider-subtle',
)}
/>
))}
</div>
</div>
)
}
export function StepShell({ title, description, descriptionClassName, hideHeader, children }: {
title: string
description: string
descriptionClassName?: string
hideHeader?: boolean
children: ReactNode
}) {
return (
<section aria-label={hideHeader ? title : undefined} className="flex min-w-0 flex-col gap-4">
{!hideHeader && (
<div className="flex min-w-0 flex-col gap-0.5">
<h2 className="system-md-semibold text-text-primary">{title}</h2>
<p className={cn('system-sm-regular text-text-tertiary', descriptionClassName)}>{description}</p>
</div>
)}
{children}
</section>
)
}
export function GuideCard({ children, actions }: {
children: ReactNode
actions: ReactNode
}) {
return (
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col">
<ScrollArea
className="min-h-0 flex-1"
slotClassNames={{
viewport: 'overscroll-contain',
content: 'min-h-full pt-0.5 pb-6',
scrollbar: 'data-[orientation=vertical]:-me-5 data-[orientation=vertical]:my-1',
}}
>
{children}
</ScrollArea>
{actions}
</div>
)
}
export function GuideFrame({ activeStep, children }: {
activeStep: GuideStep
children: ReactNode
}) {
const { t } = useTranslation('deployments')
return (
<div className="relative flex h-full min-h-0 overflow-hidden bg-background-default-subtle">
<div className="flex min-w-0 flex-1 shrink-0 justify-center overflow-hidden">
<section
aria-label={t('createGuide.title')}
className="flex h-full w-full max-w-[840px] flex-col px-5 sm:px-8 lg:px-10"
>
<div className="h-5 sm:h-8 lg:h-12" />
<div className="flex min-w-0 items-start justify-between gap-6 pt-1 pb-4">
<h1 className="title-2xl-semi-bold text-text-primary">{t('createGuide.title')}</h1>
<div className="hidden w-[184px] shrink-0 min-[1120px]:block">
<GuideProgressSummary activeStep={activeStep} />
</div>
</div>
<GuideStepIntro activeStep={activeStep} />
<div className="mb-6 lg:hidden">
<GuideProgress activeStep={activeStep} />
</div>
{children}
</section>
</div>
</div>
)
}
export function GuideActions({
canContinue,
canSkipDeployment,
isDeploying,
isSkippingDeployment,
step,
onBack,
onPrimaryAction,
onSkipDeployment,
}: {
canContinue: boolean
canSkipDeployment: boolean
isDeploying: boolean
isSkippingDeployment: boolean
step: GuideStep
onBack: () => void
onPrimaryAction: () => void
onSkipDeployment: () => void
}) {
const { t } = useTranslation('deployments')
const primaryLabel = step === 'target'
? isDeploying && !isSkippingDeployment ? t('createGuide.actions.deploying') : t('createGuide.actions.createAndDeploy')
: step === 'release' && isDeploying
? t('createGuide.actions.creating')
: t('createGuide.actions.next')
const skipLabel = isSkippingDeployment
? t('createGuide.actions.creating')
: t('createGuide.actions.skipDeploy')
return (
<div className="sticky bottom-0 z-10 -mx-5 mt-auto flex items-center justify-end gap-2 border-t border-divider-subtle bg-background-default-subtle/95 px-5 py-4 backdrop-blur-sm sm:-mx-8 sm:px-8 lg:-mx-10 lg:px-10">
{(step === 'release' || step === 'target') && (
<Button type="button" variant="secondary" onClick={onBack} disabled={isDeploying}>
{t('createGuide.actions.back')}
</Button>
)}
{step === 'target' && (
<Button type="button" variant="secondary" disabled={!canSkipDeployment || isDeploying} onClick={onSkipDeployment}>
{skipLabel}
</Button>
)}
<Button type="button" variant="primary" disabled={!canContinue || isDeploying} onClick={onPrimaryAction}>
{primaryLabel}
</Button>
</div>
)
}

View File

@ -0,0 +1,82 @@
'use client'
import type { GuideMethod } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { TitleTooltip } from '../components/title-tooltip'
import { StepShell } from './layout'
function MethodCard({ icon, title, description, badge, selected, onClick }: {
icon: string
title: string
description: string
badge?: string
selected: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
`relative box-content h-[84px] w-full cursor-pointer rounded-xl border-[0.5px]
border-components-option-card-option-border bg-components-panel-on-panel-item-bg p-3
text-left shadow-xs outline-hidden hover:shadow-md focus-visible:ring-2
focus-visible:ring-state-accent-solid sm:w-[240px]`,
selected && 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-md ring-[0.5px] ring-components-option-card-option-selected-border ring-inset',
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-md border border-divider-subtle bg-background-default-subtle">
<span className={cn('size-4 text-text-tertiary', icon)} aria-hidden="true" />
</span>
<span className="mt-2 mb-0.5 flex min-w-0 items-center gap-1">
<span className="truncate system-sm-semibold text-text-secondary">{title}</span>
{badge && (
<span className="shrink-0 rounded-md bg-background-default-subtle px-1.5 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
{badge}
</span>
)}
</span>
<span className="flex min-w-0 items-start gap-1">
<TitleTooltip content={description}>
<span className="line-clamp-2 min-w-0 grow system-xs-regular text-text-tertiary">
{description}
</span>
</TitleTooltip>
</span>
</button>
)
}
export function MethodStep({ method, onSelect }: {
method?: GuideMethod
onSelect: (method: GuideMethod) => void
}) {
const { t } = useTranslation('deployments')
return (
<StepShell
title={t('createGuide.steps.method')}
description={t('createGuide.method.description')}
descriptionClassName="lg:hidden"
hideHeader
>
<div className="flex flex-col gap-2 sm:flex-row">
<MethodCard
icon="i-ri-stack-line"
title={t('createGuide.methods.bindApp.title')}
description={t('createGuide.methods.bindApp.description')}
selected={method === 'bindApp'}
onClick={() => onSelect('bindApp')}
/>
<MethodCard
icon="i-ri-file-code-line"
title={t('createGuide.methods.importDsl.title')}
description={t('createGuide.methods.importDsl.description')}
selected={method === 'importDsl'}
onClick={() => onSelect('importDsl')}
/>
</div>
</StepShell>
)
}

View File

@ -0,0 +1,118 @@
'use client'
import { Input } from '@langgenius/dify-ui/input'
import { useTranslation } from 'react-i18next'
import { StepShell } from './layout'
export function ReleaseStep({
instanceName,
instanceDescription,
releaseName,
releaseDescription,
instanceNamePlaceholder,
instanceNameError,
releaseNamePlaceholder,
onInstanceNameChange,
onInstanceDescriptionChange,
onReleaseNameChange,
onReleaseDescriptionChange,
}: {
instanceName: string
instanceDescription: string
releaseName: string
releaseDescription: string
instanceNamePlaceholder: string
instanceNameError?: string
releaseNamePlaceholder: string
onInstanceNameChange: (value: string) => void
onInstanceDescriptionChange: (value: string) => void
onReleaseNameChange: (value: string) => void
onReleaseDescriptionChange: (value: string) => void
}) {
const { t } = useTranslation('deployments')
const instanceNameErrorId = 'create-guide-instance-name-error'
return (
<StepShell
title={t('createGuide.release.title')}
description={t('createGuide.release.description')}
hideHeader
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<h3 className="system-sm-semibold text-text-primary">
{t('createGuide.release.deployInfo')}
</h3>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="create-guide-instance-name">
{t('createGuide.release.instanceName')}
</label>
<Input
id="create-guide-instance-name"
value={instanceName}
onChange={event => onInstanceNameChange(event.target.value)}
placeholder={instanceNamePlaceholder}
required
aria-invalid={instanceNameError ? true : undefined}
aria-describedby={instanceNameError ? instanceNameErrorId : undefined}
className="h-9"
/>
{instanceNameError && (
<div id={instanceNameErrorId} role="alert" className="system-xs-regular text-text-destructive">
{instanceNameError}
</div>
)}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="create-guide-instance-description">
{t('createGuide.release.instanceDescription')}
</label>
<span className="system-xs-regular text-text-quaternary">{t('versions.optional')}</span>
</div>
<textarea
id="create-guide-instance-description"
value={instanceDescription}
onChange={event => onInstanceDescriptionChange(event.target.value)}
placeholder={t('createGuide.release.instanceDescriptionPlaceholder')}
className="min-h-16 w-full resize-none appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
/>
</div>
</div>
<div className="flex flex-col gap-4">
<h3 className="system-sm-semibold text-text-primary">
{t('createGuide.release.firstVersion')}
</h3>
<div className="flex flex-col gap-2">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="create-guide-release-name">
{t('createGuide.release.releaseName')}
</label>
<Input
id="create-guide-release-name"
value={releaseName}
onChange={event => onReleaseNameChange(event.target.value)}
placeholder={releaseNamePlaceholder}
required
className="h-9"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-1.5">
<label className="system-xs-medium-uppercase text-text-tertiary" htmlFor="create-guide-release-description">
{t('createGuide.release.releaseDescription')}
</label>
<span className="system-xs-regular text-text-quaternary">{t('versions.optional')}</span>
</div>
<textarea
id="create-guide-release-description"
value={releaseDescription}
onChange={event => onReleaseDescriptionChange(event.target.value)}
placeholder={t('createGuide.release.releaseDescriptionPlaceholder')}
className="min-h-16 w-full resize-none appearance-none rounded-md border border-transparent bg-components-input-bg-normal p-2 px-3 system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
/>
</div>
</div>
</div>
</StepShell>
)
}

View File

@ -0,0 +1,107 @@
'use client'
import type { ReactNode } from 'react'
import type { GuideMethod } from './types'
import type { App } from '@/types/app'
import { DslStep } from './dsl-step'
import { MethodStep } from './method-step'
import { ReleaseStep } from './release-step'
import { SourceStep } from './source-step'
export function CreationSections({
children,
defaultedReleaseName,
instanceDescription,
instanceName,
instanceNameError,
method,
onInstanceDescriptionChange,
onInstanceNameChange,
onReleaseDescriptionChange,
onReleaseNameChange,
onSearchTextChange,
onSelectMethod,
onSelectSourceApp,
onDslFileChange,
releaseDescription,
releaseName,
selectedApp,
sourceApps,
sourceAppsLoading,
sourceName,
sourceSearchText,
stage,
dslFile,
isReadingDsl,
dslReadError,
}: {
children?: ReactNode
defaultedReleaseName: string
instanceDescription: string
instanceName: string
instanceNameError?: string
method?: GuideMethod
onInstanceDescriptionChange: (value: string) => void
onInstanceNameChange: (value: string) => void
onReleaseDescriptionChange: (value: string) => void
onReleaseNameChange: (value: string) => void
onSearchTextChange: (value: string) => void
onSelectMethod: (method: GuideMethod) => void
onSelectSourceApp: (app: App) => void
onDslFileChange: (file?: File) => void
releaseDescription: string
releaseName: string
selectedApp?: App
sourceApps: App[]
sourceAppsLoading: boolean
sourceName: string
sourceSearchText: string
stage: 'source' | 'release'
dslFile?: File
isReadingDsl: boolean
dslReadError: boolean
}) {
return (
<div className="flex flex-col gap-7 pb-4">
{stage === 'source' && (
<div className="flex flex-col gap-4">
<MethodStep method={method} onSelect={onSelectMethod} />
{method === 'bindApp' && (
<SourceStep
apps={sourceApps}
selectedApp={selectedApp}
searchText={sourceSearchText}
isLoading={sourceAppsLoading}
onSearchTextChange={onSearchTextChange}
onSelectApp={onSelectSourceApp}
/>
)}
{method === 'importDsl' && (
<DslStep
dslFile={dslFile}
isReadingDsl={isReadingDsl}
readError={dslReadError}
onDslFileChange={onDslFileChange}
/>
)}
</div>
)}
{stage === 'release' && method && (
<ReleaseStep
instanceName={instanceName}
instanceDescription={instanceDescription}
releaseName={releaseName}
releaseDescription={releaseDescription}
instanceNamePlaceholder={sourceName}
instanceNameError={instanceNameError}
releaseNamePlaceholder={defaultedReleaseName}
onInstanceNameChange={onInstanceNameChange}
onInstanceDescriptionChange={onInstanceDescriptionChange}
onReleaseNameChange={onReleaseNameChange}
onReleaseDescriptionChange={onReleaseDescriptionChange}
/>
)}
{children}
</div>
)
}

View File

@ -0,0 +1,159 @@
'use client'
import type { App } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import { Input } from '@langgenius/dify-ui/input'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import { toAppMode } from '../app-mode'
import { DeploymentStateMessage } from '../components/empty-state'
import { StepShell } from './layout'
const sourceAppSkeletonKeys = ['first-source-app', 'second-source-app', 'third-source-app']
function sourceAppSearchText(app: App) {
return `${app.name} ${app.id} ${app.mode}`.toLowerCase()
}
function SourceAppSkeleton() {
return (
<div className="divide-y divide-divider-subtle">
{sourceAppSkeletonKeys.map(key => (
<SkeletonRow key={key} className="h-14 px-3 py-2">
<SkeletonRectangle className="my-0 size-7 animate-pulse rounded-lg" />
<div className="flex min-w-0 grow flex-col gap-1">
<SkeletonRectangle className="my-0 h-3.5 w-2/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-2.5 w-1/3 animate-pulse" />
</div>
</SkeletonRow>
))}
</div>
)
}
function SourceAppOption({ app, selected, onSelect }: {
app: App
selected: boolean
onSelect: () => void
}) {
const { t } = useTranslation('deployments')
const mode = toAppMode(app.mode)
return (
<label
className={cn(
'group flex min-h-14 cursor-pointer items-center gap-3 border-b border-b-divider-subtle px-3 py-2 transition-colors first:rounded-t-lg last:rounded-b-lg last:border-b-0',
selected
? 'bg-state-accent-hover hover:bg-state-accent-hover'
: 'bg-background-default hover:bg-state-base-hover',
)}
>
<AppIcon
className="shrink-0"
size="xs"
iconType={app.icon_type}
icon={app.icon}
background={app.icon_background}
imageUrl={app.icon_url}
/>
<span className="flex min-w-0 grow flex-col gap-0.5">
<span className={cn('truncate system-sm-medium', selected ? 'text-text-accent' : 'text-text-primary')}>{app.name}</span>
<span className={cn('truncate system-xs-regular', selected ? 'text-text-secondary' : 'text-text-tertiary')}>{t(`appMode.${mode}`)}</span>
</span>
<input
type="radio"
name="source-app"
checked={selected}
onChange={onSelect}
className="sr-only"
/>
<span
className={cn(
'flex size-5 shrink-0 items-center justify-center rounded-full',
selected ? 'bg-primary-600 text-text-primary-on-surface' : 'text-transparent',
)}
aria-hidden="true"
>
<span className="i-ri-check-line size-4" />
</span>
</label>
)
}
export function SourceStep({
apps,
selectedApp,
searchText,
isLoading,
onSearchTextChange,
onSelectApp,
}: {
apps: App[]
selectedApp?: App
searchText: string
isLoading: boolean
onSearchTextChange: (value: string) => void
onSelectApp: (app: App) => void
}) {
const { t } = useTranslation('deployments')
const effectiveSelectedAppId = selectedApp?.id ?? apps[0]?.id
const filteredApps = searchText.trim()
? apps.filter(app => sourceAppSearchText(app).includes(searchText.trim().toLowerCase()))
: apps
return (
<StepShell
title={t('createGuide.source.title')}
description={t('createGuide.source.description')}
descriptionClassName="lg:hidden"
hideHeader
>
<div className="flex flex-col gap-3">
<div className="relative">
<span className="pointer-events-none absolute top-1/2 left-2.5 i-ri-search-line size-4 -translate-y-1/2 text-text-tertiary" aria-hidden="true" />
<Input
id="create-guide-source-search"
aria-label={t('createGuide.source.sourceApp')}
value={searchText}
onChange={event => onSearchTextChange(event.target.value)}
placeholder={t('createGuide.source.searchPlaceholder')}
className="h-9 pr-8 pl-8"
/>
{searchText && (
<button
type="button"
aria-label={t('createGuide.source.clearSearch')}
onClick={() => onSearchTextChange('')}
className="absolute top-1/2 right-2.5 flex size-4 -translate-y-1/2 items-center justify-center text-text-quaternary hover:text-text-secondary"
>
<span className="i-ri-close-circle-fill size-4" aria-hidden="true" />
</button>
)}
</div>
<div className="max-h-[336px] overflow-y-auto rounded-lg border border-divider-subtle bg-background-default">
{isLoading
? <SourceAppSkeleton />
: filteredApps.length === 0
? (
<DeploymentStateMessage variant="embedded">
{t('createGuide.source.empty')}
</DeploymentStateMessage>
)
: (
<div>
{filteredApps.map(app => (
<SourceAppOption
key={app.id}
app={app}
selected={effectiveSelectedAppId === app.id}
onSelect={() => onSelectApp(app)}
/>
))}
</div>
)}
</div>
</div>
</StepShell>
)
}

View File

@ -0,0 +1,260 @@
'use client'
import type {
CredentialSlot,
EnvVarSlot,
} from '@dify/contracts/enterprise/types.gen'
import type { EnvVarValues } from '../components/env-var-bindings-utils'
import type { BindingSelections, EnvironmentOption } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import {
EnvVarBindingsPanel,
} from '../components/env-var-bindings'
import {
RuntimeCredentialBindingsPanel,
} from '../components/runtime-credential-bindings'
import { TitleTooltip } from '../components/title-tooltip'
import {
environmentBackend,
environmentMatchesIdentifier,
environmentMode,
environmentName,
} from '../environment'
import { StepShell } from './layout'
const targetEnvironmentSkeletonKeys = ['first-target-environment', 'second-target-environment']
const targetBindingSkeletonKeys = ['first-target-binding', 'second-target-binding']
function EnvironmentOptionRow({ environment, selected, onSelect }: {
environment: EnvironmentOption
selected: boolean
onSelect: () => void
}) {
const { t } = useTranslation('deployments')
const mode = environmentMode(environment)
const summary = environment.description?.trim() || `${t(mode === 'isolated' ? 'mode.isolated' : 'mode.shared')} · ${environmentBackend(environment).toUpperCase()}`
return (
<label
className={cn(
'flex cursor-pointer items-center gap-3 rounded-xl border p-3',
selected
? 'border-state-accent-solid bg-state-accent-hover shadow-xs'
: 'border-components-option-card-option-border bg-components-option-card-option-bg hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
)}
>
<input
type="radio"
name="target-environment"
checked={selected}
onChange={onSelect}
className="size-4 shrink-0 accent-primary-600"
/>
<span className="flex min-w-0 grow flex-col gap-1">
<span className={cn('truncate system-sm-semibold', selected ? 'text-text-accent' : 'text-text-primary')}>{environmentName(environment)}</span>
<TitleTooltip content={summary}>
<span className={cn('line-clamp-1 system-xs-regular', selected ? 'text-text-secondary' : 'text-text-tertiary')}>
{summary}
</span>
</TitleTooltip>
</span>
</label>
)
}
function TargetEnvironmentSkeleton() {
return (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{targetEnvironmentSkeletonKeys.map(key => (
<SkeletonRow key={key} className="h-17 rounded-xl border border-divider-subtle px-3 py-3">
<SkeletonRectangle className="my-0 size-4 animate-pulse rounded-full" />
<div className="flex min-w-0 grow flex-col gap-1.5">
<SkeletonRectangle className="my-0 h-3.5 w-1/2 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-2/3 animate-pulse" />
</div>
</SkeletonRow>
))}
</div>
)
}
function TargetBindingSkeleton() {
return (
<div className="border-t border-divider-subtle">
{targetBindingSkeletonKeys.map(key => (
<SkeletonRow key={key} className="h-15 px-3 py-3">
<div className="flex min-w-0 grow flex-col gap-1.5">
<SkeletonRectangle className="my-0 h-3.5 w-1/3 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-1/2 animate-pulse" />
</div>
<SkeletonRectangle className="my-0 h-8 w-48 animate-pulse rounded-lg" />
</SkeletonRow>
))}
</div>
)
}
function TargetStep({
environments,
bindingSlots,
envVarSlots,
selectedEnvironmentId,
bindingSelections,
envVarValues,
isEnvironmentLoading,
isEnvironmentError,
isBindingLoading,
isBindingError,
onSelectEnvironment,
onSelectBinding,
onSetEnvVar,
}: {
environments: EnvironmentOption[]
bindingSlots: CredentialSlot[]
envVarSlots: EnvVarSlot[]
selectedEnvironmentId: string
bindingSelections: BindingSelections
envVarValues: EnvVarValues
isEnvironmentLoading: boolean
isEnvironmentError: boolean
isBindingLoading: boolean
isBindingError: boolean
onSelectEnvironment: (environmentId: string) => void
onSelectBinding: (slot: string, value: string) => void
onSetEnvVar: (key: string, value: string) => void
}) {
const { t } = useTranslation('deployments')
const hasEnvironmentOptions = environments.length > 0
return (
<StepShell
title={t('createGuide.target.title')}
description={t('createGuide.target.description')}
hideHeader
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('createGuide.target.environment')}</div>
{hasEnvironmentOptions
? (
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{environments.map(environment => (
<EnvironmentOptionRow
key={environment.id}
environment={environment}
selected={environmentMatchesIdentifier(environment, selectedEnvironmentId)}
onSelect={() => onSelectEnvironment(environment.id)}
/>
))}
</div>
)
: isEnvironmentLoading
? <TargetEnvironmentSkeleton />
: (
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{isEnvironmentError
? t('createGuide.target.loadEnvironmentsFailed')
: t('createGuide.target.noEnvironmentOptions')}
</div>
)}
</div>
{isBindingLoading || isBindingError
? (
<div className="overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg">
<div className="flex min-w-0 flex-col gap-0.5 px-3 py-2.5">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('createGuide.target.bindings')}</div>
<span className="system-xs-regular text-text-quaternary">{t('createGuide.target.bindingHint')}</span>
</div>
{isBindingLoading
? <TargetBindingSkeleton />
: (
<div className="border-t border-divider-subtle px-3 py-3 system-sm-regular text-text-quaternary">
{t('createGuide.target.loadBindingsFailed')}
</div>
)}
</div>
)
: (
<RuntimeCredentialBindingsPanel
slots={bindingSlots}
selections={bindingSelections}
title={t('createGuide.target.bindings')}
hint={t('createGuide.target.bindingHint')}
requiredLabel={t('createGuide.target.required')}
noBindingRequiredLabel={t('createGuide.target.noBindingRequired')}
noCredentialCandidatesLabel={t('createGuide.target.noCredentialCandidates')}
selectCredentialLabel={t('createGuide.target.selectCredential')}
missingRequiredLabel={t('createGuide.target.missingRequiredBinding')}
bindingCountLabel={t('createGuide.target.bindingCount', { count: bindingSlots.length })}
onChange={onSelectBinding}
className="border-components-option-card-option-border bg-components-option-card-option-bg"
/>
)}
{!isBindingLoading && !isBindingError && (
<EnvVarBindingsPanel
slots={envVarSlots}
values={envVarValues}
title={t('createGuide.target.envVars')}
hint={t('createGuide.target.envVarHint')}
requiredLabel={t('createGuide.target.required')}
envVarPlaceholder={t('createGuide.target.envVarPlaceholder')}
envVarCountLabel={t('createGuide.target.envVarCount', { count: envVarSlots.length })}
onChange={onSetEnvVar}
className="border-components-option-card-option-border bg-components-option-card-option-bg"
/>
)}
</div>
</StepShell>
)
}
export function TargetReviewSections({
bindingSelections,
bindingSlots,
envVarSlots,
envVarValues,
environments,
isBindingError,
isBindingLoading,
isEnvironmentError,
isEnvironmentLoading,
onSelectBinding,
onSelectEnvironment,
onSetEnvVar,
selectedEnvironmentId,
}: {
bindingSelections: BindingSelections
bindingSlots: CredentialSlot[]
envVarSlots: EnvVarSlot[]
envVarValues: EnvVarValues
environments: EnvironmentOption[]
isBindingError: boolean
isBindingLoading: boolean
isEnvironmentError: boolean
isEnvironmentLoading: boolean
onSelectBinding: (slot: string, value: string) => void
onSelectEnvironment: (environmentId: string) => void
onSetEnvVar: (key: string, value: string) => void
selectedEnvironmentId: string
}) {
return (
<TargetStep
environments={environments}
bindingSlots={bindingSlots}
envVarSlots={envVarSlots}
selectedEnvironmentId={selectedEnvironmentId}
bindingSelections={bindingSelections}
envVarValues={envVarValues}
isEnvironmentLoading={isEnvironmentLoading}
isEnvironmentError={isEnvironmentError}
isBindingLoading={isBindingLoading}
isBindingError={isBindingError}
onSelectEnvironment={onSelectEnvironment}
onSelectBinding={onSelectBinding}
onSetEnvVar={onSetEnvVar}
/>
)
}

View File

@ -0,0 +1,9 @@
import type {
Environment,
} from '@dify/contracts/enterprise/types.gen'
import type { RuntimeCredentialBindingSelections } from '../components/runtime-credential-bindings-utils'
export type GuideMethod = 'bindApp' | 'importDsl'
export type GuideStep = 'source' | 'release' | 'target'
export type EnvironmentOption = Environment & { id: string }
export type BindingSelections = RuntimeCredentialBindingSelections

View File

@ -0,0 +1,567 @@
'use client'
import type {
Environment,
} from '@dify/contracts/enterprise/types.gen'
import type { EnvVarValues } from '../components/env-var-bindings-utils'
import type {
BindingSelections,
EnvironmentOption,
GuideMethod,
GuideStep,
} from './types'
import type { App } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query'
import { load as yamlLoad } from 'js-yaml'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter } from '@/next/navigation'
import { consoleClient, consoleQuery } from '@/service/client'
import {
hasEnvVarSlotKey,
hasMissingRequiredEnvVarValue,
selectedDeploymentEnvVars,
} from '../components/env-var-bindings-utils'
import {
hasMissingRequiredRuntimeCredentialBinding,
runtimeCredentialSlotKey,
selectedDeploymentRuntimeCredentials,
selectedRuntimeCredentialSelections,
} from '../components/runtime-credential-bindings-utils'
import { DEPLOYMENT_PAGE_SIZE, SOURCE_APPS_PAGE_SIZE } from '../data'
import {
environmentDeploymentId,
environmentMatchesIdentifier,
} from '../environment'
import { deploymentErrorMessage } from '../error'
import { createDeploymentIdempotencyKey } from '../idempotency'
type DslMetadata = {
app?: {
name?: unknown
}
}
const RANDOM_SUFFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz'
const RANDOM_SUFFIX_LENGTH = 4
const RANDOM_SUFFIX_FALLBACK_LENGTH = 6
const RANDOM_SUFFIX_MAX_ATTEMPTS = 16
function hasEnvironmentId(environment?: Environment): environment is EnvironmentOption {
return Boolean(environment?.id)
}
function encodeUtf8Base64(value: string) {
const bytes = new TextEncoder().encode(value)
const chunkSize = 0x8000
const chunks: string[] = []
for (let offset = 0; offset < bytes.length; offset += chunkSize)
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)))
return btoa(chunks.join(''))
}
function dslAppName(content: string) {
try {
const parsed = yamlLoad(content) as DslMetadata | undefined
const name = parsed?.app?.name
return typeof name === 'string' ? name.trim() : ''
}
catch {
return ''
}
}
function randomLetterCombination(length: number) {
const randomValues = new Uint8Array(length)
if (globalThis.crypto) {
globalThis.crypto.getRandomValues(randomValues)
}
else {
randomValues.forEach((_, index) => {
randomValues[index] = Math.floor(Math.random() * 256)
})
}
return Array.from(randomValues, value => RANDOM_SUFFIX_ALPHABET[value % RANDOM_SUFFIX_ALPHABET.length]).join('')
}
function availableInstanceName(baseName: string, existingNames: readonly string[]) {
const existingNameSet = new Set(existingNames)
if (!existingNameSet.has(baseName))
return baseName
for (let attempt = 0; attempt < RANDOM_SUFFIX_MAX_ATTEMPTS; attempt++) {
const candidate = `${baseName}-${randomLetterCombination(RANDOM_SUFFIX_LENGTH)}`
if (!existingNameSet.has(candidate))
return candidate
}
return `${baseName}-${randomLetterCombination(RANDOM_SUFFIX_FALLBACK_LENGTH)}`
}
export function useCreateDeploymentGuide() {
const { t } = useTranslation('deployments')
const router = useRouter()
const createInitialDeploymentFromSourceApp = useMutation(consoleQuery.enterprise.deploymentService.createInitialDeploymentFromSourceApp.mutationOptions())
const createInitialDeploymentFromDsl = useMutation(consoleQuery.enterprise.deploymentService.createInitialDeploymentFromDsl.mutationOptions())
const [step, setStep] = useState<GuideStep>('source')
const [method, setMethod] = useState<GuideMethod>('bindApp')
const [sourceSearchText, setSourceSearchText] = useState('')
const [selectedApp, setSelectedApp] = useState<App>()
const [dslFile, setDslFile] = useState<File>()
const [dslContent, setDslContent] = useState('')
const [dslDefaultAppName, setDslDefaultAppName] = useState('')
const [isReadingDsl, setIsReadingDsl] = useState(false)
const [dslReadError, setDslReadError] = useState(false)
const [instanceName, setInstanceName] = useState('')
const [instanceDescription, setInstanceDescription] = useState('')
const [releaseName, setReleaseName] = useState('')
const [releaseDescription, setReleaseDescription] = useState('')
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState('')
const [manualBindingSelections, setManualBindingSelections] = useState<BindingSelections>({})
const [envVarValues, setEnvVarValues] = useState<EnvVarValues>({})
const [isSkippingReleaseOnly, setIsSkippingReleaseOnly] = useState(false)
const dslReadTokenRef = useRef(0)
const sourceAppsQuery = useInfiniteQuery({
...consoleQuery.apps.list.infiniteOptions({
input: pageParam => ({
query: {
page: Number(pageParam),
limit: SOURCE_APPS_PAGE_SIZE,
name: sourceSearchText,
},
}),
getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : undefined,
initialPageParam: 1,
placeholderData: keepPreviousData,
}),
})
const sourceApps = sourceAppsQuery.data?.pages.flatMap(page => page.data) ?? []
const appInstancesQuery = useQuery({
...consoleQuery.enterprise.appInstanceService.listAppInstances.queryOptions({
input: {
query: {
pageNumber: 1,
resultsPerPage: DEPLOYMENT_PAGE_SIZE,
},
},
}),
placeholderData: keepPreviousData,
})
const effectiveSelectedApp = selectedApp ?? sourceApps[0]
const hasDslContent = Boolean(dslContent.trim())
const encodedDslContent = hasDslContent ? encodeUtf8Base64(dslContent) : ''
const shouldResolveDeploymentTarget = step === 'target'
const shouldLoadSourceDeploymentTarget = method === 'bindApp' && Boolean(effectiveSelectedApp?.id) && shouldResolveDeploymentTarget
const shouldLoadDslDeploymentTarget = method === 'importDsl' && hasDslContent && shouldResolveDeploymentTarget
const shouldLoadDeploymentTarget = shouldLoadSourceDeploymentTarget || shouldLoadDslDeploymentTarget
const deployableEnvironmentsQuery = useQuery(consoleQuery.enterprise.environmentService.listDeployableEnvironments.queryOptions({
input: {
query: {},
},
enabled: shouldLoadDeploymentTarget,
}))
const sourceDeploymentOptionsQuery = useQuery(consoleQuery.enterprise.releaseService.getDeploymentOptionsFromSourceApp.queryOptions({
input: {
body: {
sourceAppId: effectiveSelectedApp?.id ?? '',
},
},
enabled: shouldLoadSourceDeploymentTarget,
}))
const dslDeploymentOptionsQuery = useQuery(consoleQuery.enterprise.releaseService.getDeploymentOptionsFromDsl.queryOptions({
input: {
body: {
dsl: encodedDslContent,
},
},
enabled: shouldLoadDslDeploymentTarget,
}))
const deploymentOptionsQuery = method === 'importDsl' ? dslDeploymentOptionsQuery : sourceDeploymentOptionsQuery
const deploymentOptions = deploymentOptionsQuery.data?.options
const environments = shouldLoadDeploymentTarget
? deployableEnvironmentsQuery.data?.data?.filter(hasEnvironmentId) ?? []
: []
const bindingSlots = shouldLoadDeploymentTarget
? deploymentOptions?.credentialSlots?.filter(slot => runtimeCredentialSlotKey(slot)) ?? []
: []
const envVarSlots = shouldLoadDeploymentTarget
? deploymentOptions?.envVarSlots?.filter(hasEnvVarSlotKey) ?? []
: []
const effectiveSelectedEnvironmentId = selectedEnvironmentId || environments[0]?.id || ''
const selectedEnvironment = environments.find(env => environmentMatchesIdentifier(env, effectiveSelectedEnvironmentId)) ?? environments[0]
const bindingSelections = selectedRuntimeCredentialSelections(bindingSlots, manualBindingSelections)
const requiredBindingsReady = bindingSlots.every(slot => !hasMissingRequiredRuntimeCredentialBinding(slot, bindingSelections[runtimeCredentialSlotKey(slot)]))
const requiredEnvVarsReady = envVarSlots.every(slot => !hasMissingRequiredEnvVarValue(slot, envVarValues))
const isEnvironmentLoading = shouldLoadDeploymentTarget && (deployableEnvironmentsQuery.isLoading || (deployableEnvironmentsQuery.isFetching && !deployableEnvironmentsQuery.data))
const isBindingLoading = shouldLoadDeploymentTarget && (deploymentOptionsQuery.isLoading || (deploymentOptionsQuery.isFetching && !deploymentOptionsQuery.data))
const isDeploying = isSkippingReleaseOnly
|| createInitialDeploymentFromSourceApp.isPending
|| createInitialDeploymentFromDsl.isPending
const sourceName = method === 'importDsl'
? dslDefaultAppName || t('createGuide.dsl.defaultAppName')
: method === 'bindApp'
? effectiveSelectedApp?.name ?? ''
: ''
const defaultedReleaseName = t('createGuide.release.defaultName')
const submittedInstanceName = instanceName.trim()
const submittedReleaseName = releaseName.trim()
const submittedReleaseDescription = releaseDescription.trim()
const existingInstanceNames = appInstancesQuery.data?.data?.map(appInstance => appInstance.name?.trim()).filter((name): name is string => Boolean(name)) ?? []
const hasInstanceNameConflict = Boolean(submittedInstanceName && existingInstanceNames.includes(submittedInstanceName))
const instanceNameError = hasInstanceNameConflict ? t('createGuide.release.instanceNameConflict') : undefined
const isSourceReady = Boolean(method && (method === 'importDsl' ? hasDslContent && !isReadingDsl && !dslReadError : effectiveSelectedApp?.id))
const isInitialReleaseReady = Boolean(isSourceReady && submittedInstanceName && submittedReleaseName && !hasInstanceNameConflict)
const showTargetConfiguration = Boolean(method && step === 'target')
function selectMethod(nextMethod: GuideMethod) {
setMethod(nextMethod)
setSelectedEnvironmentId('')
setManualBindingSelections({})
setEnvVarValues({})
}
function handleDslFileChange(file?: File) {
const readToken = dslReadTokenRef.current + 1
dslReadTokenRef.current = readToken
setDslFile(file)
setDslContent('')
setDslDefaultAppName('')
setDslReadError(false)
setSelectedEnvironmentId('')
setManualBindingSelections({})
setEnvVarValues({})
if (!file) {
setIsReadingDsl(false)
return
}
setIsReadingDsl(true)
void file.text()
.then((content) => {
if (dslReadTokenRef.current !== readToken)
return
setDslContent(content)
setDslDefaultAppName(dslAppName(content))
})
.catch(() => {
if (dslReadTokenRef.current !== readToken)
return
setDslReadError(true)
})
.finally(() => {
if (dslReadTokenRef.current !== readToken)
return
setIsReadingDsl(false)
})
}
function handleSelectMethod(nextMethod: GuideMethod) {
selectMethod(nextMethod)
setStep('source')
}
function canContinueCurrentStep() {
if (step === 'source')
return isSourceReady
if (step === 'release') {
return isInitialReleaseReady
}
if (step === 'target') {
const deploymentTargetReady = shouldLoadDeploymentTarget
&& !isEnvironmentLoading
&& !deployableEnvironmentsQuery.isError
&& !isBindingLoading
&& !deploymentOptionsQuery.isError
return Boolean(
selectedEnvironment?.id
&& deploymentTargetReady
&& requiredBindingsReady
&& requiredEnvVarsReady
&& isInitialReleaseReady,
)
}
return false
}
function handleBack() {
if (isDeploying)
return
if (step === 'release')
setStep('source')
else if (step === 'target')
setStep('release')
}
async function createReleaseArtifactsAndContinue() {
if (method === 'bindApp' && (!effectiveSelectedApp?.id || isDeploying))
return
if (method === 'importDsl' && (!hasDslContent || isReadingDsl || dslReadError || isDeploying))
return
setSelectedEnvironmentId('')
setManualBindingSelections({})
setEnvVarValues({})
setStep('target')
}
function applyReleaseDefaults() {
const nextInstanceName = sourceName.trim()
if (!instanceName.trim() && nextInstanceName)
setInstanceName(availableInstanceName(nextInstanceName, existingInstanceNames))
if (!releaseName.trim())
setReleaseName(defaultedReleaseName)
}
async function createInitialReleaseOnly() {
setIsSkippingReleaseOnly(true)
try {
const createdAppInstance = await consoleClient.enterprise.appInstanceService.createAppInstance({
body: {
name: submittedInstanceName,
description: instanceDescription.trim() || undefined,
},
})
const appInstanceId = createdAppInstance.appInstance?.id
if (!appInstanceId)
throw new Error('Create app instance did not return an app instance.')
const createdRelease = method === 'importDsl'
? await consoleClient.enterprise.releaseService.createReleaseFromDsl({
body: {
appInstanceId,
dsl: encodedDslContent,
name: submittedReleaseName,
description: submittedReleaseDescription || undefined,
createAppInstance: false,
},
})
: effectiveSelectedApp?.id
? await consoleClient.enterprise.releaseService.createReleaseFromSourceApp({
body: {
appInstanceId,
sourceAppId: effectiveSelectedApp.id,
name: submittedReleaseName,
description: submittedReleaseDescription || undefined,
createAppInstance: false,
},
})
: undefined
if (!createdRelease?.release?.id)
throw new Error('Create release did not return a release.')
router.push(`/deployments/${appInstanceId}/overview`)
}
finally {
setIsSkippingReleaseOnly(false)
}
}
async function createDeploymentAndRelease({ deployToEnvironment }: {
deployToEnvironment: boolean
}) {
if (isDeploying || !isInitialReleaseReady)
return
if (hasInstanceNameConflict)
return
if (deployToEnvironment && !selectedEnvironment?.id)
return
if (method === 'bindApp' && !effectiveSelectedApp?.id)
return
if (method === 'importDsl' && !hasDslContent)
return
try {
if (!deployToEnvironment) {
await createInitialReleaseOnly()
return
}
const targetEnvironmentId = await resolveSelectedDeploymentEnvironmentId()
if (!targetEnvironmentId) {
toast.error(t('createGuide.errors.deployFailed'))
return
}
const missingRequiredBinding = bindingSlots.some(slot => hasMissingRequiredRuntimeCredentialBinding(slot, bindingSelections[runtimeCredentialSlotKey(slot)]))
if (missingRequiredBinding)
throw new Error('Missing required deployment binding.')
const missingRequiredEnvVar = envVarSlots.some(slot => hasMissingRequiredEnvVarValue(slot, envVarValues))
if (missingRequiredEnvVar)
throw new Error('Missing required deployment environment variable.')
const idempotencyKey = createDeploymentIdempotencyKey()
const response = method === 'importDsl'
? await createInitialDeploymentFromDsl.mutateAsync({
body: {
dsl: encodedDslContent,
environmentId: targetEnvironmentId,
appInstanceName: submittedInstanceName,
appInstanceDescription: instanceDescription.trim() || undefined,
releaseName: submittedReleaseName,
releaseDescription: submittedReleaseDescription || undefined,
credentials: selectedDeploymentRuntimeCredentials(bindingSlots, bindingSelections),
envVars: selectedDeploymentEnvVars(envVarSlots, envVarValues),
idempotencyKey,
expectedDslDigest: deploymentOptions?.dslDigest,
},
})
: effectiveSelectedApp?.id
? await createInitialDeploymentFromSourceApp.mutateAsync({
body: {
sourceAppId: effectiveSelectedApp.id,
environmentId: targetEnvironmentId,
appInstanceName: submittedInstanceName,
appInstanceDescription: instanceDescription.trim() || undefined,
releaseName: submittedReleaseName,
releaseDescription: submittedReleaseDescription || undefined,
credentials: selectedDeploymentRuntimeCredentials(bindingSlots, bindingSelections),
envVars: selectedDeploymentEnvVars(envVarSlots, envVarValues),
idempotencyKey,
expectedDslDigest: deploymentOptions?.dslDigest,
},
})
: undefined
const appInstanceId = response?.appInstance?.id ?? response?.release?.appInstanceId
if (!appInstanceId)
throw new Error('Create initial deployment did not return an app instance.')
router.push(`/deployments/${appInstanceId}/overview`)
}
catch (error) {
const fallbackMessage = t(deployToEnvironment ? 'createGuide.errors.deployFailed' : 'createGuide.errors.createReleaseFailed')
toast.error(await deploymentErrorMessage(error) || fallbackMessage)
}
}
async function handleDeploy() {
await createDeploymentAndRelease({ deployToEnvironment: true })
}
async function handleSkipDeployment() {
await createDeploymentAndRelease({ deployToEnvironment: false })
}
async function resolveSelectedDeploymentEnvironmentId() {
const currentEnvironmentId = environmentDeploymentId(selectedEnvironment)
if (currentEnvironmentId)
return currentEnvironmentId
const selectedEnvironmentIdentifier = selectedEnvironmentId || selectedEnvironment?.id || selectedEnvironment?.name || ''
const selectedEnvironmentName = selectedEnvironment?.name || ''
const freshResult = await deployableEnvironmentsQuery.refetch()
const freshEnvironments = freshResult.data?.data?.filter(hasEnvironmentId) ?? []
const freshSelectedEnvironment = freshEnvironments.find(environment => (
environmentMatchesIdentifier(environment, selectedEnvironmentIdentifier)
|| (selectedEnvironmentName && environment.name === selectedEnvironmentName)
)) ?? freshEnvironments[0]
return environmentDeploymentId(freshSelectedEnvironment)
}
function handlePrimaryAction() {
if (!canContinueCurrentStep())
return
if (step === 'source') {
if (method === 'bindApp' && effectiveSelectedApp)
setSelectedApp(effectiveSelectedApp)
applyReleaseDefaults()
setStep('release')
return
}
if (step === 'release') {
if (method === 'bindApp' && effectiveSelectedApp)
setSelectedApp(effectiveSelectedApp)
void createReleaseArtifactsAndContinue()
return
}
if (step === 'target') {
void handleDeploy()
}
}
return {
canContinue: canContinueCurrentStep(),
canSkipDeployment: Boolean(step === 'target' && isInitialReleaseReady),
creationSectionsProps: {
defaultedReleaseName,
dslFile,
dslReadError,
instanceDescription,
instanceName,
instanceNameError,
isReadingDsl,
method,
onDslFileChange: handleDslFileChange,
onInstanceDescriptionChange: (value: string) => {
setInstanceDescription(value)
setStep('release')
},
onInstanceNameChange: (value: string) => {
setInstanceName(value)
setStep('release')
},
onReleaseDescriptionChange: (value: string) => {
setReleaseDescription(value)
setStep('release')
},
onReleaseNameChange: (value: string) => {
setReleaseName(value)
setStep('release')
},
onSearchTextChange: setSourceSearchText,
onSelectMethod: handleSelectMethod,
onSelectSourceApp: (app: App) => {
setSelectedApp(app)
},
releaseDescription,
releaseName,
selectedApp: effectiveSelectedApp,
sourceApps,
sourceAppsLoading: sourceAppsQuery.isLoading || (sourceAppsQuery.isFetching && sourceApps.length === 0),
sourceName,
sourceSearchText,
stage: step === 'release' ? 'release' as const : 'source' as const,
},
handleBack,
handlePrimaryAction,
handleSkipDeployment,
isDeploying,
isSkippingDeployment: isSkippingReleaseOnly,
showTargetConfiguration,
step,
targetReviewSectionsProps: {
bindingSelections,
bindingSlots,
environments,
envVarSlots,
envVarValues,
isBindingError: deploymentOptionsQuery.isError,
isBindingLoading,
isEnvironmentError: deployableEnvironmentsQuery.isError,
isEnvironmentLoading,
onSelectBinding: (slot: string, value: string) => {
setManualBindingSelections(prev => ({ ...prev, [slot]: value }))
},
onSelectEnvironment: setSelectedEnvironmentId,
onSetEnvVar: (key: string, value: string) => {
setEnvVarValues(prev => ({ ...prev, [key]: value }))
},
selectedEnvironmentId: effectiveSelectedEnvironmentId,
},
}
}

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,77 @@
import type { DeploymentUiStatus } from './runtime-status'
export type DeploymentStatusTone = 'neutral' | 'success' | 'warning' | 'danger' | 'info'
const STATUS_TONE: Record<DeploymentUiStatus, DeploymentStatusTone> = {
deploy_failed: 'danger',
deploying: 'info',
drifted: 'warning',
invalid: 'danger',
not_deployed: 'neutral',
ready: 'success',
unknown: 'neutral',
}
const TONE_CLASS_NAMES: Record<DeploymentStatusTone, { badge: string, dot: string, icon: string }> = {
danger: {
badge: 'border-util-colors-red-red-200 bg-util-colors-red-red-50 text-util-colors-red-red-700',
dot: 'bg-util-colors-red-red-500',
icon: 'text-util-colors-red-red-600',
},
info: {
badge: 'border-util-colors-blue-blue-200 bg-util-colors-blue-blue-50 text-util-colors-blue-blue-700',
dot: 'bg-util-colors-blue-blue-500',
icon: 'text-util-colors-blue-blue-600',
},
neutral: {
badge: 'border-divider-subtle bg-background-section-burn text-text-tertiary',
dot: 'bg-text-quaternary',
icon: 'text-text-tertiary',
},
success: {
badge: 'border-util-colors-green-green-200 bg-util-colors-green-green-50 text-util-colors-green-green-700',
dot: 'bg-util-colors-green-green-500',
icon: 'text-util-colors-green-green-600',
},
warning: {
badge: 'border-util-colors-warning-warning-200 bg-util-colors-warning-warning-50 text-util-colors-warning-warning-700',
dot: 'bg-util-colors-warning-warning-500',
icon: 'text-util-colors-warning-warning-600',
},
}
const STATUS_LABEL_KEYS = {
deploy_failed: 'status.deployFailed',
deploying: 'status.deploying',
drifted: 'status.drifted',
invalid: 'status.invalid',
not_deployed: 'status.notDeployed',
ready: 'status.ready',
unknown: 'status.unknown',
} as const satisfies Record<DeploymentUiStatus, string>
const STATUS_ICON_CLASS_NAMES: Record<DeploymentUiStatus, string> = {
deploy_failed: 'i-ri-alert-line',
deploying: 'i-ri-loader-4-line animate-spin motion-reduce:animate-none',
drifted: 'i-ri-loop-left-line',
invalid: 'i-ri-error-warning-line',
not_deployed: 'i-ri-circle-line',
ready: 'i-ri-check-line',
unknown: 'i-ri-question-line',
}
export function deploymentStatusLabelKey(status: DeploymentUiStatus) {
return STATUS_LABEL_KEYS[status]
}
export function deploymentStatusTone(status: DeploymentUiStatus) {
return STATUS_TONE[status]
}
export function deploymentStatusToneClassNames(status: DeploymentUiStatus) {
return TONE_CLASS_NAMES[deploymentStatusTone(status)]
}
export function deploymentStatusIconClassName(status: DeploymentUiStatus) {
return STATUS_ICON_CLASS_NAMES[status]
}

View File

@ -0,0 +1,83 @@
'use client'
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import type { ComponentPropsWithRef } from 'react'
import type { DeploymentUiStatus } from './runtime-status'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import {
deploymentStatusIconClassName,
deploymentStatusLabelKey,
deploymentStatusToneClassNames,
} from './deployment-ui-utils'
import { environmentName } from './environment'
import { deploymentStatus } from './runtime-status'
export function DeploymentStatusBadge({
status,
className,
compact,
}: {
status: DeploymentUiStatus
className?: string
compact?: boolean
}) {
const { t } = useTranslation('deployments')
const toneClassNames = deploymentStatusToneClassNames(status)
return (
<span
className={cn(
'inline-flex h-6 max-w-full items-center gap-1.5 rounded-md border px-2 system-xs-medium',
toneClassNames.badge,
className,
)}
>
<span
aria-hidden
className={cn('size-3.5 shrink-0', deploymentStatusIconClassName(status), toneClassNames.icon)}
/>
{!compact && <span className="truncate">{t(deploymentStatusLabelKey(status))}</span>}
</span>
)
}
type EnvironmentDeploymentBadgeProps = Omit<ComponentPropsWithRef<'span'>, 'children' | 'title'> & {
row: EnvironmentDeployment
showStatus?: boolean
summaryLabel?: string
}
export function EnvironmentDeploymentBadge({
row,
className,
showStatus = true,
summaryLabel,
'aria-label': ariaLabel,
ref,
...props
}: EnvironmentDeploymentBadgeProps) {
const { t } = useTranslation('deployments')
const name = environmentName(row.environment)
const status = deploymentStatus(row)
const toneClassNames = deploymentStatusToneClassNames(status)
const statusLabel = t(deploymentStatusLabelKey(status))
const label = summaryLabel ?? `${name} · ${statusLabel}`
const visibleLabel = showStatus ? `${name} · ${statusLabel}` : name
return (
<span
ref={ref}
aria-label={ariaLabel ?? label}
className={cn(
'inline-flex h-6 max-w-full cursor-default items-center gap-1.5 rounded-md border px-2 system-xs-medium',
toneClassNames.badge,
className,
)}
{...props}
>
<span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', toneClassNames.dot, status === 'deploying' && 'animate-pulse')} />
<span className="truncate">{visibleLabel}</span>
</span>
)
}

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,121 @@
'use client'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import {
DeploymentEmptyState,
DeploymentNoticeState,
DeploymentStateMessage,
} from '../components/empty-state'
type SectionProps = {
title: string
description?: string
action?: ReactNode
children: ReactNode
layout?: 'block' | 'row'
tone?: 'default' | 'destructive'
showDivider?: boolean
}
export function DetailEmptyState(props: Parameters<typeof DeploymentEmptyState>[0]) {
return <DeploymentEmptyState {...props} />
}
export function DetailNoticeState(props: Parameters<typeof DeploymentNoticeState>[0]) {
return <DeploymentNoticeState {...props} />
}
export function SectionState({ children }: {
children: ReactNode
}) {
return <DeploymentStateMessage variant="section">{children}</DeploymentStateMessage>
}
export function DetailListState({ children }: {
children: ReactNode
}) {
return <DeploymentStateMessage variant="list">{children}</DeploymentStateMessage>
}
export function Section({
title,
description,
action,
children,
layout = 'block',
tone = 'default',
showDivider = true,
}: SectionProps) {
const hasAction = Boolean(action)
const titleClassName = cn(
'system-sm-semibold',
tone === 'destructive'
? 'text-util-colors-red-red-700'
: layout === 'row'
? 'text-text-secondary'
: 'text-text-primary',
)
const descriptionClassName = cn(
'mt-1 body-xs-regular',
tone === 'destructive' ? 'text-util-colors-red-red-600' : 'text-text-tertiary',
)
if (layout === 'row') {
return (
<section className={cn('py-4 first:pt-0 last:pb-0', showDivider && 'border-b border-divider-subtle last:border-b-0')}>
<div className="flex flex-col gap-3 sm:flex-row sm:gap-x-6">
<div className="flex min-w-0 shrink-0 flex-col sm:w-40 sm:pt-1">
<div className={titleClassName}>
{title}
</div>
{description && (
<p className={descriptionClassName}>
{description}
</p>
)}
</div>
<div className="min-w-0 grow">
{hasAction
? (
<div className="flex min-w-0 items-start gap-3">
<div className="min-w-0 grow">
{children}
</div>
<div className="shrink-0">
{action}
</div>
</div>
)
: children}
</div>
</div>
</section>
)
}
return (
<section className={cn('py-6 first:pt-0 last:pb-0', showDivider && 'border-b border-divider-subtle last:border-b-0')}>
<div className="mb-3 flex min-w-0 flex-col">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
<div className={titleClassName}>
{title}
</div>
{hasAction && (
<div className="shrink-0">
{action}
</div>
)}
</div>
{description && (
<p className={cn(descriptionClassName, 'max-w-150')}>
{description}
</p>
)}
</div>
<div className="min-w-0">
{children}
</div>
</section>
)
}

View File

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

View File

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

View File

@ -0,0 +1,108 @@
'use client'
import type { EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import type { DeploymentUiStatus } from '../../runtime-status'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import {
deploymentStatusIconClassName,
deploymentStatusToneClassNames,
} from '../../deployment-ui-utils'
import { releaseLabel } from '../../release'
import {
deploymentStatus,
isUndeployedDeploymentRow,
} from '../../runtime-status'
function DeploymentStatusPill({ status, label }: {
status: DeploymentUiStatus
label: string
}) {
const toneClassNames = deploymentStatusToneClassNames(status)
return (
<span
className={cn(
'inline-flex h-6 max-w-full items-center gap-1.5 rounded-md border px-2 system-xs-medium',
toneClassNames.badge,
)}
>
<span
aria-hidden
className={cn('size-3.5 shrink-0', deploymentStatusIconClassName(status), toneClassNames.icon)}
/>
<span className="truncate">{label}</span>
</span>
)
}
export function DeploymentStatusSummary({ row }: {
row: EnvironmentDeployment
}) {
const { t } = useTranslation('deployments')
if (isUndeployedDeploymentRow(row)) {
return (
<DeploymentStatusPill
status="not_deployed"
label={t('status.notDeployed')}
/>
)
}
const status = deploymentStatus(row)
if (status === 'deploying') {
const targetRelease = row.desiredRelease ?? row.currentRelease
const hasTargetRelease = !!(targetRelease?.name || targetRelease?.id)
const statusLabel = hasTargetRelease
? t('deployTab.status.deployingRelease', { release: releaseLabel(targetRelease) })
: t('status.undeploying')
return <DeploymentStatusPill status="deploying" label={statusLabel} />
}
if (status === 'deploy_failed') {
const hasRunningRelease = !!row.currentRelease?.id
return (
<DeploymentStatusPill
status="deploy_failed"
label={t(hasRunningRelease ? 'deployTab.status.runningWithFailed' : 'deployTab.status.deployFailed')}
/>
)
}
if (status === 'drifted') {
const hasRunningRelease = !!row.currentRelease?.id
return (
<DeploymentStatusPill
status="drifted"
label={t(hasRunningRelease ? 'deployTab.status.runningOutOfSync' : 'status.drifted')}
/>
)
}
if (status === 'invalid') {
return (
<DeploymentStatusPill
status="invalid"
label={t('status.invalid')}
/>
)
}
if (status === 'unknown') {
return (
<DeploymentStatusPill
status="unknown"
label={t('status.unknown')}
/>
)
}
return (
<DeploymentStatusPill
status="ready"
label={t('status.ready')}
/>
)
}

View File

@ -0,0 +1,45 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { useQuery } from '@tanstack/react-query'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { deploymentStatusPollingInterval, hasRuntimeInstanceDeployment } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
export function NewDeploymentButton({ appInstanceId }: {
appInstanceId: string
}) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
return (
<Button
size="medium"
variant="primary"
className="gap-1.5"
onClick={() => openDeployDrawer({ appInstanceId })}
>
<span className="i-ri-rocket-line size-4 shrink-0" aria-hidden="true" />
{t('deployTab.newDeployment')}
</Button>
)
}
export function NewDeploymentHeaderAction({ appInstanceId }: {
appInstanceId: string
}) {
const environmentDeploymentsQuery = useQuery(consoleQuery.enterprise.deploymentService.listEnvironmentDeployments.queryOptions({
input: {
params: { appInstanceId },
},
refetchInterval: query => deploymentStatusPollingInterval(query.state.data),
}))
const rows = environmentDeploymentsQuery.data?.data?.filter(hasRuntimeInstanceDeployment) ?? []
if (environmentDeploymentsQuery.isLoading || environmentDeploymentsQuery.isError || rows.length === 0)
return null
return <NewDeploymentButton appInstanceId={appInstanceId} />
}

View File

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

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,98 @@
'use client'
import type { ReactNode } from 'react'
import type { InstanceDetailTabKey } from './tabs'
import { useTranslation } from 'react-i18next'
import useDocumentTitle from '@/hooks/use-document-title'
import Link from '@/next/link'
import { useSelectedLayoutSegment } from '@/next/navigation'
import { DeployDrawer } from '../components/deploy-drawer'
import { NewDeploymentHeaderAction } from './deploy-tab/new-deployment-button'
import { DeploymentSidebar } from './deployment-sidebar'
import { DeveloperApiHeaderActions, DeveloperApiHeaderSwitch } from './settings-tab/access/developer-api-section'
import { INSTANCE_DETAIL_TAB_KEYS, isInstanceDetailTabKey } from './tabs'
import { CreateReleaseControl } from './versions-tab/create-release-control'
function MobileDetailTabs({ appInstanceId, activeTab }: {
appInstanceId: string
activeTab: InstanceDetailTabKey
}) {
const { t } = useTranslation('deployments')
return (
<nav
aria-label={t('detail.mobileTabs')}
className="border-b border-divider-subtle bg-components-panel-bg px-4 pc:hidden"
>
<div className="flex min-w-0 scrollbar-none gap-1 overflow-x-auto py-2">
{INSTANCE_DETAIL_TAB_KEYS.map(tab => (
<Link
key={tab}
href={`/deployments/${appInstanceId}/${tab}`}
className={`inline-flex h-8 shrink-0 items-center rounded-lg px-3 system-sm-medium ${
activeTab === tab
? 'bg-state-accent-hover text-text-accent'
: 'text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary'
}`}
>
{t(`tabs.${tab}.name`)}
</Link>
))}
</div>
</nav>
)
}
export function InstanceDetail({ appInstanceId, children }: {
appInstanceId: string
children: ReactNode
}) {
const { t } = useTranslation('deployments')
const selectedSegment = useSelectedLayoutSegment()
const selectedTab = selectedSegment ?? undefined
const activeTab: InstanceDetailTabKey = isInstanceDetailTabKey(selectedTab) ? selectedTab : 'overview'
const contentMaxWidthClassName = activeTab === 'settings' ? 'max-w-[872px]' : 'max-w-[1120px]'
useDocumentTitle(t('documentTitle.detail'))
return (
<>
<div className="relative flex h-full min-w-0 overflow-hidden rounded-t-2xl shadow-xs">
<DeploymentSidebar appInstanceId={appInstanceId} />
<div className="min-w-0 grow overflow-hidden bg-components-panel-bg">
<div className="h-full min-w-0 overflow-y-auto">
<div className={`mx-auto flex min-h-full w-full ${contentMaxWidthClassName} flex-col`}>
<div className="flex w-full flex-col gap-y-0.5 px-4 pt-3 pb-2 sm:px-6">
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
<div className="system-xl-semibold text-text-primary">{t(`tabs.${activeTab}.name`)}</div>
{activeTab === 'api-tokens' && (
<div className="shrink-0">
<DeveloperApiHeaderSwitch appInstanceId={appInstanceId} />
</div>
)}
</div>
<div className="system-sm-regular text-text-tertiary">{t(`tabs.${activeTab}.description`)}</div>
</div>
{(activeTab === 'api-tokens' || activeTab === 'instances' || activeTab === 'releases') && (
<div className="w-full shrink-0 pt-1 sm:w-auto sm:pt-1.5 [&_button]:w-full sm:[&_button]:w-auto">
{activeTab === 'api-tokens'
? <DeveloperApiHeaderActions appInstanceId={appInstanceId} />
: activeTab === 'instances'
? <NewDeploymentHeaderAction appInstanceId={appInstanceId} />
: <CreateReleaseControl appInstanceId={appInstanceId} size="medium" />}
</div>
)}
</div>
</div>
<MobileDetailTabs appInstanceId={appInstanceId} activeTab={activeTab} />
{children}
</div>
</div>
</div>
</div>
<DeployDrawer />
</>
)
}

View File

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

View File

@ -0,0 +1,295 @@
'use client'
import type { AccessChannels, EnvironmentDeployment } from '@dify/contracts/enterprise/types.gen'
import { cn } from '@langgenius/dify-ui/cn'
import { useQueries } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { environmentId } from '../../environment'
import { hasRuntimeInstanceDeployment } from '../../runtime-status'
import { SectionState } from '../common'
import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME, OVERVIEW_INTERACTIVE_CARD_CLASS_NAME, OVERVIEW_STATUS_BADGE_CLASS_NAME } from './card-styles'
type AccessStatusSectionProps = {
appInstanceId: string
accessChannels?: AccessChannels
}
type ApiTokenSummarySectionProps = {
appInstanceId: string
rows: EnvironmentDeployment[]
accessChannels?: AccessChannels
isEnvironmentLoading: boolean
isEnvironmentError: boolean
}
type AccessStatusItem = {
key: 'webapp' | 'cli'
href: string
icon: string
label: string
enabled: boolean
meta: string
}
const ACCESS_STATUS_SKELETON_KEYS = ['webapp', 'cli']
export function AccessStatusSection({ appInstanceId, accessChannels }: AccessStatusSectionProps) {
const { t } = useTranslation('deployments')
const items: AccessStatusItem[] = [
{
key: 'webapp',
href: `/deployments/${appInstanceId}/access`,
icon: 'i-ri-global-line',
label: t('card.access.webApp'),
enabled: Boolean(accessChannels?.webAppEnabled),
meta: t('overview.accessMeta.webApp'),
},
{
key: 'cli',
href: `/deployments/${appInstanceId}/access`,
icon: 'i-ri-terminal-box-line',
label: t('card.access.cli'),
enabled: Boolean(accessChannels?.webAppEnabled),
meta: t('overview.accessMeta.cli'),
},
]
return (
<section className="flex min-w-0 flex-col gap-3">
<h3 className="system-sm-semibold text-text-primary">
{t('overview.accessStatus')}
</h3>
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,220px),1fr))] gap-3">
{items.map(item => (
<Link
key={item.key}
href={item.href}
className={cn(
OVERVIEW_INTERACTIVE_CARD_CLASS_NAME,
'group flex min-h-18 min-w-0 items-start gap-3',
)}
>
<span
aria-hidden
className={OVERVIEW_ICON_CLASS_NAME}
>
<span className={cn('size-4', item.icon)} />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate system-sm-medium text-text-primary">
{item.label}
</span>
<span className="flex shrink-0 items-center gap-2">
<span
className={cn(
OVERVIEW_STATUS_BADGE_CLASS_NAME,
item.enabled
? 'text-util-colors-green-green-700'
: 'text-text-tertiary',
)}
>
<span
aria-hidden
className={cn(
'size-1.5 shrink-0 rounded-full',
item.enabled ? 'bg-util-colors-green-green-500' : 'bg-text-quaternary',
)}
/>
{item.enabled ? t('overview.enabled') : t('overview.disabled')}
</span>
<span
aria-hidden
className="i-ri-arrow-right-line size-4 text-text-quaternary opacity-60 transition group-hover:translate-x-0.5 group-hover:opacity-100 group-focus-visible:translate-x-0.5 group-focus-visible:opacity-100"
/>
</span>
</span>
<span className="truncate text-xs text-text-tertiary">
{item.meta}
</span>
</span>
</Link>
))}
</div>
</section>
)
}
export function ApiTokenSummarySection({
appInstanceId,
rows,
accessChannels,
isEnvironmentLoading,
isEnvironmentError,
}: ApiTokenSummarySectionProps) {
const { t } = useTranslation('deployments')
const runtimeRows = rows.filter(hasRuntimeInstanceDeployment)
const apiEnabled = Boolean(accessChannels?.developerApiEnabled)
const apiKeyQueries = useQueries({
queries: runtimeRows.map(row => consoleQuery.enterprise.accessService.listApiKeys.queryOptions({
input: {
params: {
appInstanceId,
environmentId: environmentId(row.environment),
},
},
enabled: apiEnabled,
})),
})
const apiKeyCount = apiKeyQueries.reduce((count, query) => count + (query.data?.data?.length ?? 0), 0)
const apiKeysLoading = apiKeyQueries.some(query => query.isLoading)
const apiKeysError = apiKeyQueries.some(query => query.isError)
const isLoading = isEnvironmentLoading || (apiEnabled && apiKeysLoading)
const isError = isEnvironmentError || (apiEnabled && apiKeysError)
return (
<section className="flex min-w-0 flex-col gap-3">
<h3 className="system-sm-semibold text-text-primary">
{t('overview.api')}
</h3>
{isLoading
? <ApiTokenSummaryCardSkeleton />
: isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: (
<Link
href={`/deployments/${appInstanceId}/api-tokens`}
className={cn(
OVERVIEW_INTERACTIVE_CARD_CLASS_NAME,
'group flex min-h-18 min-w-0 items-start gap-3',
)}
>
<span aria-hidden className={OVERVIEW_ICON_CLASS_NAME}>
<span className="i-ri-code-s-slash-line size-4" />
</span>
<span className="flex min-w-0 flex-1 flex-col gap-2">
<span className="flex min-w-0 items-center justify-between gap-3">
<span className="truncate system-sm-medium text-text-primary">
{t('card.access.api')}
</span>
<span className="flex shrink-0 items-center gap-2">
<StatusBadge enabled={apiEnabled} />
<span
aria-hidden
className="i-ri-arrow-right-line size-4 text-text-quaternary opacity-60 transition group-hover:translate-x-0.5 group-hover:opacity-100 group-focus-visible:translate-x-0.5 group-focus-visible:opacity-100"
/>
</span>
</span>
{apiEnabled
? (
<span className="flex min-w-0 flex-wrap gap-2">
<span className="inline-flex h-6 min-w-0 items-center rounded-md bg-background-section-burn px-2 system-xs-medium text-text-secondary">
{t('overview.apiKeysCount', { count: apiKeyCount })}
</span>
<span className="inline-flex h-6 min-w-0 items-center rounded-md bg-background-section-burn px-2 system-xs-medium text-text-secondary">
{t('overview.apiTokenSummary.environments', { count: runtimeRows.length })}
</span>
</span>
)
: (
<span className="truncate text-xs text-text-tertiary">
{t('overview.accessMeta.apiTokens')}
</span>
)}
</span>
</Link>
)}
</section>
)
}
function StatusBadge({ enabled }: {
enabled: boolean
}) {
const { t } = useTranslation('deployments')
return (
<span
className={cn(
OVERVIEW_STATUS_BADGE_CLASS_NAME,
enabled
? 'text-util-colors-green-green-700'
: 'text-text-tertiary',
)}
>
<span
aria-hidden
className={cn(
'size-1.5 shrink-0 rounded-full',
enabled ? 'bg-util-colors-green-green-500' : 'bg-text-quaternary',
)}
/>
{enabled ? t('overview.enabled') : t('overview.disabled')}
</span>
)
}
export function ApiTokenSummarySectionSkeleton() {
const { t } = useTranslation('deployments')
return (
<section className="flex min-w-0 flex-col gap-3">
<h3 className="system-sm-semibold text-text-primary">
{t('overview.api')}
</h3>
<ApiTokenSummaryCardSkeleton />
</section>
)
}
function ApiTokenSummaryCardSkeleton() {
return (
<div
data-slot="deployment-overview-api-token-card-skeleton"
className={cn(OVERVIEW_CARD_CLASS_NAME, 'flex min-h-18 min-w-0 items-start gap-3')}
>
<SkeletonRectangle className="my-0 size-8 shrink-0 animate-pulse rounded-lg" />
<span className="flex min-w-0 flex-1 flex-col gap-2">
<span className="flex min-w-0 items-center justify-between gap-3">
<SkeletonRectangle className="my-0 h-3.5 w-20 animate-pulse" />
<SkeletonRectangle className="my-0 h-6 w-14 shrink-0 animate-pulse rounded-md" />
</span>
<span className="flex gap-2">
<SkeletonRectangle className="my-0 h-6 w-24 animate-pulse rounded-md" />
<SkeletonRectangle className="my-0 h-6 w-32 animate-pulse rounded-md" />
</span>
</span>
</div>
)
}
export function AccessStatusSectionSkeleton() {
const { t } = useTranslation('deployments')
return (
<section className="flex min-w-0 flex-col gap-3">
<h3 className="system-sm-semibold text-text-primary">
{t('overview.accessStatus')}
</h3>
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,220px),1fr))] gap-3">
{ACCESS_STATUS_SKELETON_KEYS.map(key => (
<div
key={key}
data-slot="deployment-overview-access-card-skeleton"
className={cn(OVERVIEW_CARD_CLASS_NAME, 'flex min-h-18 min-w-0 items-start gap-3')}
>
<SkeletonRectangle className="my-0 size-8 shrink-0 animate-pulse rounded-lg" />
<span className="flex min-w-0 flex-1 flex-col gap-2">
<span className="flex min-w-0 items-center justify-between gap-3">
<SkeletonRectangle className="my-0 h-3.5 w-20 animate-pulse" />
<SkeletonRectangle className="my-0 h-6 w-14 shrink-0 animate-pulse rounded-md" />
</span>
<SkeletonRectangle className="my-0 h-3 w-4/5 animate-pulse" />
</span>
</div>
))}
</div>
</section>
)
}

View File

@ -0,0 +1,12 @@
import { cn } from '@langgenius/dify-ui/cn'
export const OVERVIEW_CARD_CLASS_NAME = 'rounded-xl border border-components-panel-border bg-components-panel-bg p-4'
export const OVERVIEW_INTERACTIVE_CARD_CLASS_NAME = cn(
OVERVIEW_CARD_CLASS_NAME,
'transition-colors hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg',
)
export const OVERVIEW_ICON_CLASS_NAME = 'flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-section-burn text-text-tertiary'
export const OVERVIEW_STATUS_BADGE_CLASS_NAME = 'inline-flex h-6 shrink-0 items-center gap-1.5 rounded-md bg-background-section-burn px-2 system-xs-medium'

View File

@ -0,0 +1,155 @@
'use client'
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
import Link from '@/next/link'
import { environmentId } from '../../environment'
import { hasRuntimeInstanceDeployment } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import { DetailEmptyState, SectionState } from '../common'
import { OVERVIEW_CARD_CLASS_NAME } from './card-styles'
import { EnvironmentTile } from './environment-tile'
const OVERVIEW_RUNTIME_INSTANCE_LIMIT = 4
type EnvironmentStripProps = {
appInstanceId: string
rows: EnvironmentDeployment[]
releaseRows: Release[]
isLoading: boolean
isError: boolean
}
export function EnvironmentStrip({ appInstanceId, rows, releaseRows, isLoading, isError }: EnvironmentStripProps) {
const { t } = useTranslation('deployments')
const runtimeRows = rows.filter(hasRuntimeInstanceDeployment)
const previewRows = runtimeRows.slice(0, OVERVIEW_RUNTIME_INSTANCE_LIMIT)
const hasRuntimeRows = runtimeRows.length > 0
const hasRelease = releaseRows.length > 0
return (
<section className="flex flex-col gap-3">
<div className="flex min-w-0 items-baseline justify-between gap-3">
<h3 className="system-sm-semibold text-text-primary">{t('overview.strip.title')}</h3>
{hasRuntimeRows && (
<Link
href={`/deployments/${appInstanceId}/instances`}
className="inline-flex shrink-0 items-center gap-1 system-xs-medium text-text-tertiary transition-colors hover:text-text-secondary"
>
{t('overview.previousReleases.viewAll')}
<span aria-hidden className="i-ri-arrow-right-line size-3.5" />
</Link>
)}
</div>
{isLoading
? <CardSkeletons />
: isError
? <SectionState>{t('common.loadFailed')}</SectionState>
: !hasRuntimeRows
? <EnvironmentEmptyState appInstanceId={appInstanceId} canDeploy={hasRelease} />
: (
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,360px),1fr))] gap-3">
{previewRows.map(row => (
<EnvironmentTile
key={environmentId(row.environment)}
appInstanceId={appInstanceId}
row={row}
releaseRows={releaseRows}
/>
))}
</div>
)}
</section>
)
}
function EnvironmentEmptyState({ appInstanceId, canDeploy }: {
appInstanceId: string
canDeploy: boolean
}) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
return (
<DetailEmptyState
variant="section"
icon="i-ri-server-line"
title={t('overview.strip.emptyTitle')}
description={canDeploy ? t('overview.strip.emptyDeployableDescription') : t('overview.strip.emptyDescription')}
className="min-h-44"
action={canDeploy
? (
<Button
type="button"
variant="primary"
size="medium"
className="gap-1.5"
onClick={() => openDeployDrawer({ appInstanceId })}
>
<span className="i-ri-rocket-line size-4 shrink-0" aria-hidden="true" />
{t('overview.strip.deployToNewEnvironment')}
</Button>
)
: undefined}
/>
)
}
const SKELETON_KEYS = ['a', 'b', 'c']
function CardSkeletons() {
return (
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,360px),1fr))] gap-3">
{SKELETON_KEYS.map(key => (
<EnvironmentTileSkeleton key={key} />
))}
</div>
)
}
function EnvironmentTileSkeleton() {
return (
<article
data-slot="deployment-overview-environment-tile-skeleton"
className={cn(OVERVIEW_CARD_CLASS_NAME, 'flex min-h-28 min-w-0 flex-col justify-between gap-4')}
>
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<SkeletonRectangle className="my-0 size-8 shrink-0 animate-pulse rounded-lg" />
<SkeletonRectangle className="my-0 h-3.5 w-28 animate-pulse" />
</div>
<SkeletonRow className="my-0 h-6 shrink-0 gap-1.5 rounded-md bg-background-section-burn px-2">
<span className="size-1.5 shrink-0 rounded-full bg-text-quaternary" />
<span className="h-2.5 w-8 rounded-xs bg-text-quaternary opacity-20" />
</SkeletonRow>
</div>
<div className="flex min-w-0 items-end justify-between gap-3">
<div className="min-w-0">
<SkeletonRectangle className="my-0 h-2.5 w-20 animate-pulse" />
<div className="mt-2 flex min-w-0 items-center gap-2">
<SkeletonRectangle className="my-0 h-3.5 w-24 animate-pulse" />
<SkeletonRectangle className="my-0 h-5 w-16 animate-pulse rounded" />
</div>
</div>
<SkeletonRectangle className="my-0 h-8 w-22 shrink-0 animate-pulse rounded-md" />
</div>
</article>
)
}
export function EnvironmentStripSkeleton() {
const { t } = useTranslation('deployments')
return (
<section className="flex flex-col gap-3">
<h3 className="system-sm-semibold text-text-primary">{t('overview.strip.title')}</h3>
<CardSkeletons />
</section>
)
}

View File

@ -0,0 +1,374 @@
'use client'
import type { EnvironmentDeployment, Release } from '@dify/contracts/enterprise/types.gen'
import type { DeploymentUiStatus } from '../../runtime-status'
import { cn } from '@langgenius/dify-ui/cn'
import { useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { useRouter } from '@/next/navigation'
import { TitleTooltip } from '../../components/title-tooltip'
import { environmentId, environmentName } from '../../environment'
import { releaseCommit, releaseLabel } from '../../release'
import { deploymentStatus } from '../../runtime-status'
import { openDeployDrawerAtom } from '../../store'
import { OVERVIEW_ICON_CLASS_NAME, OVERVIEW_INTERACTIVE_CARD_CLASS_NAME, OVERVIEW_STATUS_BADGE_CLASS_NAME } from './card-styles'
import { computeDrift, latestReleaseId } from './overview-drift'
type EnvironmentTileProps = {
appInstanceId: string
row: EnvironmentDeployment
releaseRows: Release[]
}
type TileKind = 'empty' | 'latest' | 'behind' | 'older' | 'deploying' | 'failed'
type TileConfig = {
kind: TileKind
dotClass: string
statusClass: string
actionClass: string
showRelease: boolean
intent: 'drawer' | 'navigate' | 'disabled'
releaseId?: string
}
export function EnvironmentTile({ appInstanceId, row, releaseRows }: EnvironmentTileProps) {
const { t } = useTranslation('deployments')
const openDeployDrawer = useSetAtom(openDeployDrawerAtom)
const router = useRouter()
const envId = environmentId(row.environment)
const drift = computeDrift(row, releaseRows)
const status = deploymentStatus(row)
const latestId = latestReleaseId(releaseRows)
const hasAnyRelease = releaseRows.length > 0
const currentReleaseId = row.currentRelease?.id
const config = resolveConfig({ drift, status, hasAnyRelease, latestId, currentReleaseId })
const isDisabled = config.intent === 'disabled'
const showStatusSignal = config.kind !== 'deploying'
const release = row.currentRelease
const showRelease = config.showRelease && Boolean(release?.id)
const commit = releaseCommit(release)
const tooltip = isDisabled
? t('overview.chip.needsReleaseFirst')
: config.intent === 'navigate'
? t('overview.chip.openInDeployTab')
: undefined
function handleAction() {
if (config.intent === 'disabled')
return
if (config.intent === 'navigate') {
router.push(`/deployments/${appInstanceId}/instances`)
return
}
openDeployDrawer({ appInstanceId, environmentId: envId, releaseId: config.releaseId })
}
const actionButton = (
<button
type="button"
disabled={isDisabled}
onClick={handleAction}
className={cn(
'inline-flex h-8 max-w-full min-w-0 shrink-0 items-center justify-center rounded-md px-2.5 system-xs-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-components-button-primary-bg',
config.actionClass,
isDisabled && 'cursor-not-allowed opacity-60',
)}
>
<span className="whitespace-nowrap">{renderActionLabel(config.kind, Boolean(currentReleaseId), t)}</span>
</button>
)
return (
<article
data-slot="deployment-overview-environment-tile"
className={cn(OVERVIEW_INTERACTIVE_CARD_CLASS_NAME, 'flex min-h-28 min-w-0 flex-col justify-between gap-4')}
>
<div className="flex min-w-0 items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<span aria-hidden className={OVERVIEW_ICON_CLASS_NAME}>
<span className="i-ri-server-line size-4" />
</span>
<h4 className="truncate system-sm-medium text-text-primary">
{environmentName(row.environment)}
</h4>
</div>
<div className="flex shrink-0 items-center gap-2">
<RuntimeStatusSignal status={status} t={t} />
{showStatusSignal && <StatusSignal config={config} drift={drift} t={t} />}
</div>
</div>
<div className="flex min-w-0 items-end justify-between gap-3">
<div className="min-w-0">
<div className="system-2xs-medium-uppercase text-text-tertiary">
{t('deployTab.col.currentRelease')}
</div>
<div className="mt-1 flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate system-sm-semibold text-text-primary">
{showRelease ? releaseLabel(release) : '—'}
</span>
{showRelease && commit !== '—' && (
<span className="shrink-0 rounded bg-background-section-burn px-1.5 py-0.5 font-mono system-xs-regular text-text-tertiary">
{commit}
</span>
)}
</div>
</div>
{tooltip
? (
<TitleTooltip content={tooltip}>
<span className="inline-flex max-w-full min-w-0 shrink-0">
{actionButton}
</span>
</TitleTooltip>
)
: actionButton}
</div>
</article>
)
}
function RuntimeStatusSignal({ status, t }: {
status: DeploymentUiStatus
t: ReturnType<typeof useTranslation<'deployments'>>['t']
}) {
const config = runtimeStatusConfig(status)
const label = runtimeStatusLabel(status, t)
return (
<TitleTooltip content={label}>
<span className={cn(OVERVIEW_STATUS_BADGE_CLASS_NAME, config.statusClass)}>
<span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', config.dotClass)} />
<span>{label}</span>
</span>
</TitleTooltip>
)
}
function StatusSignal({ className, config, drift, t }: {
className?: string
config: TileConfig
drift: ReturnType<typeof computeDrift>
t: ReturnType<typeof useTranslation<'deployments'>>['t']
}) {
const title = renderDriftTitle(config.kind, drift, t)
return (
<TitleTooltip content={title}>
<span className={cn(OVERVIEW_STATUS_BADGE_CLASS_NAME, config.statusClass, className)}>
<span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', config.dotClass)} />
<span>{renderStatus(config.kind, drift, t)}</span>
</span>
</TitleTooltip>
)
}
function runtimeStatusConfig(status: DeploymentUiStatus): {
dotClass: string
statusClass: string
} {
switch (status) {
case 'ready':
return {
dotClass: 'bg-util-colors-green-green-500',
statusClass: 'text-util-colors-green-green-700',
}
case 'deploying':
return {
dotClass: 'bg-util-colors-blue-blue-500 animate-pulse',
statusClass: 'text-util-colors-blue-blue-700',
}
case 'deploy_failed':
return {
dotClass: 'bg-util-colors-red-red-500',
statusClass: 'text-util-colors-red-red-700',
}
case 'drifted':
return {
dotClass: 'bg-util-colors-warning-warning-500',
statusClass: 'text-util-colors-warning-warning-700',
}
case 'invalid':
return {
dotClass: 'bg-util-colors-red-red-500',
statusClass: 'text-util-colors-red-red-700',
}
case 'not_deployed':
return {
dotClass: 'bg-text-quaternary',
statusClass: 'text-text-tertiary',
}
case 'unknown':
return {
dotClass: 'bg-text-quaternary',
statusClass: 'text-text-tertiary',
}
}
}
function runtimeStatusLabel(
status: DeploymentUiStatus,
t: ReturnType<typeof useTranslation<'deployments'>>['t'],
): string {
switch (status) {
case 'ready':
return t('status.ready')
case 'deploying':
return t('status.deploying')
case 'deploy_failed':
return t('status.deployFailed')
case 'drifted':
return t('status.drifted')
case 'invalid':
return t('status.invalid')
case 'not_deployed':
return t('status.notDeployed')
case 'unknown':
return t('status.unknown')
}
}
function resolveConfig({ drift, status, hasAnyRelease, latestId, currentReleaseId }: {
drift: ReturnType<typeof computeDrift>
status: ReturnType<typeof deploymentStatus>
hasAnyRelease: boolean
latestId: string | undefined
currentReleaseId: string | undefined
}): TileConfig {
if (status === 'deploying') {
return {
kind: 'deploying',
dotClass: 'bg-util-colors-blue-blue-500 animate-pulse',
statusClass: 'text-util-colors-blue-blue-700',
actionClass: 'text-text-secondary hover:bg-state-base-hover hover:text-text-primary',
showRelease: true,
intent: 'navigate',
}
}
if (status === 'deploy_failed') {
return {
kind: 'failed',
dotClass: 'bg-util-colors-red-red-500',
statusClass: 'text-util-colors-red-red-700',
actionClass: 'text-primary-600 hover:bg-state-accent-hover',
showRelease: true,
intent: 'drawer',
releaseId: currentReleaseId ?? latestId,
}
}
if (drift.kind === 'undeployed') {
return {
kind: 'empty',
dotClass: 'bg-text-quaternary',
statusClass: 'text-text-tertiary',
actionClass: hasAnyRelease
? 'text-primary-600 hover:bg-state-accent-hover'
: 'text-text-tertiary',
showRelease: false,
intent: hasAnyRelease ? 'drawer' : 'disabled',
releaseId: latestId,
}
}
if (drift.kind === 'up-to-date') {
return {
kind: 'latest',
dotClass: 'bg-util-colors-green-green-500',
statusClass: 'text-util-colors-green-green-700',
actionClass: 'text-text-secondary hover:bg-state-base-hover hover:text-text-primary',
showRelease: true,
intent: 'drawer',
releaseId: currentReleaseId,
}
}
if (drift.kind === 'behind') {
return {
kind: 'behind',
dotClass: 'bg-util-colors-warning-warning-500',
statusClass: 'text-util-colors-warning-warning-700',
actionClass: 'text-primary-600 hover:bg-state-accent-hover',
showRelease: true,
intent: 'drawer',
releaseId: latestId,
}
}
return {
kind: 'older',
dotClass: 'bg-text-tertiary',
statusClass: 'text-text-tertiary',
actionClass: 'text-primary-600 hover:bg-state-accent-hover',
showRelease: true,
intent: 'drawer',
releaseId: latestId,
}
}
function renderActionLabel(
kind: TileKind,
hasCurrentRelease: boolean,
t: ReturnType<typeof useTranslation<'deployments'>>['t'],
): string {
switch (kind) {
case 'empty':
case 'older':
case 'behind':
return t('overview.cardAction.deployLatest')
case 'latest':
return t('overview.cardAction.redeploy')
case 'deploying':
return t('overview.cardAction.viewProgress')
case 'failed':
return hasCurrentRelease
? t('overview.cardAction.redeploy')
: t('overview.cardAction.deployLatest')
}
}
function renderStatus(
kind: TileKind,
drift: ReturnType<typeof computeDrift>,
t: ReturnType<typeof useTranslation<'deployments'>>['t'],
): string {
switch (kind) {
case 'empty':
return t('overview.chip.empty')
case 'latest':
return t('overview.chip.latest')
case 'behind':
return t('overview.chip.behind', { count: drift.kind === 'behind' ? drift.steps : 0 })
case 'older':
return t('overview.chip.olderRelease')
case 'deploying':
return t('overview.chip.deploying')
case 'failed':
return t('overview.chip.failed')
}
}
function renderDriftTitle(
kind: TileKind,
drift: ReturnType<typeof computeDrift>,
t: ReturnType<typeof useTranslation<'deployments'>>['t'],
): string {
switch (kind) {
case 'latest':
return t('overview.chip.latestTooltip')
case 'behind':
return t('overview.chip.behindTooltip', { count: drift.kind === 'behind' ? drift.steps : 0 })
case 'older':
return t('overview.chip.olderReleaseTooltip')
case 'empty':
return t('overview.chip.emptyTooltip')
case 'deploying':
return t('overview.chip.deployingTooltip')
case 'failed':
return t('overview.chip.failedTooltip')
}
}

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,168 @@
'use client'
import type { Release } from '@dify/contracts/enterprise/types.gen'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { skipToken, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import Link from '@/next/link'
import { consoleQuery } from '@/service/client'
import { TitleTooltip } from '../../components/title-tooltip'
import { formatDate, releaseCommit, releaseLabel } from '../../release'
import { DetailEmptyState } from '../common'
import { CreateReleaseControl } from '../versions-tab/create-release-control'
import { OVERVIEW_CARD_CLASS_NAME, OVERVIEW_ICON_CLASS_NAME } from './card-styles'
type ReleaseHeroProps = {
appInstanceId: string
latestRelease?: Release
releaseCount: number
}
type ReleaseMetaItemProps = {
label?: string
showSeparator?: boolean
children: ReactNode
}
export function ReleaseHero({ appInstanceId, latestRelease, releaseCount }: ReleaseHeroProps) {
const { t } = useTranslation('deployments')
const { formatTimeFromNow } = useFormatTimeFromNow()
const author = latestRelease?.createdBy?.name ?? ''
const ago = latestRelease?.createdAt ? formatTimeFromNow(new Date(latestRelease.createdAt).getTime()) : ''
const createdAtTitle = latestRelease?.createdAt ? formatDate(latestRelease.createdAt) : undefined
const commit = releaseCommit(latestRelease)
if (!latestRelease?.id) {
return (
<DetailEmptyState
variant="section"
icon="i-ri-stack-line"
title={t('overview.hero.empty')}
description={t('overview.hero.emptyDescription')}
action={<CreateReleaseControl appInstanceId={appInstanceId} size="medium" />}
className="min-h-44"
/>
)
}
return (
<div className={cn(OVERVIEW_CARD_CLASS_NAME, 'flex min-w-0 flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6')}>
<div className="flex min-w-0 items-center gap-3">
<span aria-hidden className={OVERVIEW_ICON_CLASS_NAME}>
<span className="i-ri-stack-fill size-4" />
</span>
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex min-w-0 items-center gap-2">
<h4 className="truncate system-sm-semibold text-text-primary">
{releaseLabel(latestRelease)}
</h4>
{commit !== '—' && (
<TitleTooltip content={t('versions.commitTooltip', { commit })}>
<span className="shrink-0 rounded bg-background-section-burn px-1.5 py-0.5 font-mono system-xs-regular text-text-tertiary">
{commit}
</span>
</TitleTooltip>
)}
</div>
<p className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 system-xs-regular text-text-tertiary">
<ReleaseMetaItem label={t('versions.col.sourceApp')} showSeparator={false}>
<LatestReleaseSource release={latestRelease} />
</ReleaseMetaItem>
{author && (
<ReleaseMetaItem>
{t('overview.hero.byName', { name: author })}
</ReleaseMetaItem>
)}
{ago && (
<ReleaseMetaItem>
<TitleTooltip content={createdAtTitle}>
<span>
{ago}
</span>
</TitleTooltip>
</ReleaseMetaItem>
)}
<ReleaseMetaItem>
{t('overview.latestRelease.releaseCount', { count: releaseCount })}
</ReleaseMetaItem>
</p>
</div>
</div>
</div>
)
}
function ReleaseMetaItem({ label, showSeparator = true, children }: ReleaseMetaItemProps) {
return (
<span className="inline-flex min-w-0 items-center gap-1.5">
{showSeparator && (
<span aria-hidden className="text-text-quaternary">·</span>
)}
{label && (
<span className="shrink-0 text-text-quaternary">{label}</span>
)}
<span className="min-w-0 truncate">{children}</span>
</span>
)
}
function LatestReleaseSource({ release }: {
release: Release
}) {
const { t } = useTranslation('deployments')
const sourceAppId = release.sourceAppId
const sourceAppQuery = useQuery(consoleQuery.apps.byAppId.get.queryOptions({
input: sourceAppId
? { params: { app_id: sourceAppId } }
: skipToken,
}))
if (!sourceAppId) {
return (
<span>
{release.source === 'RELEASE_SOURCE_UPLOAD' ? t('versions.manualDslOption') : '—'}
</span>
)
}
const sourceAppName = sourceAppQuery.data?.name
const label = sourceAppName || sourceAppId
const title = sourceAppName ? `${sourceAppName} (${sourceAppId})` : sourceAppId
return (
<TitleTooltip content={title}>
<Link
href={`/app/${encodeURIComponent(sourceAppId)}/workflow`}
target="_blank"
rel="noreferrer"
className="inline-flex max-w-full min-w-0 items-center gap-1 text-text-secondary transition-colors hover:text-text-accent"
>
<span className="min-w-0 truncate">{label}</span>
<span className="i-ri-arrow-right-up-line size-3.5 shrink-0" aria-hidden="true" />
</Link>
</TitleTooltip>
)
}
export function ReleaseHeroSkeleton() {
return (
<div
data-slot="deployment-overview-release-hero-skeleton"
className={cn(OVERVIEW_CARD_CLASS_NAME, 'flex min-w-0 items-start gap-3')}
>
<SkeletonRectangle className="my-0 size-8 shrink-0 animate-pulse rounded-lg" />
<div className="flex min-w-0 flex-col gap-2">
<SkeletonRectangle className="my-0 h-4 w-40 animate-pulse" />
<div className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-1">
<SkeletonRectangle className="my-0 h-3 w-32 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-14 animate-pulse" />
<SkeletonRectangle className="my-0 h-3 w-28 animate-pulse" />
</div>
</div>
</div>
)
}

View File

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

View File

@ -0,0 +1,129 @@
'use client'
import type { App } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import {
Drawer,
DrawerBackdrop,
DrawerCloseButton,
DrawerContent,
DrawerDescription,
DrawerPopup,
DrawerPortal,
DrawerTitle,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { useTranslation } from 'react-i18next'
import TemplateWorkflowEn from '@/app/components/develop/template/template_workflow.en.mdx'
import TemplateWorkflowJa from '@/app/components/develop/template/template_workflow.ja.mdx'
import TemplateWorkflowZh from '@/app/components/develop/template/template_workflow.zh.mdx'
import { useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { getDocLanguage } from '@/i18n-config/language'
import { AppModeEnum, Theme } from '@/types/app'
type PromptVariable = { key: string, name: string }
type WorkflowDocTemplateProps = {
appDetail: App
variables: PromptVariable[]
inputs: Record<string, string>
}
const EMPTY_VARIABLES: PromptVariable[] = []
const EMPTY_INPUTS: Record<string, string> = {}
function WorkflowDocTemplate({ docLanguage, appDetail, variables, inputs }: WorkflowDocTemplateProps & {
docLanguage: string
}) {
if (docLanguage === 'zh') {
return (
<TemplateWorkflowZh
appDetail={appDetail}
variables={variables}
inputs={inputs}
/>
)
}
if (docLanguage === 'ja') {
return (
<TemplateWorkflowJa
appDetail={appDetail}
variables={variables}
inputs={inputs}
/>
)
}
return (
<TemplateWorkflowEn
appDetail={appDetail}
variables={variables}
inputs={inputs}
/>
)
}
export function DeveloperApiDocsDrawer({
open,
appInstanceId,
apiBaseUrl,
onOpenChange,
}: {
open: boolean
appInstanceId: string
apiBaseUrl: string
onOpenChange: (open: boolean) => void
}) {
const { t } = useTranslation('deployments')
const locale = useLocale()
const { theme } = useTheme()
const docLanguage = getDocLanguage(locale)
const appDetail = {
id: appInstanceId,
mode: AppModeEnum.WORKFLOW,
api_base_url: apiBaseUrl,
} as App
return (
<Drawer
open={open}
modal
swipeDirection="right"
onOpenChange={onOpenChange}
>
<DrawerPortal>
<DrawerBackdrop />
<DrawerViewport>
<DrawerPopup className="data-[swipe-direction=right]:top-16 data-[swipe-direction=right]:right-2 data-[swipe-direction=right]:bottom-2 data-[swipe-direction=right]:h-auto data-[swipe-direction=right]:w-[840px] data-[swipe-direction=right]:max-w-[calc(100vw-1rem)] data-[swipe-direction=right]:rounded-xl data-[swipe-direction=right]:border-[0.5px]">
<DrawerCloseButton
aria-label={t('access.api.docsClose')}
className="absolute top-4 right-5 size-6 rounded-md"
/>
<DrawerContent className="flex min-h-0 flex-1 flex-col bg-components-panel-bg p-0 pb-0">
<div className="shrink-0 border-b border-divider-subtle px-6 py-5 pr-14">
<DrawerTitle className="title-xl-semi-bold text-text-primary">
{t('access.api.docsTitle')}
</DrawerTitle>
<DrawerDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('access.api.docsDescription')}
</DrawerDescription>
</div>
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto px-6 py-5">
<article className={cn('prose max-w-none', theme === Theme.dark && 'prose-invert')}>
<WorkflowDocTemplate
docLanguage={docLanguage}
appDetail={appDetail}
variables={EMPTY_VARIABLES}
inputs={EMPTY_INPUTS}
/>
</article>
</div>
</DrawerContent>
</DrawerPopup>
</DrawerViewport>
</DrawerPortal>
</Drawer>
)
}

View File

@ -0,0 +1,516 @@
'use client'
import type {
ApiKey,
Environment,
} from '@dify/contracts/enterprise/types.gen'
import type { ButtonProps } from '@langgenius/dify-ui/button'
import type { FormEvent } from 'react'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogDescription,
DialogTitle,
} from '@langgenius/dify-ui/dialog'
import { Input } from '@langgenius/dify-ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectLabel,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { consoleQuery } from '@/service/client'
import { environmentName } from '../../../environment'
import {
DetailTable,
DetailTableBody,
DetailTableCard,
DetailTableCardList,
DetailTableCell,
DetailTableHead,
DetailTableHeader,
DetailTableRow,
} from '../../table'
import {
API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES,
} from '../../table-styles'
const API_TOKEN_NAME_ADJECTIVES = [
'ancient',
'autumn',
'bright',
'calm',
'crystal',
'gentle',
'golden',
'hidden',
'holy',
'quiet',
'rapid',
'silver',
]
const API_TOKEN_NAME_NOUNS = [
'brook',
'cloud',
'field',
'forest',
'harbor',
'lake',
'meadow',
'moon',
'river',
'stone',
'valley',
'wave',
]
function randomListItem(items: string[]) {
return items[Math.floor(Math.random() * items.length)]!
}
function generateApiTokenName() {
const suffix = Math.floor(1000 + Math.random() * 9000)
return `${randomListItem(API_TOKEN_NAME_ADJECTIVES)}-${randomListItem(API_TOKEN_NAME_NOUNS)}-${suffix}`
}
function ApiKeyName({ apiKey }: {
apiKey: ApiKey
}) {
return (
<span className="block truncate text-text-primary">
{apiKey.name || apiKey.id || '—'}
</span>
)
}
function EnvironmentBadge({ environment }: {
environment?: Environment
}) {
return (
<span className="inline-flex h-5 max-w-36 items-center rounded-md bg-background-section-burn px-1.5 text-xs text-text-tertiary">
<span className="truncate">{environmentName(environment)}</span>
</span>
)
}
function ApiKeyValue({ value }: {
value: string
}) {
return (
<div className="flex h-8 min-w-0 items-center rounded-lg border border-components-input-border-active bg-components-input-bg-normal px-2">
<div className="min-w-0 flex-1 truncate font-mono system-sm-medium text-text-secondary">
{value}
</div>
</div>
)
}
function RevokeApiKeyButton({ apiKey }: {
apiKey: ApiKey
}) {
const { t } = useTranslation('deployments')
const [showRevokeConfirm, setShowRevokeConfirm] = useState(false)
const revokeApiKey = useMutation(consoleQuery.enterprise.accessService.deleteApiKey.mutationOptions())
const isRevoking = revokeApiKey.isPending
const apiKeyName = apiKey.name || apiKey.id || t('access.api.table.key')
function handleRevoke() {
if (!apiKey.id || isRevoking)
return
revokeApiKey.mutate(
{
params: {
apiKeyId: apiKey.id,
},
},
{
onSuccess: () => {
setShowRevokeConfirm(false)
toast.success(t('access.api.revokeSuccess'))
},
onError: () => {
toast.error(t('access.api.revokeFailed'))
},
},
)
}
function handleRevokeConfirmOpenChange(open: boolean) {
if (isRevoking)
return
setShowRevokeConfirm(open)
}
return (
<>
<button
type="button"
onClick={() => setShowRevokeConfirm(true)}
aria-label={t('access.revoke')}
aria-busy={isRevoking}
disabled={!apiKey.id || isRevoking}
className={cn(
'inline-flex size-8 shrink-0 items-center justify-center rounded-md text-text-tertiary outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid',
isRevoking
? 'cursor-not-allowed opacity-60'
: 'hover:bg-state-destructive-hover hover:text-text-destructive',
)}
>
<span className={cn(isRevoking ? 'i-ri-loader-2-line animate-spin' : 'i-ri-delete-bin-line', 'size-3.5')} />
</button>
<AlertDialog open={showRevokeConfirm} onOpenChange={handleRevokeConfirmOpenChange}>
<AlertDialogContent>
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
{t('access.api.revokeConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t('access.api.revokeConfirmDescription', { name: apiKeyName })}
</AlertDialogDescription>
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={isRevoking}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton loading={isRevoking} disabled={isRevoking} onClick={handleRevoke}>
{t('access.revoke')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
function ApiKeyMobileRow({ apiKey, environment }: {
apiKey: ApiKey
environment?: Environment
}) {
const { t } = useTranslation('deployments')
const displayValue = apiKey.maskedToken || apiKey.id || '—'
return (
<DetailTableCard>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<ApiKeyName apiKey={apiKey} />
<div className="mt-1">
<EnvironmentBadge environment={environment} />
</div>
</div>
<RevokeApiKeyButton apiKey={apiKey} />
</div>
<div className="flex min-w-0 flex-col gap-1">
<span className="system-2xs-medium-uppercase text-text-tertiary">
{t('access.api.table.key')}
</span>
<ApiKeyValue value={displayValue} />
</div>
</div>
</DetailTableCard>
)
}
function ApiKeyDesktopRow({ apiKey, environment }: {
apiKey: ApiKey
environment?: Environment
}) {
const displayValue = apiKey.maskedToken || apiKey.id || '—'
return (
<DetailTableRow>
<DetailTableCell>
<ApiKeyName apiKey={apiKey} />
</DetailTableCell>
<DetailTableCell>
<EnvironmentBadge environment={environment} />
</DetailTableCell>
<DetailTableCell>
<ApiKeyValue value={displayValue} />
</DetailTableCell>
<DetailTableCell>
<div className="flex justify-end">
<RevokeApiKeyButton apiKey={apiKey} />
</div>
</DetailTableCell>
</DetailTableRow>
)
}
function ApiKeyTableHeader() {
const { t } = useTranslation('deployments')
return (
<DetailTableHeader>
<DetailTableRow>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.name}>{t('access.api.table.name')}</DetailTableHead>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.environment}>{t('access.api.table.environment')}</DetailTableHead>
<DetailTableHead className={API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.key}>{t('access.api.table.key')}</DetailTableHead>
<DetailTableHead className={`${API_KEY_DETAIL_TABLE_COLUMN_CLASS_NAMES.action} text-right`}>{t('access.api.table.action')}</DetailTableHead>
</DetailTableRow>
</DetailTableHeader>
)
}
function ApiKeyTable({ apiKeys, environments }: {
apiKeys: ApiKey[]
environments: Environment[]
}) {
const environmentById = new Map(environments.map(environment => [environment.id, environment]))
return (
<>
<DetailTableCardList className={cn('pc:hidden')}>
{apiKeys.map((apiKey, index) => (
<ApiKeyMobileRow
key={apiKey.id ?? apiKey.maskedToken ?? apiKey.name ?? index}
apiKey={apiKey}
environment={apiKey.environmentId ? environmentById.get(apiKey.environmentId) : undefined}
/>
))}
</DetailTableCardList>
<div className="hidden pc:block">
<DetailTable>
<ApiKeyTableHeader />
<DetailTableBody>
{apiKeys.map((apiKey, index) => (
<ApiKeyDesktopRow
key={apiKey.id ?? apiKey.maskedToken ?? apiKey.name ?? index}
apiKey={apiKey}
environment={apiKey.environmentId ? environmentById.get(apiKey.environmentId) : undefined}
/>
))}
</DetailTableBody>
</DetailTable>
</div>
</>
)
}
export function ApiKeyList({ apiKeys, environments }: {
apiKeys: ApiKey[]
environments: Environment[]
}) {
return (
<ApiKeyTable apiKeys={apiKeys} environments={environments} />
)
}
export function ApiKeyGenerateMenu({
appInstanceId,
environments,
onCreatedToken,
triggerVariant = 'secondary',
triggerClassName,
}: {
appInstanceId: string
environments: Environment[]
onCreatedToken: (token: string) => void
triggerVariant?: ButtonProps['variant']
triggerClassName?: string
}) {
const { t } = useTranslation('deployments')
const nameInputId = useId()
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string>()
const [draftName, setDraftName] = useState('')
const [nameError, setNameError] = useState(false)
const generateApiKey = useMutation(consoleQuery.enterprise.accessService.createApiKey.mutationOptions())
const selectableEnvironments = environments.filter(env => env.id)
const selectedEnvironment = selectedEnvironmentId
? environments.find(env => env.id === selectedEnvironmentId)
: undefined
const disabled = selectableEnvironments.length === 0
const isCreating = generateApiKey.isPending
function resetCreateDialog() {
setCreateDialogOpen(false)
setSelectedEnvironmentId(undefined)
setDraftName('')
setNameError(false)
}
function handleOpenCreateDialog() {
const firstEnvironmentId = selectableEnvironments[0]?.id
if (!firstEnvironmentId)
return
setSelectedEnvironmentId(firstEnvironmentId)
setDraftName(generateApiTokenName())
setNameError(false)
setCreateDialogOpen(true)
}
function handleEnvironmentChange(environmentId: string) {
setSelectedEnvironmentId(environmentId)
setNameError(false)
}
function handleDialogOpenChange(nextOpen: boolean) {
if (nextOpen || isCreating)
return
resetCreateDialog()
}
function handleGenerateApiKey(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const name = draftName.trim()
if (!selectedEnvironmentId || !name) {
setNameError(true)
return
}
generateApiKey.mutate(
{
params: {
appInstanceId,
environmentId: selectedEnvironmentId,
},
body: {
appInstanceId,
environmentId: selectedEnvironmentId,
name,
},
},
{
onSuccess: (response) => {
if (response.token)
onCreatedToken(response.token)
resetCreateDialog()
},
onError: () => {
toast.error(t('access.api.createFailed'))
},
},
)
}
return (
<>
<Button
type="button"
variant={triggerVariant}
disabled={disabled}
onClick={handleOpenCreateDialog}
className={cn('gap-1.5', triggerClassName)}
>
<span className="i-ri-add-line size-4" aria-hidden="true" />
{t('access.api.newKey')}
</Button>
<Dialog open={createDialogOpen} onOpenChange={handleDialogOpenChange}>
<DialogContent className="w-120 max-w-[calc(100vw-32px)] overflow-hidden p-0">
<DialogCloseButton disabled={isCreating} />
<form onSubmit={handleGenerateApiKey}>
<div className="border-b border-divider-subtle px-6 py-5 pr-14">
<DialogTitle className="title-xl-semi-bold text-text-primary">
{t('access.api.createKeyTitle')}
</DialogTitle>
<DialogDescription className="mt-1 system-sm-regular text-text-tertiary">
{t('access.api.description')}
</DialogDescription>
</div>
<div className="flex flex-col gap-4 px-6 py-5">
<div>
<label
htmlFor={nameInputId}
className="mb-1 block system-sm-medium text-text-secondary"
>
{t('access.api.nameLabel')}
</label>
<Input
id={nameInputId}
value={draftName}
disabled={isCreating}
autoFocus
aria-invalid={nameError || undefined}
aria-describedby={nameError ? `${nameInputId}-error` : undefined}
placeholder={t('access.api.namePlaceholder')}
onChange={(event) => {
setDraftName(event.target.value)
if (nameError && event.target.value.trim())
setNameError(false)
}}
/>
{nameError && (
<div id={`${nameInputId}-error`} className="mt-1 system-xs-regular text-text-destructive">
{t('access.api.nameRequired')}
</div>
)}
</div>
<div>
<Select
value={selectedEnvironmentId ?? null}
disabled={isCreating}
onValueChange={value => value && handleEnvironmentChange(value)}
>
<SelectLabel className="mb-1 block system-sm-medium text-text-secondary">
{t('access.api.table.environment')}
</SelectLabel>
<SelectTrigger>
{environmentName(selectedEnvironment)}
</SelectTrigger>
<SelectContent>
{selectableEnvironments.map(env => (
<SelectItem key={env.id} value={env.id!}>
<SelectItemText>{environmentName(env)}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-divider-subtle bg-background-default-subtle px-6 py-4">
<Button
type="button"
variant="secondary"
disabled={isCreating}
onClick={() => handleDialogOpenChange(false)}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
type="submit"
variant="primary"
loading={isCreating}
disabled={!selectedEnvironmentId || !draftName.trim()}
>
{t('access.api.createKey')}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</>
)
}

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