mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-18 05:35:26 +02:00
Compare commits
502 Commits
v0.26.7
...
feat/add-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad3b2b9b21 | ||
|
|
6e67864204 | ||
|
|
2102840bb9 | ||
|
|
30f061e774 | ||
|
|
c00aa6acbf | ||
|
|
8e9ab98a7a | ||
|
|
ce82e2322b | ||
|
|
ec7df05990 | ||
|
|
75a4e8e8ef | ||
|
|
b4319c7ea2 | ||
|
|
e9787b753d | ||
|
|
b419294b09 | ||
|
|
922b4d58f1 | ||
|
|
dc8ff78ee5 | ||
|
|
735c9952d8 | ||
|
|
21821295e3 | ||
|
|
a8467e80e8 | ||
|
|
95e14b4199 | ||
|
|
076262e479 | ||
|
|
c4f4db3ebc | ||
|
|
4882bd25ad | ||
|
|
7a8f2e53d5 | ||
|
|
50182a8048 | ||
|
|
35d35028f6 | ||
|
|
a5a4a1a818 | ||
|
|
c106d13ab5 | ||
|
|
808001d8de | ||
|
|
ce24eadbb4 | ||
|
|
b87f8cc5d8 | ||
|
|
f650200771 | ||
|
|
f961dc6e7a | ||
|
|
4be25da185 | ||
|
|
675c1d7a7d | ||
|
|
28cc361c47 | ||
|
|
cedec5239f | ||
|
|
2f4cbbd3ac | ||
|
|
38b20450dc | ||
|
|
49f43ab3fb | ||
|
|
2eae756cec | ||
|
|
70c261d021 | ||
|
|
9ae2ebff46 | ||
|
|
8ce880d108 | ||
|
|
34304526b1 | ||
|
|
a16c4c1294 | ||
|
|
d1c4ac20e3 | ||
|
|
0195119a86 | ||
|
|
48a577e792 | ||
|
|
bf7a75dd9f | ||
|
|
d316aa4401 | ||
|
|
f1b2cc35b3 | ||
|
|
d2fabc998d | ||
|
|
7185047eb7 | ||
|
|
7121fbe50a | ||
|
|
36cf3a69fc | ||
|
|
c34a01a173 | ||
|
|
9ac147a140 | ||
|
|
20f79ac655 | ||
|
|
6f21f1cc1f | ||
|
|
af76548482 | ||
|
|
47ab89c5be | ||
|
|
13638d0f04 | ||
|
|
edceebec7e | ||
|
|
7599565e73 | ||
|
|
08c9113405 | ||
|
|
1014d4674c | ||
|
|
39b40c58bb | ||
|
|
1861e10b2a | ||
|
|
964e3c4150 | ||
|
|
e05f31d8c6 | ||
|
|
cc3b902d1e | ||
|
|
6c1f2372ed | ||
|
|
7da69862e1 | ||
|
|
612e73bb80 | ||
|
|
a360a259f5 | ||
|
|
149293f4d3 | ||
|
|
a8a5e1c6f1 | ||
|
|
4ede21eda9 | ||
|
|
e275e9162e | ||
|
|
60a6dc5fab | ||
|
|
705c5bc1c9 | ||
|
|
8d56544c1d | ||
|
|
ca527ab6ff | ||
|
|
439fa17292 | ||
|
|
096c04486c | ||
|
|
c9e1079076 | ||
|
|
e29a86a85f | ||
|
|
f9dedd979e | ||
|
|
1ba0eb0c2e | ||
|
|
d7dc10993e | ||
|
|
2a5d3975e8 | ||
|
|
9f3356ddb4 | ||
|
|
f5674f5bf8 | ||
|
|
17a617e585 | ||
|
|
f50eea9e05 | ||
|
|
81ee8f653a | ||
|
|
9507745cc0 | ||
|
|
d33e164876 | ||
|
|
7e6e815375 | ||
|
|
8ab139e222 | ||
|
|
55c04b1323 | ||
|
|
29fc8bfa97 | ||
|
|
e90d8c0ac4 | ||
|
|
98d09f187e | ||
|
|
f96a6a509b | ||
|
|
df27b8b748 | ||
|
|
5c915bc1b9 | ||
|
|
a188ed3914 | ||
|
|
0094b67c13 | ||
|
|
0b9dc3a1a2 | ||
|
|
f8628269b9 | ||
|
|
345023f090 | ||
|
|
3352f7a1c9 | ||
|
|
9e1406e6c8 | ||
|
|
798bfc2b92 | ||
|
|
9d07903e71 | ||
|
|
4baf77c740 | ||
|
|
0adfa51174 | ||
|
|
7a25a9f5b4 | ||
|
|
3d81b98f48 | ||
|
|
9b4a37eebf | ||
|
|
a467920410 | ||
|
|
e62bb7593a | ||
|
|
4af0bdc27b | ||
|
|
b9da9e6367 | ||
|
|
1ea1f6b603 | ||
|
|
677fedbbc0 | ||
|
|
234862e5b6 | ||
|
|
b8bfee7f87 | ||
|
|
2a3ae89f5e | ||
|
|
c22e006e30 | ||
|
|
66ef8a0a5f | ||
|
|
d29de8fcba | ||
|
|
42eeade121 | ||
|
|
8e893e6c64 | ||
|
|
e0c5273eb3 | ||
|
|
a1ebb804fe | ||
|
|
b2218efce6 | ||
|
|
b027d21589 | ||
|
|
2d0874d499 | ||
|
|
e92ba584c0 | ||
|
|
53b612534d | ||
|
|
7b6ece0b65 | ||
|
|
3b7dcaca7a | ||
|
|
0d0383f84a | ||
|
|
331b12c7d8 | ||
|
|
7c534d62b6 | ||
|
|
df648eccf6 | ||
|
|
fca10c135a | ||
|
|
03969b8f45 | ||
|
|
d8e08558cc | ||
|
|
293ad3862a | ||
|
|
6cc646c974 | ||
|
|
d00ec952a9 | ||
|
|
74461c860e | ||
|
|
9d09e51cf7 | ||
|
|
e3aadf1908 | ||
|
|
69d3286aaf | ||
|
|
0b1c1e8b8c | ||
|
|
5435e1dac4 | ||
|
|
999e5fabd3 | ||
|
|
6a2098d522 | ||
|
|
5d3e05536e | ||
|
|
3e461f642e | ||
|
|
7a62f47e43 | ||
|
|
daf700429d | ||
|
|
781bf5e116 | ||
|
|
66190434a7 | ||
|
|
2b42ef7829 | ||
|
|
97374f736e | ||
|
|
28fc58d898 | ||
|
|
32a14be564 | ||
|
|
e874b2c459 | ||
|
|
660bc3cd00 | ||
|
|
c98548fa51 | ||
|
|
0d4d60953e | ||
|
|
f7079f51de | ||
|
|
15a1a5d0aa | ||
|
|
d99e0bf4dd | ||
|
|
20acc8bce5 | ||
|
|
5ef431b9e9 | ||
|
|
3439b758df | ||
|
|
97f9e8ad25 | ||
|
|
5faa319b69 | ||
|
|
42e8320866 | ||
|
|
b0c6b1338d | ||
|
|
309a411718 | ||
|
|
ef65e0934e | ||
|
|
84dd6458aa | ||
|
|
37e5c52cbe | ||
|
|
49914c5d92 | ||
|
|
9df4398c8f | ||
|
|
a83a742bf3 | ||
|
|
713aa5fd58 | ||
|
|
b210c48eaa | ||
|
|
d70c865dc7 | ||
|
|
df2221a4bd | ||
|
|
831584550b | ||
|
|
c688311580 | ||
|
|
b9c62cc515 | ||
|
|
605931861b | ||
|
|
4e8d37bff7 | ||
|
|
be35709cea | ||
|
|
6c3230648a | ||
|
|
756d276f47 | ||
|
|
1d5ab71bd5 | ||
|
|
9880c71dba | ||
|
|
33c3a4ed4e | ||
|
|
3689a82ec5 | ||
|
|
b818d661fd | ||
|
|
1302d705e7 | ||
|
|
685a4c0b69 | ||
|
|
b58f2b236f | ||
|
|
6350a8ddd3 | ||
|
|
46e1bed5e9 | ||
|
|
8aba7b08cf | ||
|
|
9eeac50642 | ||
|
|
2db4c448d4 | ||
|
|
c89f2e302b | ||
|
|
1c25ab4303 | ||
|
|
46ac272f3f | ||
|
|
9818e3c3ba | ||
|
|
20320639ce | ||
|
|
88f387dd83 | ||
|
|
752f90c330 | ||
|
|
0fc043d0ad | ||
|
|
13b94ed3be | ||
|
|
7747929cdf | ||
|
|
06fd561bb1 | ||
|
|
62fb117ecf | ||
|
|
8713d3e1aa | ||
|
|
76038f6db6 | ||
|
|
a511f4db40 | ||
|
|
95a944c4e5 | ||
|
|
6d6cf18108 | ||
|
|
32ed0c7285 | ||
|
|
923466b4fa | ||
|
|
d5163322fb | ||
|
|
714849883e | ||
|
|
407ce3f425 | ||
|
|
49a189fcbf | ||
|
|
7e8d3b7162 | ||
|
|
24010af265 | ||
|
|
33192ce4d1 | ||
|
|
02a695c6af | ||
|
|
e5f51fd7be | ||
|
|
620e4c4835 | ||
|
|
125c23e2c0 | ||
|
|
51e005701d | ||
|
|
c04dd63db8 | ||
|
|
4fd06b00a0 | ||
|
|
1f9335ad5d | ||
|
|
2cd3c27ae9 | ||
|
|
53ae08cec4 | ||
|
|
8aab8dd2a5 | ||
|
|
516315db79 | ||
|
|
e8bec0ae03 | ||
|
|
389a69484e | ||
|
|
f656e624f7 | ||
|
|
f5635f6645 | ||
|
|
81a04d0777 | ||
|
|
b63c22a7df | ||
|
|
05ad6d812c | ||
|
|
aa579977e3 | ||
|
|
2788323e01 | ||
|
|
3b74425d35 | ||
|
|
edbc98aea7 | ||
|
|
60f5ab304a | ||
|
|
8291c6d835 | ||
|
|
7928d117b3 | ||
|
|
eec4e21751 | ||
|
|
343a84d6bc | ||
|
|
89416fef47 | ||
|
|
74d72f1494 | ||
|
|
a24dbe365a | ||
|
|
3b753ecfbf | ||
|
|
7184b7d4b2 | ||
|
|
5c36ca3986 | ||
|
|
3a3f3ab7d4 | ||
|
|
1779a8a950 | ||
|
|
a51a4b3e87 | ||
|
|
034d55d7cb | ||
|
|
eeb7f00d05 | ||
|
|
1326d14a00 | ||
|
|
59f843f8a0 | ||
|
|
fe807ae2a6 | ||
|
|
a4eb0bfea1 | ||
|
|
ce9ba60902 | ||
|
|
744ebab15a | ||
|
|
17da1d5b3c | ||
|
|
f7613d9375 | ||
|
|
a43ad106f2 | ||
|
|
0e26c5023b | ||
|
|
f4a4530481 | ||
|
|
00dc3fae11 | ||
|
|
1da23f8888 | ||
|
|
bee4e4639c | ||
|
|
bd5b27ad51 | ||
|
|
b391abfd5c | ||
|
|
21a6657e00 | ||
|
|
d348ad5556 | ||
|
|
5d8b7b9b99 | ||
|
|
f5fa39b97e | ||
|
|
0a3a90c4e9 | ||
|
|
f440df343a | ||
|
|
4ec282b2f3 | ||
|
|
c039e638a6 | ||
|
|
65ffc63da4 | ||
|
|
5ba120567f | ||
|
|
8a335789b3 | ||
|
|
d420311507 | ||
|
|
a01ace12e8 | ||
|
|
24c022f837 | ||
|
|
ecd81eb7fa | ||
|
|
f2e4a96154 | ||
|
|
08ba24c252 | ||
|
|
ff55270b52 | ||
|
|
f78819d81a | ||
|
|
79e02483ad | ||
|
|
f25ed46dbc | ||
|
|
7ad09c0d0d | ||
|
|
a212d42495 | ||
|
|
51095e3ac5 | ||
|
|
a897fe6115 | ||
|
|
a0d9f06a35 | ||
|
|
f1d0fb95f4 | ||
|
|
4bc494e009 | ||
|
|
110bdce38c | ||
|
|
b9e700243e | ||
|
|
bc39addfa8 | ||
|
|
2532934cdf | ||
|
|
f4b5a589b6 | ||
|
|
105562bdcb | ||
|
|
16359e21a2 | ||
|
|
9451958193 | ||
|
|
1a810790cd | ||
|
|
e426c89cb2 | ||
|
|
325a0aeedf | ||
|
|
a8293b7b5c | ||
|
|
54bd25da39 | ||
|
|
e4c440b265 | ||
|
|
e39f0fee77 | ||
|
|
5b48e45536 | ||
|
|
3a7f76e33e | ||
|
|
a54c84a138 | ||
|
|
8ba26f01e3 | ||
|
|
9bc88eba72 | ||
|
|
b741618251 | ||
|
|
c0328ab63f | ||
|
|
425bcf8958 | ||
|
|
26d4058457 | ||
|
|
ccaac28f08 | ||
|
|
a1a348e22d | ||
|
|
ad29bb6ec2 | ||
|
|
aa2e0e81c6 | ||
|
|
3750cdab44 | ||
|
|
6cf448ba80 | ||
|
|
3e64647d0d | ||
|
|
dde00fc380 | ||
|
|
f4ad3dae35 | ||
|
|
85a8ec8ba9 | ||
|
|
c68525aa59 | ||
|
|
91d6365275 | ||
|
|
35a7445a09 | ||
|
|
4607b15a85 | ||
|
|
4eae1a5c14 | ||
|
|
5381b13813 | ||
|
|
66ae8e1fff | ||
|
|
1aa05eaa8d | ||
|
|
4f13c25ca2 | ||
|
|
83599cee37 | ||
|
|
1c4e95d8e3 | ||
|
|
97f1105cf4 | ||
|
|
c65026353a | ||
|
|
8872dc178c | ||
|
|
fa0c2ec5e3 | ||
|
|
f9eda8e95d | ||
|
|
5b2b0db686 | ||
|
|
6576731842 | ||
|
|
ec7bf9fd2f | ||
|
|
bc053744fc | ||
|
|
af87614cb0 | ||
|
|
b2484da2af | ||
|
|
5d3c05d291 | ||
|
|
40accfbf60 | ||
|
|
3f0558d077 | ||
|
|
7ae3d7d906 | ||
|
|
6877ebe027 | ||
|
|
51e881d831 | ||
|
|
80bbb752b6 | ||
|
|
3c9945ec35 | ||
|
|
4d8c358b33 | ||
|
|
4b82659a48 | ||
|
|
d77c562c84 | ||
|
|
37ea75be3e | ||
|
|
82158ed34d | ||
|
|
9ab98c9a63 | ||
|
|
ca7d3f8cb3 | ||
|
|
66448ff6c2 | ||
|
|
31d47efb1e | ||
|
|
33802f554a | ||
|
|
38265fd921 | ||
|
|
ca2efc5c68 | ||
|
|
dfcb422294 | ||
|
|
47470e2343 | ||
|
|
bac9dd5c31 | ||
|
|
65dab84e7f | ||
|
|
3a0da19ea8 | ||
|
|
5e460e6b4f | ||
|
|
9299f04f74 | ||
|
|
2746133252 | ||
|
|
bde192c1e7 | ||
|
|
99646f887b | ||
|
|
542ccc4479 | ||
|
|
9910c0e602 | ||
|
|
4f0d707905 | ||
|
|
a86fe46b7b | ||
|
|
139c06b63d | ||
|
|
999dc7d360 | ||
|
|
dc74d3057a | ||
|
|
ac833ef265 | ||
|
|
00e31f399e | ||
|
|
8001e5d24a | ||
|
|
cfb9534e06 | ||
|
|
c2894260dc | ||
|
|
582f493f3f | ||
|
|
8335f40238 | ||
|
|
f6f0921560 | ||
|
|
c739c67616 | ||
|
|
dc8148ae51 | ||
|
|
3307f62183 | ||
|
|
2b36381f8d | ||
|
|
de2579401c | ||
|
|
5c3b7acd54 | ||
|
|
44f8590fe8 | ||
|
|
945406adc5 | ||
|
|
1e7522d173 | ||
|
|
2b72b4888c | ||
|
|
8b1cc949c0 | ||
|
|
a70018f70a | ||
|
|
71b87895eb | ||
|
|
354407cd12 | ||
|
|
766fd00be5 | ||
|
|
c31e970172 | ||
|
|
c56def9c97 | ||
|
|
aa558b3a8c | ||
|
|
11082f25d7 | ||
|
|
00ce8cad1b | ||
|
|
dc756e2bbb | ||
|
|
fb06cf8e55 | ||
|
|
69ba901535 | ||
|
|
4667cb525f | ||
|
|
54229b0dcd | ||
|
|
6b42c9d142 | ||
|
|
7665b38b79 | ||
|
|
d5de5b8ad7 | ||
|
|
d22d96105c | ||
|
|
bc5c65b2d2 | ||
|
|
acb3c1d238 | ||
|
|
68587c3c8b | ||
|
|
cae7a92599 | ||
|
|
f3d9960b7f | ||
|
|
66b4bf2c4e | ||
|
|
c4515a2ca4 | ||
|
|
1f33b0fd24 | ||
|
|
3c2f675eb9 | ||
|
|
61f6bbfe1c | ||
|
|
21b1652259 | ||
|
|
30c3e44422 | ||
|
|
f72bc28d70 | ||
|
|
82c06a487a | ||
|
|
12a87f9f8b | ||
|
|
9a8de9ae16 | ||
|
|
6064b8ca48 | ||
|
|
7f27601f7f | ||
|
|
2e7f4dc1a2 | ||
|
|
2b52332e43 | ||
|
|
346216fc71 | ||
|
|
c9ffb99808 | ||
|
|
cbfa690a80 | ||
|
|
262960a59a | ||
|
|
709ffddd4f | ||
|
|
0c299a3807 | ||
|
|
25fa362cdb | ||
|
|
f680818b56 | ||
|
|
25d37b76a1 | ||
|
|
ad382f1fe5 | ||
|
|
d5b0e3193a | ||
|
|
43228fc51b | ||
|
|
ba8a334fbe | ||
|
|
6c90075a64 | ||
|
|
6271f3bb1a | ||
|
|
57eee45dbb | ||
|
|
2be92d20bb | ||
|
|
2be938a695 | ||
|
|
95dd9ddeb6 | ||
|
|
33fb21bfe1 | ||
|
|
5ca4d8366e | ||
|
|
cc49db63da | ||
|
|
0a401843f8 | ||
|
|
f5f21ef195 | ||
|
|
464d58daaa | ||
|
|
50b0a5d61c |
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -8,7 +8,7 @@ Before submitting this PR, please make sure that:
|
||||
|
||||
- [ ] You created a dedicated branch based on the `canary` branch.
|
||||
- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
|
||||
- [ ] You have tested this PR in your local instance.
|
||||
- [ ] You have tested this PR in your local instance. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you.
|
||||
|
||||
## Issues related (if applicable)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute.
|
||||
|
||||
Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues.
|
||||
Before you start, please first discuss the feature/bug you want to add with the owners and community via github issues.
|
||||
|
||||
We have a few guidelines to follow when contributing to this project:
|
||||
|
||||
@@ -11,6 +11,7 @@ We have a few guidelines to follow when contributing to this project:
|
||||
- [Development](#development)
|
||||
- [Build](#build)
|
||||
- [Pull Request](#pull-request)
|
||||
- [Important Considerations](#important-considerations-for-pull-requests)
|
||||
|
||||
## Commit Convention
|
||||
|
||||
@@ -162,8 +163,9 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.
|
||||
- If your pull request fixes an open issue, please reference the issue in the pull request description.
|
||||
- Once your pull request is merged, you will be automatically added as a contributor to the project.
|
||||
|
||||
**Important Considerations for Pull Requests:**
|
||||
### Important Considerations for Pull Requests
|
||||
|
||||
- **Testing is Mandatory:** All Pull Requests **must be tested** before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested will be closed.** This policy ensures clean contributions and reduces the time maintainers spend reviewing untested or broken code.
|
||||
- **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects.
|
||||
- **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task.
|
||||
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).
|
||||
|
||||
@@ -65,4 +65,8 @@ RUN curl -sSL https://railpack.com/install.sh | bash
|
||||
COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
||||
|
||||
EXPOSE 3000
|
||||
CMD [ "pnpm", "start" ]
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
|
||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||
|
||||
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
||||
|
||||
47
README.md
47
README.md
@@ -12,24 +12,8 @@
|
||||
<br />
|
||||
|
||||
|
||||
|
||||
<div align="center" markdown="1">
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://tuple.app/dokploy">
|
||||
<img src=".github/sponsors/tuple.png" alt="Tuple's sponsorship image" width="400"/>
|
||||
</a>
|
||||
|
||||
### [Tuple, the premier screen sharing app for developers](https://tuple.app/dokploy)
|
||||
[Available for MacOS & Windows](https://tuple.app/dokploy)<br>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
|
||||
|
||||
|
||||
## ✨ Features
|
||||
|
||||
Dokploy includes multiple features to make your life easier.
|
||||
@@ -60,40 +44,9 @@ curl -sSL https://dokploy.com/install.sh | sh
|
||||
|
||||
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||
|
||||
## ♥️ Sponsors
|
||||
|
||||
🙏 We're deeply grateful to all our sponsors who make Dokploy possible! Your support helps cover the costs of hosting, testing, and developing new features.
|
||||
|
||||
[Dokploy Open Collective](https://opencollective.com/dokploy)
|
||||
|
||||
[Github Sponsors](https://github.com/sponsors/Siumauricio)
|
||||
|
||||
## Sponsors
|
||||
|
||||
| Sponsor | Logo | Supporter Level |
|
||||
|---------|:----:|----------------|
|
||||
| [Hostinger](https://www.hostinger.com/vps-hosting?ref=dokploy) | <img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="200"/> | 🎖 Hero Sponsor |
|
||||
| [LX Aer](https://www.lxaer.com/?ref=dokploy) | <img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/> | 🎖 Hero Sponsor |
|
||||
| [LinkDR](https://linkdr.com/?ref=dokploy) | <img src="https://dokploy.com/linkdr-logo.svg" alt="LinkDR" width="100"/> | 🎖 Hero Sponsor |
|
||||
| [LambdaTest](https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor) | <img src="https://www.lambdatest.com/blue-logo.png" alt="LambdaTest" width="200"/> | 🎖 Hero Sponsor |
|
||||
| [Awesome Tools](https://awesome.tools/) | <img src=".github/sponsors/awesome.png" alt="Awesome Tools" width="100"/> | 🎖 Hero Sponsor |
|
||||
| [Supafort](https://supafort.com/?ref=dokploy) | <img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="200"/> | 🥇 Premium Supporter |
|
||||
| [Agentdock](https://agentdock.ai/?ref=dokploy) | <img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/> | 🥇 Premium Supporter |
|
||||
| [AmericanCloud](https://americancloud.com/?ref=dokploy) | <img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="200"/> | 🥈 Elite Contributor |
|
||||
| [Tolgee](https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy) | <img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/> | 🥈 Elite Contributor |
|
||||
| [Cloudblast](https://cloudblast.io/?ref=dokploy) | <img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" alt="Cloudblast.io" width="150"/> | 🥉 Supporting Member |
|
||||
| [Synexa](https://synexa.ai/?ref=dokploy) | <img src=".github/sponsors/synexa.png" alt="Synexa" width="100"/> | 🥉 Supporting Member |
|
||||
|
||||
### Community Backers 🤝
|
||||
|
||||
#### Organizations:
|
||||
|
||||
[Sponsors on Open Collective](https://opencollective.com/dokploy)
|
||||
|
||||
#### Individuals:
|
||||
|
||||
[](https://opencollective.com/dokploy)
|
||||
|
||||
### Contributors 🤝
|
||||
|
||||
<a href="https://github.com/dokploy/dokploy/graphs/contributors">
|
||||
|
||||
@@ -1,2 +1,11 @@
|
||||
LEMON_SQUEEZY_API_KEY=""
|
||||
LEMON_SQUEEZY_STORE_ID=""
|
||||
LEMON_SQUEEZY_STORE_ID=""
|
||||
|
||||
# Inngest (for GET /jobs - list deployment queue). Self-hosted example:
|
||||
# INNGEST_BASE_URL="http://localhost:8288"
|
||||
# Production: INNGEST_BASE_URL="https://dev-inngest.dokploy.com"
|
||||
# INNGEST_SIGNING_KEY="your-signing-key"
|
||||
# Optional: only events after this RFC3339 timestamp. If unset, no date filter is applied.
|
||||
# INNGEST_EVENTS_RECEIVED_AFTER="2024-01-01T00:00:00Z"
|
||||
# Max events to fetch when listing jobs (paginates with cursor). Default 100, max 10000.
|
||||
# INNGEST_JOBS_MAX_EVENTS=100
|
||||
@@ -14,7 +14,7 @@
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/zod-validator": "0.3.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"hono": "^4.7.10",
|
||||
"hono": "^4.11.7",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
"react": "18.2.0",
|
||||
@@ -23,7 +23,7 @@
|
||||
"zod": "^3.25.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.17.51",
|
||||
"@types/node": "^20.16.0",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"tsx": "^4.16.2",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type DeployJob,
|
||||
deployJobSchema,
|
||||
} from "./schema.js";
|
||||
import { fetchDeploymentJobs } from "./service.js";
|
||||
import { deploy } from "./utils.js";
|
||||
|
||||
const app = new Hono();
|
||||
@@ -118,7 +119,6 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
|
||||
200,
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
logger.error("Failed to send deployment event", error);
|
||||
return c.json(
|
||||
{
|
||||
@@ -176,6 +176,29 @@ app.get("/health", async (c) => {
|
||||
return c.json({ status: "ok" });
|
||||
});
|
||||
|
||||
// List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI
|
||||
app.get("/jobs", async (c) => {
|
||||
const serverId = c.req.query("serverId");
|
||||
if (!serverId) {
|
||||
return c.json({ message: "serverId is required" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await fetchDeploymentJobs(serverId);
|
||||
return c.json(rows);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("INNGEST_BASE_URL")) {
|
||||
return c.json(
|
||||
{ message: "INNGEST_BASE_URL is required to list deployment jobs" },
|
||||
503,
|
||||
);
|
||||
}
|
||||
logger.error("Failed to fetch jobs from Inngest", { serverId, error });
|
||||
return c.json([], 200);
|
||||
}
|
||||
});
|
||||
|
||||
// Serve Inngest functions endpoint
|
||||
app.on(
|
||||
["GET", "POST", "PUT"],
|
||||
|
||||
239
apps/api/src/service.ts
Normal file
239
apps/api/src/service.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
const baseUrl = process.env.INNGEST_BASE_URL ?? "";
|
||||
const signingKey = process.env.INNGEST_SIGNING_KEY ?? "";
|
||||
|
||||
const DEFAULT_MAX_EVENTS = 500;
|
||||
const MAX_EVENTS = DEFAULT_MAX_EVENTS;
|
||||
|
||||
/** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */
|
||||
type InngestEventRow = {
|
||||
internal_id?: string;
|
||||
accountID?: string;
|
||||
environmentID?: string;
|
||||
source?: string;
|
||||
sourceID?: string | null;
|
||||
/** RFC3339 timestamp – API uses receivedAt, dev server may use received_at */
|
||||
receivedAt?: string;
|
||||
received_at?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
data: Record<string, unknown>;
|
||||
user?: unknown;
|
||||
ts: number;
|
||||
v?: string | null;
|
||||
metadata?: {
|
||||
fetchedAt: string;
|
||||
cachedUntil: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
/** Run shape from GET /v1/events/{eventId}/runs – the actual job execution */
|
||||
type InngestRun = {
|
||||
run_id: string;
|
||||
event_id: string;
|
||||
status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"?
|
||||
run_started_at?: string;
|
||||
ended_at?: string | null;
|
||||
output?: unknown;
|
||||
// dev server / API may use different casing
|
||||
run_started_at_ms?: number;
|
||||
};
|
||||
|
||||
function getEventReceivedAt(ev: InngestEventRow): string | undefined {
|
||||
return ev.receivedAt ?? ev.received_at;
|
||||
}
|
||||
|
||||
/** Map Inngest run status to BullMQ-style state for the UI */
|
||||
function runStatusToState(
|
||||
status: string,
|
||||
): "pending" | "active" | "completed" | "failed" | "cancelled" {
|
||||
const s = status.toLowerCase();
|
||||
if (s === "running") return "active";
|
||||
if (s === "completed") return "completed";
|
||||
if (s === "failed") return "failed";
|
||||
if (s === "cancelled") return "cancelled";
|
||||
if (s === "queued") return "pending";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export const fetchInngestEvents = async () => {
|
||||
const maxEvents = MAX_EVENTS;
|
||||
const all: InngestEventRow[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const params = new URLSearchParams({ limit: "100" });
|
||||
if (cursor) {
|
||||
params.set("cursor", cursor);
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}/v1/events?${params}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${signingKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
logger.warn("Inngest API error", {
|
||||
status: res.status,
|
||||
body: await res.text(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data?: InngestEventRow[];
|
||||
cursor?: string;
|
||||
nextCursor?: string;
|
||||
};
|
||||
const data = Array.isArray(body.data) ? body.data : [];
|
||||
all.push(...data);
|
||||
|
||||
// Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs)
|
||||
const nextCursor =
|
||||
body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id;
|
||||
const hasMore = data.length === 100 && nextCursor && all.length < maxEvents;
|
||||
cursor = hasMore ? nextCursor : undefined;
|
||||
} while (cursor);
|
||||
|
||||
return all.slice(0, maxEvents);
|
||||
};
|
||||
|
||||
/** Fetch runs for a single event (GET /v1/events/{eventId}/runs) – runs are the actual jobs */
|
||||
export const fetchInngestRunsForEvent = async (
|
||||
eventId: string,
|
||||
): Promise<InngestRun[]> => {
|
||||
const res = await fetch(
|
||||
`${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${signingKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
logger.warn("Inngest runs API error", {
|
||||
eventId,
|
||||
status: res.status,
|
||||
body: await res.text(),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
const body = (await res.json()) as { data?: InngestRun[] };
|
||||
return Array.isArray(body.data) ? body.data : [];
|
||||
};
|
||||
|
||||
/** One row for the queue UI (BullMQ-compatible shape) */
|
||||
export type DeploymentJobRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
data: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
processedOn?: number;
|
||||
finishedOn?: number;
|
||||
failedReason?: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
/** Build queue rows from events + their runs (one row per run, or pending if no run yet) */
|
||||
function buildDeploymentRowsFromRuns(
|
||||
events: InngestEventRow[],
|
||||
runsByEventId: Map<string, InngestRun[]>,
|
||||
serverId: string,
|
||||
): DeploymentJobRow[] {
|
||||
const requested = events.filter(
|
||||
(e) =>
|
||||
e.name === "deployment/requested" &&
|
||||
(e.data as Record<string, unknown>)?.serverId === serverId,
|
||||
);
|
||||
const rows: DeploymentJobRow[] = [];
|
||||
|
||||
for (const ev of requested) {
|
||||
const data = (ev.data ?? {}) as Record<string, unknown>;
|
||||
const runs = runsByEventId.get(ev.id) ?? [];
|
||||
|
||||
if (runs.length === 0) {
|
||||
// Queued: event received but no run yet
|
||||
rows.push({
|
||||
id: ev.id,
|
||||
name: ev.name,
|
||||
data,
|
||||
timestamp: ev.ts,
|
||||
processedOn: ev.ts,
|
||||
finishedOn: undefined,
|
||||
failedReason: undefined,
|
||||
state: "pending",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const run of runs) {
|
||||
const state = runStatusToState(run.status);
|
||||
const runStartedMs =
|
||||
run.run_started_at_ms ??
|
||||
(run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts);
|
||||
const endedMs = run.ended_at
|
||||
? new Date(run.ended_at).getTime()
|
||||
: undefined;
|
||||
const failedReason =
|
||||
state === "failed" &&
|
||||
run.output &&
|
||||
typeof run.output === "object" &&
|
||||
"error" in run.output
|
||||
? String((run.output as { error?: unknown }).error)
|
||||
: undefined;
|
||||
|
||||
rows.push({
|
||||
id: run.run_id,
|
||||
name: ev.name,
|
||||
data,
|
||||
timestamp: runStartedMs,
|
||||
processedOn: runStartedMs,
|
||||
finishedOn:
|
||||
state === "completed" || state === "failed" || state === "cancelled"
|
||||
? endedMs
|
||||
: undefined,
|
||||
failedReason,
|
||||
state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
|
||||
}
|
||||
|
||||
/** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */
|
||||
export const fetchDeploymentJobs = async (
|
||||
serverId: string,
|
||||
): Promise<DeploymentJobRow[]> => {
|
||||
if (!signingKey) {
|
||||
logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list");
|
||||
return [];
|
||||
}
|
||||
if (!baseUrl) {
|
||||
throw new Error("INNGEST_BASE_URL is required to list deployment jobs");
|
||||
}
|
||||
|
||||
const events = await fetchInngestEvents();
|
||||
|
||||
const requestedForServer = events.filter(
|
||||
(e) =>
|
||||
e.name === "deployment/requested" &&
|
||||
(e.data as Record<string, unknown>)?.serverId === serverId,
|
||||
);
|
||||
// Limit to avoid too many run fetches
|
||||
const toFetch = requestedForServer.slice(0, 50);
|
||||
const runsByEventId = new Map<string, InngestRun[]>();
|
||||
|
||||
await Promise.all(
|
||||
toFetch.map(async (ev) => {
|
||||
const runs = await fetchInngestRunsForEvent(ev.id);
|
||||
runsByEventId.set(ev.id, runs);
|
||||
}),
|
||||
);
|
||||
|
||||
return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId);
|
||||
};
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
updateCompose,
|
||||
updatePreviewDeployment,
|
||||
} from "@dokploy/server";
|
||||
import type { DeployJob } from "./schema";
|
||||
import type { DeployJob } from "./schema.js";
|
||||
|
||||
export const deploy = async (job: DeployJob) => {
|
||||
try {
|
||||
|
||||
@@ -4,21 +4,30 @@ import { describe, expect, it } from "vitest";
|
||||
describe("addDokployNetworkToService", () => {
|
||||
it("should add network to an empty array", () => {
|
||||
const result = addDokployNetworkToService([]);
|
||||
expect(result).toEqual(["dokploy-network"]);
|
||||
expect(result).toEqual(["dokploy-network", "default"]);
|
||||
});
|
||||
|
||||
it("should not add duplicate network to an array", () => {
|
||||
const result = addDokployNetworkToService(["dokploy-network"]);
|
||||
expect(result).toEqual(["dokploy-network"]);
|
||||
expect(result).toEqual(["dokploy-network", "default"]);
|
||||
});
|
||||
|
||||
it("should add network to an existing array with other networks", () => {
|
||||
const result = addDokployNetworkToService(["other-network"]);
|
||||
expect(result).toEqual(["other-network", "dokploy-network"]);
|
||||
expect(result).toEqual(["other-network", "dokploy-network", "default"]);
|
||||
});
|
||||
|
||||
it("should add network to an object if networks is an object", () => {
|
||||
const result = addDokployNetworkToService({ "other-network": {} });
|
||||
expect(result).toEqual({ "other-network": {}, "dokploy-network": {} });
|
||||
expect(result).toEqual({
|
||||
"other-network": {},
|
||||
"dokploy-network": {},
|
||||
default: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not duplicate default network when already present", () => {
|
||||
const result = addDokployNetworkToService(["default", "dokploy-network"]);
|
||||
expect(result).toEqual(["default", "dokploy-network"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,6 +147,7 @@ const baseApp: ApplicationNested = {
|
||||
dockerContextPath: null,
|
||||
rollbackActive: false,
|
||||
stopGracePeriodSwarm: null,
|
||||
ulimitsSwarm: null,
|
||||
};
|
||||
|
||||
describe("unzipDrop using real zip files", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ type MockCreateServiceOptions = {
|
||||
TaskTemplate?: {
|
||||
ContainerSpec?: {
|
||||
StopGracePeriod?: number;
|
||||
Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>;
|
||||
};
|
||||
};
|
||||
[key: string]: unknown;
|
||||
@@ -13,11 +14,11 @@ type MockCreateServiceOptions = {
|
||||
|
||||
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
|
||||
vi.hoisted(() => {
|
||||
const inspect = vi.fn<[], Promise<never>>();
|
||||
const inspect = vi.fn<() => Promise<never>>();
|
||||
const getService = vi.fn(() => ({ inspect }));
|
||||
const createService = vi.fn<[MockCreateServiceOptions], Promise<void>>(
|
||||
async () => undefined,
|
||||
);
|
||||
const createService = vi.fn<
|
||||
(opts: MockCreateServiceOptions) => Promise<void>
|
||||
>(async () => undefined);
|
||||
const getRemoteDocker = vi.fn(async () => ({
|
||||
getService,
|
||||
createService,
|
||||
@@ -57,6 +58,7 @@ const createApplication = (
|
||||
},
|
||||
replicas: 1,
|
||||
stopGracePeriodSwarm: 0n,
|
||||
ulimitsSwarm: null,
|
||||
serverId: "server-id",
|
||||
...overrides,
|
||||
}) as unknown as ApplicationNested;
|
||||
@@ -80,7 +82,9 @@ describe("mechanizeDockerContainer", () => {
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
const call = createServiceMock.mock.calls[0] as
|
||||
| [MockCreateServiceOptions]
|
||||
| undefined;
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
@@ -97,7 +101,9 @@ describe("mechanizeDockerContainer", () => {
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
const call = createServiceMock.mock.calls[0] as
|
||||
| [MockCreateServiceOptions]
|
||||
| undefined;
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
@@ -106,4 +112,50 @@ describe("mechanizeDockerContainer", () => {
|
||||
"StopGracePeriod",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes ulimits to ContainerSpec when ulimitsSwarm is defined", async () => {
|
||||
const ulimits = [
|
||||
{ Name: "nofile", Soft: 10000, Hard: 20000 },
|
||||
{ Name: "nproc", Soft: 4096, Hard: 8192 },
|
||||
];
|
||||
const application = createApplication({ ulimitsSwarm: ulimits });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec?.Ulimits).toEqual(ulimits);
|
||||
});
|
||||
|
||||
it("omits Ulimits when ulimitsSwarm is null", async () => {
|
||||
const application = createApplication({ ulimitsSwarm: null });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
|
||||
});
|
||||
|
||||
it("omits Ulimits when ulimitsSwarm is an empty array", async () => {
|
||||
const application = createApplication({ ulimitsSwarm: [] });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
|
||||
});
|
||||
});
|
||||
|
||||
40
apps/dokploy/__test__/setup.ts
Normal file
40
apps/dokploy/__test__/setup.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Mock the DB module so tests that import from @dokploy/server (barrel)
|
||||
* never open a real TCP connection to PostgreSQL (e.g. in CI where no DB runs).
|
||||
* Without this, loading the server barrel pulls in lib/auth and db, which
|
||||
* connect to localhost:5432 and cause ECONNREFUSED.
|
||||
*/
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const chain = () => chain;
|
||||
chain.set = () => chain;
|
||||
chain.where = () => chain;
|
||||
chain.values = () => chain;
|
||||
chain.returning = () => Promise.resolve([{}]);
|
||||
chain.then = undefined;
|
||||
|
||||
const tableMock = {
|
||||
findFirst: vi.fn(() => Promise.resolve(undefined)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
insert: vi.fn(() => Promise.resolve([{}])),
|
||||
update: vi.fn(() => chain),
|
||||
delete: vi.fn(() => chain),
|
||||
};
|
||||
const createQueryMock = () => tableMock;
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(() => chain),
|
||||
insert: vi.fn(() => ({
|
||||
values: () => ({ returning: () => Promise.resolve([{}]) }),
|
||||
})),
|
||||
update: vi.fn(() => chain),
|
||||
delete: vi.fn(() => chain),
|
||||
query: new Proxy({} as Record<string, typeof tableMock>, {
|
||||
get: () => tableMock,
|
||||
}),
|
||||
},
|
||||
dbUrl: "postgres://mock:mock@localhost:5432/mock",
|
||||
};
|
||||
});
|
||||
@@ -125,6 +125,7 @@ const baseApp: ApplicationNested = {
|
||||
username: null,
|
||||
dockerContextPath: null,
|
||||
stopGracePeriodSwarm: null,
|
||||
ulimitsSwarm: null,
|
||||
};
|
||||
|
||||
const baseDomain: Domain = {
|
||||
@@ -274,3 +275,51 @@ test("CertificateType on websecure entrypoint", async () => {
|
||||
|
||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
||||
});
|
||||
|
||||
/** IDN/Punycode */
|
||||
|
||||
test("Internationalized domain name is converted to punycode", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, host: "тест.рф" },
|
||||
"web",
|
||||
);
|
||||
|
||||
// тест.рф in punycode is xn--e1aybc.xn--p1ai
|
||||
expect(router.rule).toContain("Host(`xn--e1aybc.xn--p1ai`)");
|
||||
expect(router.rule).not.toContain("тест.рф");
|
||||
});
|
||||
|
||||
test("ASCII domain remains unchanged", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, host: "example.com" },
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(router.rule).toContain("Host(`example.com`)");
|
||||
});
|
||||
|
||||
test("Russian Cyrillic label with .ru TLD is converted to punycode", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, host: "сайт.ru" },
|
||||
"web",
|
||||
);
|
||||
|
||||
// сайт in punycode is xn--80aswg
|
||||
expect(router.rule).toContain("Host(`xn--80aswg.ru`)");
|
||||
expect(router.rule).not.toContain("сайт");
|
||||
});
|
||||
|
||||
test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, host: "app.тест.рф" },
|
||||
"web",
|
||||
);
|
||||
|
||||
// app stays ASCII, тест.рф becomes xn--e1aybc.xn--p1ai
|
||||
expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)");
|
||||
expect(router.rule).not.toContain("тест.рф");
|
||||
});
|
||||
|
||||
@@ -7,10 +7,15 @@ export default defineConfig({
|
||||
include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__
|
||||
exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"],
|
||||
pool: "forks",
|
||||
setupFiles: [path.resolve(__dirname, "setup.ts")],
|
||||
},
|
||||
define: {
|
||||
"process.env": {
|
||||
NODE: "test",
|
||||
GITHUB_CLIENT_ID: "test",
|
||||
GITHUB_CLIENT_SECRET: "test",
|
||||
GOOGLE_CLIENT_ID: "test",
|
||||
GOOGLE_CLIENT_SECRET: "test",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
HealthCheckForm,
|
||||
LabelsForm,
|
||||
ModeForm,
|
||||
NetworkForm,
|
||||
PlacementForm,
|
||||
RestartPolicyForm,
|
||||
RollbackConfigForm,
|
||||
@@ -79,6 +80,13 @@ const menuItems: MenuItem[] = [
|
||||
docDescription:
|
||||
"Set service mode to either 'Replicated' with a specified number of tasks (Replicas), or 'Global' (one task per node).",
|
||||
},
|
||||
{
|
||||
id: "network",
|
||||
label: "Network",
|
||||
description: "Configure network attachments",
|
||||
docDescription:
|
||||
"Attach the service to one or more networks. Specify the network name (Target) and optional network aliases for service discovery.",
|
||||
},
|
||||
{
|
||||
id: "labels",
|
||||
label: "Labels",
|
||||
@@ -190,6 +198,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
<RollbackConfigForm id={id} type={type} />
|
||||
)}
|
||||
{activeMenu === "mode" && <ModeForm id={id} type={type} />}
|
||||
{activeMenu === "network" && <NetworkForm id={id} type={type} />}
|
||||
{activeMenu === "labels" && <LabelsForm id={id} type={type} />}
|
||||
{activeMenu === "stop-grace-period" && (
|
||||
<StopGracePeriodForm id={id} type={type} />
|
||||
|
||||
@@ -2,6 +2,7 @@ export { EndpointSpecForm } from "./endpoint-spec-form";
|
||||
export { HealthCheckForm } from "./health-check-form";
|
||||
export { LabelsForm } from "./labels-form";
|
||||
export { ModeForm } from "./mode-form";
|
||||
export { NetworkForm } from "./network-form";
|
||||
export { PlacementForm } from "./placement-form";
|
||||
export { RestartPolicyForm } from "./restart-policy-form";
|
||||
export { RollbackConfigForm } from "./rollback-config-form";
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const driverOptEntrySchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const networkFormSchema = z.object({
|
||||
networks: z
|
||||
.array(
|
||||
z.object({
|
||||
Target: z.string().optional(),
|
||||
Aliases: z.string().optional(),
|
||||
DriverOptsEntries: z.array(driverOptEntrySchema).optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface NetworkFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
}
|
||||
|
||||
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<z.infer<typeof networkFormSchema>>({
|
||||
resolver: zodResolver(networkFormSchema),
|
||||
defaultValues: {
|
||||
networks: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "networks",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.networkSwarm && Array.isArray(data.networkSwarm)) {
|
||||
const networkEntries = data.networkSwarm.map((network) => ({
|
||||
Target: network.Target || "",
|
||||
Aliases: network.Aliases?.join(", ") || "",
|
||||
DriverOptsEntries: network.DriverOpts
|
||||
? Object.entries(network.DriverOpts).map(([key, value]) => ({
|
||||
key,
|
||||
value: value ?? "",
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
form.reset({ networks: networkEntries });
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (formData: z.infer<typeof networkFormSchema>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const networksArray =
|
||||
formData.networks
|
||||
?.filter((network) => network.Target)
|
||||
.map((network) => {
|
||||
const entries = (network.DriverOptsEntries ?? []).filter(
|
||||
(e) => e.key.trim() !== "",
|
||||
);
|
||||
const driverOpts =
|
||||
entries.length > 0
|
||||
? Object.fromEntries(
|
||||
entries.map((e) => [e.key.trim(), e.value]),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
Target: network.Target,
|
||||
Aliases: network.Aliases
|
||||
? network.Aliases.split(",").map((alias) => alias.trim())
|
||||
: undefined,
|
||||
DriverOpts: driverOpts,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
// If no networks, send null to clear the database
|
||||
const networksToSend = networksArray.length > 0 ? networksArray : null;
|
||||
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
networkSwarm: networksToSend,
|
||||
});
|
||||
|
||||
toast.success("Network configuration updated successfully");
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error("Error updating network configuration");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<FormLabel>Networks</FormLabel>
|
||||
<FormDescription>
|
||||
Configure network attachments for your service
|
||||
</FormDescription>
|
||||
<div className="space-y-2 mt-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="space-y-2 p-3 border rounded">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.Target`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Network Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="my-network" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The name of the network to attach to
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.Aliases`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Aliases (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="alias1, alias2, alias3"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Comma-separated list of network aliases
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Driver options (optional)</FormLabel>
|
||||
<FormDescription>
|
||||
e.g. com.docker.network.driver.mtu,
|
||||
com.docker.network.driver.host_binding
|
||||
</FormDescription>
|
||||
{(
|
||||
form.watch(`networks.${index}.DriverOptsEntries`) ?? []
|
||||
).map((_, optIndex) => (
|
||||
<div
|
||||
key={optIndex}
|
||||
className="flex gap-2 items-end flex-wrap"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.DriverOptsEntries.${optIndex}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1 min-w-[140px]">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="com.docker.network.driver.mtu"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.DriverOptsEntries.${optIndex}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1 min-w-[100px]">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="1500" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const entries =
|
||||
form.getValues(
|
||||
`networks.${index}.DriverOptsEntries`,
|
||||
) ?? [];
|
||||
form.setValue(
|
||||
`networks.${index}.DriverOptsEntries`,
|
||||
entries.filter((_, i) => i !== optIndex),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const entries =
|
||||
form.getValues(`networks.${index}.DriverOptsEntries`) ??
|
||||
[];
|
||||
form.setValue(`networks.${index}.DriverOptsEntries`, [
|
||||
...entries,
|
||||
{ key: "", value: "" },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add driver option
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
Remove Network
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({
|
||||
Target: "",
|
||||
Aliases: "",
|
||||
DriverOptsEntries: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Network
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset({ networks: [] });
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save Networks
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { InfoIcon, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -21,10 +21,18 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
createConverter,
|
||||
NumberInputWithSteps,
|
||||
} from "@/components/ui/number-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -50,13 +58,36 @@ const memoryConverter = createConverter(1024 * 1024, (mb) => {
|
||||
: `${formatNumber(mb)} MB`;
|
||||
});
|
||||
|
||||
const ulimitSchema = z.object({
|
||||
Name: z.string().min(1, "Name is required"),
|
||||
Soft: z.coerce.number().int().min(-1, "Must be >= -1"),
|
||||
Hard: z.coerce.number().int().min(-1, "Must be >= -1"),
|
||||
});
|
||||
|
||||
const addResourcesSchema = z.object({
|
||||
memoryReservation: z.string().optional(),
|
||||
cpuLimit: z.string().optional(),
|
||||
memoryLimit: z.string().optional(),
|
||||
cpuReservation: z.string().optional(),
|
||||
ulimitsSwarm: z.array(ulimitSchema).optional(),
|
||||
});
|
||||
|
||||
const ULIMIT_PRESETS = [
|
||||
{ value: "nofile", label: "nofile (Open Files)" },
|
||||
{ value: "nproc", label: "nproc (Processes)" },
|
||||
{ value: "memlock", label: "memlock (Locked Memory)" },
|
||||
{ value: "stack", label: "stack (Stack Size)" },
|
||||
{ value: "core", label: "core (Core File Size)" },
|
||||
{ value: "cpu", label: "cpu (CPU Time)" },
|
||||
{ value: "data", label: "data (Data Segment)" },
|
||||
{ value: "fsize", label: "fsize (File Size)" },
|
||||
{ value: "locks", label: "locks (File Locks)" },
|
||||
{ value: "msgqueue", label: "msgqueue (Message Queues)" },
|
||||
{ value: "nice", label: "nice (Nice Priority)" },
|
||||
{ value: "rtprio", label: "rtprio (Real-time Priority)" },
|
||||
{ value: "sigpending", label: "sigpending (Pending Signals)" },
|
||||
];
|
||||
|
||||
export type ServiceType =
|
||||
| "postgres"
|
||||
| "mongo"
|
||||
@@ -107,10 +138,16 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: "",
|
||||
memoryLimit: "",
|
||||
memoryReservation: "",
|
||||
ulimitsSwarm: [],
|
||||
},
|
||||
resolver: zodResolver(addResourcesSchema),
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "ulimitsSwarm",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
@@ -118,6 +155,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: data?.cpuReservation || undefined,
|
||||
memoryLimit: data?.memoryLimit || undefined,
|
||||
memoryReservation: data?.memoryReservation || undefined,
|
||||
ulimitsSwarm: data?.ulimitsSwarm || [],
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
@@ -134,6 +172,10 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: formData.cpuReservation || null,
|
||||
memoryLimit: formData.memoryLimit || null,
|
||||
memoryReservation: formData.memoryReservation || null,
|
||||
ulimitsSwarm:
|
||||
formData.ulimitsSwarm && formData.ulimitsSwarm.length > 0
|
||||
? formData.ulimitsSwarm
|
||||
: null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Resources Updated");
|
||||
@@ -325,6 +367,145 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ulimits Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-base">Ulimits</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>
|
||||
Set resource limits for the container. Each ulimit has
|
||||
a soft limit (warning threshold) and hard limit
|
||||
(maximum allowed). Use -1 for unlimited.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({ Name: "nofile", Soft: 65535, Hard: 65535 })
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Ulimit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ulimitsSwarm.${index}.Name`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel className="text-xs">Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select ulimit" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{ULIMIT_PRESETS.map((preset) => (
|
||||
<SelectItem
|
||||
key={preset.value}
|
||||
value={preset.value}
|
||||
>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ulimitsSwarm.${index}.Soft`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-32">
|
||||
<FormLabel className="text-xs">
|
||||
Soft Limit
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={-1}
|
||||
placeholder="65535"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ulimitsSwarm.${index}.Hard`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-32">
|
||||
<FormLabel className="text-xs">
|
||||
Hard Limit
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={-1}
|
||||
placeholder="65535"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-6 text-destructive hover:text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No ulimits configured. Click "Add Ulimit" to set
|
||||
resource limits.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
Save
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const UpdateTraefikConfigSchema = z.object({
|
||||
@@ -59,6 +61,7 @@ export const validateAndFormatYAML = (yamlText: string) => {
|
||||
|
||||
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
@@ -85,13 +88,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
}, [data]);
|
||||
|
||||
const onSubmit = async (data: UpdateTraefikConfig) => {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: (error as string) || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
if (!skipYamlValidation) {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: (error as string) || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
form.clearErrors("traefikConfig");
|
||||
await mutateAsync({
|
||||
@@ -116,6 +121,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setSkipYamlValidation(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -169,7 +175,28 @@ routers:
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="flex-col sm:flex-row gap-4">
|
||||
<div className="flex flex-col gap-1 w-full sm:w-auto sm:mr-auto">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="skip-yaml-validation-app"
|
||||
checked={skipYamlValidation}
|
||||
onCheckedChange={(checked) =>
|
||||
setSkipYamlValidation(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="skip-yaml-validation-app"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Skip YAML validation (for Go templating)
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check to save configs with Go templating (e.g.{" "}
|
||||
<code className="text-xs">{"{{range}}"}</code>).
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-update-traefik-config"
|
||||
|
||||
@@ -245,13 +245,13 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -263,11 +263,15 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!bitbucketId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a Bitbucket account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -258,14 +258,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo: GiteaRepository) =>
|
||||
repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -277,11 +277,15 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!giteaId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a Gitea account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -233,13 +233,13 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -251,11 +251,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!githubId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a GitHub account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -254,13 +254,13 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -272,11 +272,15 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!gitlabId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a GitLab account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -34,6 +34,7 @@ export const DockerLogs = dynamic(
|
||||
export const badgeStateColor = (state: string) => {
|
||||
switch (state) {
|
||||
case "running":
|
||||
case "ready":
|
||||
return "green";
|
||||
case "exited":
|
||||
case "shutdown":
|
||||
@@ -142,6 +143,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.status ? ` ${container.status}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
@@ -157,6 +159,9 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.currentState
|
||||
? ` ${container.currentState}`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
@@ -166,6 +171,13 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{option === "swarm" &&
|
||||
services?.find((c) => c.containerId === containerId)?.error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
|
||||
<span className="font-medium">Error: </span>
|
||||
{services?.find((c) => c.containerId === containerId)?.error}
|
||||
</div>
|
||||
)}
|
||||
<DockerLogs
|
||||
serverId={serverId || ""}
|
||||
containerId={containerId || "select-a-container"}
|
||||
|
||||
@@ -247,13 +247,13 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -265,11 +265,15 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!bitbucketId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a Bitbucket account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -244,13 +244,13 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
@@ -261,11 +261,15 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!giteaId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a Gitea account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -234,13 +234,13 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -252,11 +252,15 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!githubId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a GitHub account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -256,13 +256,13 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -274,11 +274,15 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!gitlabId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a GitLab account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -128,6 +128,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.status ? ` ${container.status}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
@@ -143,6 +144,9 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.currentState
|
||||
? ` ${container.currentState}`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
@@ -152,6 +156,13 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{option === "swarm" &&
|
||||
services?.find((c) => c.containerId === containerId)?.error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
|
||||
<span className="font-medium">Error: </span>
|
||||
{services.find((c) => c.containerId === containerId)?.error}
|
||||
</div>
|
||||
)}
|
||||
<DockerLogs
|
||||
serverId={serverId || ""}
|
||||
containerId={containerId || "select-a-container"}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -93,6 +93,7 @@ export const ShowDockerLogsCompose = ({
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.status ? ` ${container.status}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Containers ({data?.length})</SelectLabel>
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Boxes,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Rocket,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { AppRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type DeploymentRow =
|
||||
inferRouterOutputs<AppRouter>["deployment"]["allCentralized"][number];
|
||||
|
||||
const statusVariants: Record<
|
||||
string,
|
||||
| "default"
|
||||
| "secondary"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "yellow"
|
||||
| "green"
|
||||
| "red"
|
||||
> = {
|
||||
running: "yellow",
|
||||
done: "green",
|
||||
error: "red",
|
||||
cancelled: "outline",
|
||||
};
|
||||
|
||||
function getServiceInfo(d: DeploymentRow) {
|
||||
const app = d.application;
|
||||
const comp = d.compose;
|
||||
if (app?.environment?.project && app.environment) {
|
||||
return {
|
||||
type: "Application" as const,
|
||||
name: app.name,
|
||||
projectId: app.environment.project.projectId,
|
||||
environmentId: app.environment.environmentId,
|
||||
projectName: app.environment.project.name,
|
||||
environmentName: app.environment.name,
|
||||
serviceId: app.applicationId,
|
||||
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
|
||||
};
|
||||
}
|
||||
if (comp?.environment?.project && comp.environment) {
|
||||
return {
|
||||
type: "Compose" as const,
|
||||
name: comp.name,
|
||||
projectId: comp.environment.project.projectId,
|
||||
environmentId: comp.environment.environmentId,
|
||||
projectName: comp.environment.project.name,
|
||||
environmentName: comp.environment.name,
|
||||
serviceId: comp.composeId,
|
||||
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ShowDeploymentsTable() {
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "createdAt", desc: true },
|
||||
]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all");
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 50,
|
||||
});
|
||||
|
||||
const { data: deploymentsList, isLoading } =
|
||||
api.deployment.allCentralized.useQuery(undefined, {
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!deploymentsList) return [];
|
||||
let list = deploymentsList;
|
||||
if (statusFilter !== "all") {
|
||||
list = list.filter((d) => d.status === statusFilter);
|
||||
}
|
||||
if (typeFilter === "application") {
|
||||
list = list.filter((d) => d.applicationId != null);
|
||||
} else if (typeFilter === "compose") {
|
||||
list = list.filter((d) => d.composeId != null);
|
||||
}
|
||||
if (globalFilter.trim()) {
|
||||
const q = globalFilter.toLowerCase();
|
||||
list = list.filter((d) => {
|
||||
const info = getServiceInfo(d);
|
||||
const serverName =
|
||||
d.server?.name ??
|
||||
d.application?.server?.name ??
|
||||
d.compose?.server?.name ??
|
||||
"";
|
||||
const buildServerName =
|
||||
d.buildServer?.name ?? d.application?.buildServer?.name ?? "";
|
||||
if (!info) return false;
|
||||
return (
|
||||
info.name.toLowerCase().includes(q) ||
|
||||
info.projectName.toLowerCase().includes(q) ||
|
||||
info.environmentName.toLowerCase().includes(q) ||
|
||||
(d.title?.toLowerCase().includes(q) ?? false) ||
|
||||
serverName.toLowerCase().includes(q) ||
|
||||
buildServerName.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}, [deploymentsList, statusFilter, typeFilter, globalFilter]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "serviceName",
|
||||
accessorFn: (row: DeploymentRow) => getServiceInfo(row)?.name ?? "",
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: {
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (asc: boolean) => void;
|
||||
};
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Service
|
||||
<ArrowUpDown className="ml-2 size-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
||||
const info = getServiceInfo(row.original);
|
||||
if (!info) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{info.type === "Application" ? (
|
||||
<Rocket className="size-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<Boxes className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-medium truncate">{info.name}</span>
|
||||
<Badge variant="outline" className="w-fit text-[10px]">
|
||||
{info.type}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "projectName",
|
||||
accessorFn: (row: DeploymentRow) =>
|
||||
getServiceInfo(row)?.projectName ?? "",
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: {
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (asc: boolean) => void;
|
||||
};
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Project
|
||||
<ArrowUpDown className="ml-2 size-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
||||
const info = getServiceInfo(row.original);
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{info?.projectName ?? "—"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "environmentName",
|
||||
accessorFn: (row: DeploymentRow) =>
|
||||
getServiceInfo(row)?.environmentName ?? "",
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: {
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (asc: boolean) => void;
|
||||
};
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Environment
|
||||
<ArrowUpDown className="ml-2 size-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
||||
const info = getServiceInfo(row.original);
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
{info?.environmentName ?? "—"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "serverName",
|
||||
accessorFn: (row: DeploymentRow) =>
|
||||
row.server?.name ??
|
||||
row.application?.server?.name ??
|
||||
row.compose?.server?.name ??
|
||||
"",
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: {
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (asc: boolean) => void;
|
||||
};
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Server
|
||||
<ArrowUpDown className="ml-2 size-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
||||
const d = row.original;
|
||||
const serverName =
|
||||
d.server?.name ??
|
||||
d.application?.server?.name ??
|
||||
d.compose?.server?.name ??
|
||||
null;
|
||||
const serverType =
|
||||
d.server?.serverType ??
|
||||
d.application?.server?.serverType ??
|
||||
d.compose?.server?.serverType ??
|
||||
null;
|
||||
const buildServerName =
|
||||
d.buildServer?.name ?? d.application?.buildServer?.name ?? null;
|
||||
const buildServerType =
|
||||
d.buildServer?.serverType ??
|
||||
d.application?.buildServer?.serverType ??
|
||||
null;
|
||||
const showBuild =
|
||||
buildServerName != null && buildServerName !== serverName;
|
||||
if (!serverName && !showBuild) {
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 text-sm">
|
||||
{serverName && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Server className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{serverName}</span>
|
||||
{serverType && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-normal"
|
||||
>
|
||||
{serverType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showBuild && buildServerName && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground flex-wrap">
|
||||
<span className="text-[10px]">Build:</span>
|
||||
<span className="truncate text-xs">{buildServerName}</span>
|
||||
{buildServerType && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-normal"
|
||||
>
|
||||
{buildServerType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: {
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (asc: boolean) => void;
|
||||
};
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Title
|
||||
<ArrowUpDown className="ml-2 size-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }: { row: { original: DeploymentRow } }) => (
|
||||
<span className="text-sm truncate max-w-[200px] block">
|
||||
{row.original.title || "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: {
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (asc: boolean) => void;
|
||||
};
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Status
|
||||
<ArrowUpDown className="ml-2 size-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
||||
const status = row.original.status ?? "running";
|
||||
return (
|
||||
<Badge variant={statusVariants[status] ?? "secondary"}>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({
|
||||
column,
|
||||
}: {
|
||||
column: {
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (asc: boolean) => void;
|
||||
};
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Created
|
||||
<ArrowUpDown className="ml-2 size-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }: { row: { original: DeploymentRow } }) => (
|
||||
<span className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
{row.original.createdAt
|
||||
? new Date(row.original.createdAt).toLocaleString()
|
||||
: "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "",
|
||||
id: "actions",
|
||||
enableSorting: false,
|
||||
cell: ({ row }: { row: { original: DeploymentRow } }) => {
|
||||
const info = getServiceInfo(row.original);
|
||||
if (!info) return null;
|
||||
return (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={info.href} className="gap-1">
|
||||
<ExternalLink className="size-4" />
|
||||
Open
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
globalFilter,
|
||||
pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search by name, project, environment, server..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="running">Running</SelectItem>
|
||||
<SelectItem value="done">Done</SelectItem>
|
||||
<SelectItem value="error">Error</SelectItem>
|
||||
<SelectItem value="cancelled">Cancelled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
<SelectItem value="application">Application</SelectItem>
|
||||
<SelectItem value="compose">Compose</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="px-0">
|
||||
{isLoading ? (
|
||||
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading deployments...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className=" text-center"
|
||||
>
|
||||
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
|
||||
<Rocket className="size-8" />
|
||||
<p className="font-medium">No deployments found</p>
|
||||
<p className="text-sm">
|
||||
Deployments from applications and compose will
|
||||
appear here.
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Rows per page
|
||||
</span>
|
||||
<Select
|
||||
value={String(pagination.pageSize)}
|
||||
onValueChange={(value) => {
|
||||
setPagination((p) => ({
|
||||
...p,
|
||||
pageSize: Number(value),
|
||||
pageIndex: 0,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{[10, 25, 50, 100].map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
Showing{" "}
|
||||
{filteredData.length === 0
|
||||
? 0
|
||||
: pagination.pageIndex * pagination.pageSize + 1}{" "}
|
||||
to{" "}
|
||||
{Math.min(
|
||||
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||
filteredData.length,
|
||||
)}{" "}
|
||||
of {filteredData.length} entries
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { AppRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type QueueRow =
|
||||
inferRouterOutputs<AppRouter>["deployment"]["queueList"][number];
|
||||
|
||||
const stateVariants: Record<
|
||||
string,
|
||||
| "default"
|
||||
| "secondary"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "yellow"
|
||||
| "green"
|
||||
| "red"
|
||||
> = {
|
||||
pending: "secondary",
|
||||
waiting: "secondary",
|
||||
active: "yellow",
|
||||
delayed: "outline",
|
||||
completed: "green",
|
||||
failed: "destructive",
|
||||
cancelled: "outline",
|
||||
paused: "outline",
|
||||
};
|
||||
|
||||
function formatTs(ts?: number): string {
|
||||
if (ts == null) return "—";
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
function getJobLabel(row: QueueRow): string {
|
||||
const d = row.data as {
|
||||
applicationType?: string;
|
||||
applicationId?: string;
|
||||
composeId?: string;
|
||||
previewDeploymentId?: string;
|
||||
titleLog?: string;
|
||||
type?: string;
|
||||
};
|
||||
if (!d) return String(row.id);
|
||||
const type = d.applicationType ?? "job";
|
||||
const title = d.titleLog ?? "";
|
||||
if (title) return title;
|
||||
if (d.applicationId) return `Application ${d.applicationId.slice(0, 8)}…`;
|
||||
if (d.composeId) return `Compose ${d.composeId.slice(0, 8)}…`;
|
||||
if (d.previewDeploymentId)
|
||||
return `Preview ${d.previewDeploymentId.slice(0, 8)}…`;
|
||||
return `${type} ${String(row.id)}`;
|
||||
}
|
||||
|
||||
export function ShowQueueTable(props: { embedded?: boolean }) {
|
||||
const { embedded: _embedded = false } = props;
|
||||
const { data: queueList, isLoading } = api.deployment.queueList.useQuery(
|
||||
undefined,
|
||||
{ refetchInterval: 3000 },
|
||||
);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const utils = api.useUtils();
|
||||
const {
|
||||
mutateAsync: cancelApplicationDeployment,
|
||||
isPending: isCancellingApp,
|
||||
} = api.application.cancelDeployment.useMutation({
|
||||
onSuccess: () => void utils.deployment.queueList.invalidate(),
|
||||
});
|
||||
const {
|
||||
mutateAsync: cancelComposeDeployment,
|
||||
isPending: isCancellingCompose,
|
||||
} = api.compose.cancelDeployment.useMutation({
|
||||
onSuccess: () => void utils.deployment.queueList.invalidate(),
|
||||
});
|
||||
const isCancelling = isCancellingApp || isCancellingCompose;
|
||||
|
||||
return (
|
||||
<div className="px-0">
|
||||
{isLoading ? (
|
||||
<div className="flex gap-4 w-full items-center justify-center min-h-[30vh] text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading queue...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Job ID</TableHead>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
<TableHead>Added</TableHead>
|
||||
<TableHead>Processed</TableHead>
|
||||
<TableHead>Finished</TableHead>
|
||||
<TableHead>Error</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{queueList?.length ? (
|
||||
queueList.map((row) => {
|
||||
const d = row.data as Record<string, unknown>;
|
||||
const appType = d?.applicationType as string | undefined;
|
||||
const pathInfo = row.servicePath;
|
||||
const hasLink = pathInfo?.href != null;
|
||||
return (
|
||||
<TableRow key={String(row.id)}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{String(row.id)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{getJobLabel(row)}
|
||||
</TableCell>
|
||||
<TableCell>{appType ?? row.name ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={stateVariants[row.state] ?? "outline"}>
|
||||
{row.state}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">
|
||||
{formatTs(row.timestamp)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">
|
||||
{formatTs(row.processedOn)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">
|
||||
{formatTs(row.finishedOn)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] truncate text-xs text-destructive">
|
||||
{row.failedReason ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
{hasLink ? (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={pathInfo!.href!}>
|
||||
<ArrowRight className="size-4 mr-1" />
|
||||
Service
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
{isCloud &&
|
||||
row.state === "active" &&
|
||||
(d?.applicationId != null ||
|
||||
d?.composeId != null) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isCancelling}
|
||||
onClick={() => {
|
||||
const appId =
|
||||
typeof d.applicationId === "string"
|
||||
? d.applicationId
|
||||
: undefined;
|
||||
const compId =
|
||||
typeof d.composeId === "string"
|
||||
? d.composeId
|
||||
: undefined;
|
||||
if (appId) {
|
||||
void cancelApplicationDeployment({
|
||||
applicationId: appId,
|
||||
});
|
||||
} else if (compId) {
|
||||
void cancelComposeDeployment({
|
||||
composeId: compId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XCircle className="size-4 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="text-center py-12">
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground min-h-[30vh]">
|
||||
<ListTodo className="size-8" />
|
||||
<p className="font-medium">Queue is empty</p>
|
||||
<p className="text-sm">
|
||||
Deployment jobs will appear here when they are queued.
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config";
|
||||
|
||||
@@ -47,6 +49,7 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
|
||||
},
|
||||
);
|
||||
const [canEdit, setCanEdit] = useState(true);
|
||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.settings.updateTraefikFile.useMutation();
|
||||
@@ -66,13 +69,15 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: UpdateServerMiddlewareConfig) => {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: error || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
if (!skipYamlValidation) {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: error || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
form.clearErrors("traefikConfig");
|
||||
await mutateAsync({
|
||||
@@ -153,14 +158,37 @@ routers:
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
disabled={canEdit || isLoading}
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="skip-yaml-validation"
|
||||
checked={skipYamlValidation}
|
||||
onCheckedChange={(checked) =>
|
||||
setSkipYamlValidation(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="skip-yaml-validation"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Skip YAML validation (for Go templating)
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground -mt-2">
|
||||
Traefik supports Go templating in dynamic configs (e.g.{" "}
|
||||
<code className="text-xs">{"{{range}}"}</code>). Configs using
|
||||
templates will fail standard YAML validation. Check this to save
|
||||
without validation.
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
disabled={canEdit || isLoading}
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -73,8 +73,8 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const organizationSchema = z.object({
|
||||
@@ -55,8 +54,6 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
const { mutateAsync, isLoading } = organizationId
|
||||
? api.organization.update.useMutation()
|
||||
: api.organization.create.useMutation();
|
||||
const { refetch: refetchActiveOrganization } =
|
||||
authClient.useActiveOrganization();
|
||||
|
||||
const form = useForm<OrganizationFormValues>({
|
||||
resolver: zodResolver(organizationSchema),
|
||||
@@ -89,7 +86,7 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
utils.organization.all.invalidate();
|
||||
if (organizationId) {
|
||||
utils.organization.one.invalidate({ organizationId });
|
||||
refetchActiveOrganization();
|
||||
utils.organization.active.invalidate();
|
||||
}
|
||||
setOpen(false);
|
||||
})
|
||||
|
||||
@@ -75,8 +75,8 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -430,7 +430,7 @@ export const ShowProjects = () => {
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
|
||||
<span className="flex flex-col gap-1.5 ">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookIcon className="size-4 text-muted-foreground" />
|
||||
@@ -439,7 +439,7 @@ export const ShowProjects = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-medium text-muted-foreground break-all">
|
||||
<span className="text-sm font-medium text-muted-foreground break-normal">
|
||||
{project.description}
|
||||
</span>
|
||||
|
||||
|
||||
@@ -74,8 +74,8 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { StatusTooltip } from "../shared/status-tooltip";
|
||||
|
||||
@@ -56,7 +55,7 @@ export const SearchCommand = () => {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: session } = api.user.session.useQuery();
|
||||
const { data } = api.project.all.useQuery(undefined, {
|
||||
enabled: !!session,
|
||||
});
|
||||
@@ -174,6 +173,14 @@ export const SearchCommand = () => {
|
||||
>
|
||||
Projects
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/dashboard/deployments");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Deployments
|
||||
</CommandItem>
|
||||
{!isCloud && (
|
||||
<>
|
||||
<CommandItem
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
@@ -39,6 +40,10 @@ const Schema = z.object({
|
||||
giteaUrl: z.string().min(1, {
|
||||
message: "Gitea URL is required",
|
||||
}),
|
||||
giteaInternalUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
clientId: z.string().min(1, {
|
||||
message: "Client ID is required",
|
||||
}),
|
||||
@@ -70,6 +75,7 @@ export const AddGiteaProvider = () => {
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
giteaUrl: "https://gitea.com",
|
||||
giteaInternalUrl: "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -83,6 +89,7 @@ export const AddGiteaProvider = () => {
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
giteaUrl: "https://gitea.com",
|
||||
giteaInternalUrl: "",
|
||||
});
|
||||
}, [form, webhookUrl, isOpen]);
|
||||
|
||||
@@ -95,6 +102,7 @@ export const AddGiteaProvider = () => {
|
||||
name: data.name,
|
||||
redirectUri: data.redirectUri,
|
||||
giteaUrl: data.giteaUrl,
|
||||
giteaInternalUrl: data.giteaInternalUrl || undefined,
|
||||
organizationName: data.organizationName,
|
||||
})) as unknown as GiteaProviderResponse;
|
||||
|
||||
@@ -223,6 +231,29 @@ export const AddGiteaProvider = () => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="giteaInternalUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="http://gitea:3000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use when Gitea runs on the same instance as Dokploy.
|
||||
Used for OAuth token exchange to reach Gitea via
|
||||
internal network (e.g. Docker service name).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="redirectUri"
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
@@ -30,6 +31,10 @@ import { useUrl } from "@/utils/hooks/use-url";
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
giteaUrl: z.string().min(1, "Gitea URL is required"),
|
||||
giteaInternalUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
clientId: z.string().min(1, "Client ID is required"),
|
||||
clientSecret: z.string().min(1, "Client Secret is required"),
|
||||
});
|
||||
@@ -94,6 +99,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
defaultValues: {
|
||||
name: "",
|
||||
giteaUrl: "https://gitea.com",
|
||||
giteaInternalUrl: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
@@ -104,6 +110,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
form.reset({
|
||||
name: gitea.gitProvider?.name || "",
|
||||
giteaUrl: gitea.giteaUrl || "https://gitea.com",
|
||||
giteaInternalUrl: gitea.giteaInternalUrl || "",
|
||||
clientId: gitea.clientId || "",
|
||||
clientSecret: gitea.clientSecret || "",
|
||||
});
|
||||
@@ -116,6 +123,7 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
gitProviderId: gitea?.gitProvider?.gitProviderId || "",
|
||||
name: values.name,
|
||||
giteaUrl: values.giteaUrl,
|
||||
giteaInternalUrl: values.giteaInternalUrl ?? null,
|
||||
clientId: values.clientId,
|
||||
clientSecret: values.clientSecret,
|
||||
})
|
||||
@@ -224,6 +232,28 @@ export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="giteaInternalUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="http://gitea:3000"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use when Gitea runs on the same instance as Dokploy. Used
|
||||
for OAuth token exchange to reach Gitea via internal network
|
||||
(e.g. Docker service name).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const AddGithubProvider = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||
|
||||
const { data: session } = api.user.session.useQuery();
|
||||
const { data } = api.user.get.useQuery();
|
||||
const [manifest, setManifest] = useState("");
|
||||
const [isOrganization, setIsOrganization] = useState(false);
|
||||
@@ -52,7 +52,7 @@ export const AddGithubProvider = () => {
|
||||
);
|
||||
|
||||
setManifest(manifest);
|
||||
}, [data?.id]);
|
||||
}, [activeOrganization?.id, session?.user?.id]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
@@ -98,8 +98,8 @@ export const AddGithubProvider = () => {
|
||||
<form
|
||||
action={
|
||||
isOrganization
|
||||
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}`
|
||||
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}`
|
||||
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
|
||||
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
|
||||
}
|
||||
method="post"
|
||||
>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
@@ -35,6 +36,10 @@ const Schema = z.object({
|
||||
gitlabUrl: z.string().min(1, {
|
||||
message: "GitLab URL is required",
|
||||
}),
|
||||
gitlabInternalUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
applicationId: z.string().min(1, {
|
||||
message: "Application ID is required",
|
||||
}),
|
||||
@@ -66,6 +71,7 @@ export const AddGitlabProvider = () => {
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
gitlabUrl: "https://gitlab.com",
|
||||
gitlabInternalUrl: "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -80,6 +86,7 @@ export const AddGitlabProvider = () => {
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
gitlabUrl: "https://gitlab.com",
|
||||
gitlabInternalUrl: "",
|
||||
});
|
||||
}, [form, isOpen]);
|
||||
|
||||
@@ -92,6 +99,7 @@ export const AddGitlabProvider = () => {
|
||||
name: data.name || "",
|
||||
redirectUri: data.redirectUri || "",
|
||||
gitlabUrl: data.gitlabUrl || "https://gitlab.com",
|
||||
gitlabInternalUrl: data.gitlabInternalUrl || undefined,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.gitProvider.getAll.invalidate();
|
||||
@@ -192,6 +200,29 @@ export const AddGitlabProvider = () => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gitlabInternalUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="http://gitlab:80"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use when GitLab runs on the same instance as Dokploy.
|
||||
Used for OAuth token exchange to reach GitLab via
|
||||
internal network (e.g. Docker service name).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="redirectUri"
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormDescription,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
@@ -33,6 +34,10 @@ const Schema = z.object({
|
||||
gitlabUrl: z.string().url({
|
||||
message: "Invalid Gitlab URL",
|
||||
}),
|
||||
gitlabInternalUrl: z
|
||||
.union([z.string().url(), z.literal("")])
|
||||
.optional()
|
||||
.transform((v) => (v === "" ? undefined : v)),
|
||||
groupName: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -61,6 +66,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
groupName: "",
|
||||
name: "",
|
||||
gitlabUrl: "https://gitlab.com",
|
||||
gitlabInternalUrl: "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -72,6 +78,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
groupName: gitlab?.groupName || "",
|
||||
name: gitlab?.gitProvider.name || "",
|
||||
gitlabUrl: gitlab?.gitlabUrl || "",
|
||||
gitlabInternalUrl: gitlab?.gitlabInternalUrl || "",
|
||||
});
|
||||
}, [form, isOpen]);
|
||||
|
||||
@@ -82,6 +89,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
groupName: data.groupName || "",
|
||||
name: data.name || "",
|
||||
gitlabUrl: data.gitlabUrl || "",
|
||||
gitlabInternalUrl: data.gitlabInternalUrl ?? null,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.gitProvider.getAll.invalidate();
|
||||
@@ -151,6 +159,29 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="gitlabInternalUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Internal URL (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="http://gitlab:80"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use when GitLab runs on the same instance as Dokploy.
|
||||
Used for OAuth token exchange to reach GitLab via
|
||||
internal network (e.g. Docker service name).
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="groupName"
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"use client";
|
||||
|
||||
import { Link2, Loader2, Unlink } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
const LINKING_CALLBACK_URL = "/dashboard/settings/profile";
|
||||
|
||||
const TRUSTED_PROVIDERS = ["google", "github"] as const;
|
||||
type SocialProvider = (typeof TRUSTED_PROVIDERS)[number];
|
||||
|
||||
type AccountItem = {
|
||||
providerId: string;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
function providerLabel(providerId: string): string {
|
||||
return providerId.charAt(0).toUpperCase() + providerId.slice(1);
|
||||
}
|
||||
|
||||
export function LinkingAccount() {
|
||||
const [accounts, setAccounts] = useState<AccountItem[]>([]);
|
||||
const [accountsLoading, setAccountsLoading] = useState(true);
|
||||
const [linkingProvider, setLinkingProvider] = useState<SocialProvider | null>(
|
||||
null,
|
||||
);
|
||||
const [unlinkingProviderId, setUnlinkingProviderId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const fetchAccounts = useCallback(async () => {
|
||||
setAccountsLoading(true);
|
||||
try {
|
||||
const { data } = await authClient.listAccounts();
|
||||
const list = Array.isArray(data)
|
||||
? data
|
||||
: ((data && typeof data === "object" && "accounts" in data
|
||||
? (data as { accounts?: AccountItem[] }).accounts
|
||||
: null) ?? []);
|
||||
setAccounts(Array.isArray(list) ? list : []);
|
||||
} catch {
|
||||
setAccounts([]);
|
||||
} finally {
|
||||
setAccountsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts();
|
||||
}, [fetchAccounts]);
|
||||
|
||||
const linkedProviderIds = new Set(accounts.map((a) => a.providerId));
|
||||
const socialAccounts = accounts.filter((a) =>
|
||||
TRUSTED_PROVIDERS.includes(a.providerId as SocialProvider),
|
||||
);
|
||||
|
||||
const handleLinkSocial = async (provider: SocialProvider) => {
|
||||
setLinkingProvider(provider);
|
||||
try {
|
||||
const { error } = await authClient.linkSocial({
|
||||
provider,
|
||||
callbackURL: LINKING_CALLBACK_URL,
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to link account");
|
||||
setLinkingProvider(null);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
"Failed to link account",
|
||||
err instanceof Error ? { description: err.message } : undefined,
|
||||
);
|
||||
setLinkingProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlink = async (providerId: string, accountId?: string) => {
|
||||
setUnlinkingProviderId(providerId);
|
||||
try {
|
||||
const { error } = await authClient.unlinkAccount({
|
||||
providerId,
|
||||
...(accountId && { accountId }),
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to unlink account");
|
||||
return;
|
||||
}
|
||||
toast.success("Account unlinked");
|
||||
await fetchAccounts();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
"Failed to unlink account",
|
||||
err instanceof Error ? { description: err.message } : undefined,
|
||||
);
|
||||
} finally {
|
||||
setUnlinkingProviderId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const canUnlink = accounts.length > 1;
|
||||
|
||||
return (
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Link2 className="size-6 text-muted-foreground self-center" />
|
||||
Linking account
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Link your Google or GitHub account to sign in with them.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6 py-8 border-t">
|
||||
{/* Linked accounts */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Linked accounts</p>
|
||||
{accountsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : socialAccounts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No social accounts linked yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{socialAccounts.map((acc) => (
|
||||
<li
|
||||
key={acc.accountId ?? acc.providerId}
|
||||
className="flex items-center justify-between rounded-lg border px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{providerLabel(acc.providerId)}
|
||||
</span>
|
||||
{canUnlink && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() =>
|
||||
handleUnlink(acc.providerId, acc.accountId)
|
||||
}
|
||||
disabled={unlinkingProviderId === acc.providerId}
|
||||
isLoading={unlinkingProviderId === acc.providerId}
|
||||
>
|
||||
{unlinkingProviderId === acc.providerId ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Unlink className="mr-1.5 size-4" />
|
||||
Unlink
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click a provider below to link it to your account. You will be
|
||||
redirected to complete the flow.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{!linkedProviderIds.has("google") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="min-w-[180px]"
|
||||
onClick={() => handleLinkSocial("google")}
|
||||
disabled={!!linkingProvider}
|
||||
isLoading={linkingProvider === "google"}
|
||||
>
|
||||
{linkingProvider === "google" ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" className="mr-2 size-4">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
Link with Google
|
||||
</Button>
|
||||
)}
|
||||
{!linkedProviderIds.has("github") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="min-w-[180px]"
|
||||
onClick={() => handleLinkSocial("github")}
|
||||
disabled={!!linkingProvider}
|
||||
isLoading={linkingProvider === "github"}
|
||||
>
|
||||
{linkingProvider === "github" ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="mr-2 size-4"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
)}
|
||||
Link with GitHub
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
LarkIcon,
|
||||
NtfyIcon,
|
||||
PushoverIcon,
|
||||
ResendIcon,
|
||||
SlackIcon,
|
||||
TelegramIcon,
|
||||
} from "@/components/icons/notification-icons";
|
||||
@@ -97,6 +98,23 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
.min(1, { message: "At least one email is required" }),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("resend"),
|
||||
apiKey: z.string().min(1, { message: "API Key is required" }),
|
||||
fromAddress: z
|
||||
.string()
|
||||
.min(1, { message: "From Address is required" })
|
||||
.email({ message: "Email is invalid" }),
|
||||
toAddresses: z
|
||||
.array(
|
||||
z.string().min(1, { message: "Email is required" }).email({
|
||||
message: "Email is invalid",
|
||||
}),
|
||||
)
|
||||
.min(1, { message: "At least one email is required" }),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("gotify"),
|
||||
@@ -169,6 +187,10 @@ export const notificationsMap = {
|
||||
icon: <Mail size={29} className="text-muted-foreground" />,
|
||||
label: "Email",
|
||||
},
|
||||
resend: {
|
||||
icon: <ResendIcon className="text-muted-foreground" />,
|
||||
label: "Resend",
|
||||
},
|
||||
gotify: {
|
||||
icon: <GotifyIcon />,
|
||||
label: "Gotify",
|
||||
@@ -214,6 +236,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
api.notification.testDiscordConnection.useMutation();
|
||||
const { mutateAsync: testEmailConnection, isLoading: isLoadingEmail } =
|
||||
api.notification.testEmailConnection.useMutation();
|
||||
const { mutateAsync: testResendConnection, isLoading: isLoadingResend } =
|
||||
api.notification.testResendConnection.useMutation();
|
||||
const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
|
||||
api.notification.testGotifyConnection.useMutation();
|
||||
const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } =
|
||||
@@ -242,6 +266,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
const emailMutation = notificationId
|
||||
? api.notification.updateEmail.useMutation()
|
||||
: api.notification.createEmail.useMutation();
|
||||
const resendMutation = notificationId
|
||||
? api.notification.updateResend.useMutation()
|
||||
: api.notification.createResend.useMutation();
|
||||
const gotifyMutation = notificationId
|
||||
? api.notification.updateGotify.useMutation()
|
||||
: api.notification.createGotify.useMutation();
|
||||
@@ -281,7 +308,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "email" && fields.length === 0) {
|
||||
if ((type === "email" || type === "resend") && fields.length === 0) {
|
||||
append("");
|
||||
}
|
||||
}, [type, append, fields.length]);
|
||||
@@ -326,7 +353,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
webhookUrl: notification.discord?.webhookUrl,
|
||||
decoration: notification.discord?.decoration || undefined,
|
||||
decoration: notification.discord?.decoration ?? undefined,
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
@@ -349,6 +376,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "resend") {
|
||||
form.reset({
|
||||
appBuildError: notification.appBuildError,
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
apiKey: notification.resend?.apiKey,
|
||||
toAddresses: notification.resend?.toAddresses,
|
||||
fromAddress: notification.resend?.fromAddress,
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "gotify") {
|
||||
form.reset({
|
||||
appBuildError: notification.appBuildError,
|
||||
@@ -358,7 +400,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
appToken: notification.gotify?.appToken,
|
||||
decoration: notification.gotify?.decoration || undefined,
|
||||
decoration: notification.gotify?.decoration ?? undefined,
|
||||
priority: notification.gotify?.priority,
|
||||
serverUrl: notification.gotify?.serverUrl,
|
||||
name: notification.name,
|
||||
@@ -442,6 +484,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
telegram: telegramMutation,
|
||||
discord: discordMutation,
|
||||
email: emailMutation,
|
||||
resend: resendMutation,
|
||||
gotify: gotifyMutation,
|
||||
ntfy: ntfyMutation,
|
||||
lark: larkMutation,
|
||||
@@ -525,6 +568,22 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
emailId: notification?.emailId || "",
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "resend") {
|
||||
promise = resendMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
apiKey: data.apiKey,
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
notificationId: notificationId || "",
|
||||
resendId: notification?.resendId || "",
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "gotify") {
|
||||
promise = gotifyMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
@@ -1042,6 +1101,96 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "resend" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="re_********"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fromAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>From Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="from@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
<FormLabel>To Addresses</FormLabel>
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex flex-row gap-2 w-full"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`toAddresses.${index}`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="email@example.com"
|
||||
className="w-full"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
remove(index);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{type === "resend" &&
|
||||
"toAddresses" in form.formState.errors && (
|
||||
<div className="text-sm font-medium text-destructive">
|
||||
{form.formState?.errors?.toAddresses?.root?.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
append("");
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "gotify" && (
|
||||
<>
|
||||
<FormField
|
||||
@@ -1627,6 +1776,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
isLoadingTelegram ||
|
||||
isLoadingDiscord ||
|
||||
isLoadingEmail ||
|
||||
isLoadingResend ||
|
||||
isLoadingGotify ||
|
||||
isLoadingNtfy ||
|
||||
isLoadingLark ||
|
||||
@@ -1667,6 +1817,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
});
|
||||
} else if (data.type === "resend") {
|
||||
await testResendConnection({
|
||||
apiKey: data.apiKey,
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
});
|
||||
} else if (data.type === "gotify") {
|
||||
await testGotifyConnection({
|
||||
serverUrl: data.serverUrl,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GotifyIcon,
|
||||
LarkIcon,
|
||||
NtfyIcon,
|
||||
ResendIcon,
|
||||
SlackIcon,
|
||||
TelegramIcon,
|
||||
} from "@/components/icons/notification-icons";
|
||||
@@ -36,7 +37,7 @@ export const ShowNotifications = () => {
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add your providers to receive notifications, like Discord, Slack,
|
||||
Telegram, Email, Lark.
|
||||
Telegram, Email, Resend, Lark.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
@@ -86,6 +87,11 @@ export const ShowNotifications = () => {
|
||||
<Mail className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "resend" && (
|
||||
<div className="flex items-center justify-center rounded-lg ">
|
||||
<ResendIcon className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "gotify" && (
|
||||
<div className="flex items-center justify-center rounded-lg ">
|
||||
<GotifyIcon className="size-6" />
|
||||
|
||||
@@ -23,6 +23,8 @@ export const ShowDokployActions = () => {
|
||||
|
||||
const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation();
|
||||
const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation();
|
||||
const { mutateAsync: cleanAllDeploymentQueue } =
|
||||
api.settings.cleanAllDeploymentQueue.useMutation();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -87,6 +89,21 @@ export const ShowDokployActions = () => {
|
||||
Clean Redis
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={async () => {
|
||||
await cleanAllDeploymentQueue()
|
||||
.then(() => {
|
||||
toast.success("Deployment queue cleaned");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error cleaning deployment queue");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clean all deployment queue
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={async () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
||||
import { api } from "@/utils/api";
|
||||
import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
|
||||
import { ManageTraefikPorts } from "../../web-server/manage-traefik-ports";
|
||||
@@ -33,14 +34,45 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
serverId,
|
||||
});
|
||||
|
||||
const {
|
||||
execute: executeWithHealthCheck,
|
||||
isExecuting: isHealthCheckExecuting,
|
||||
} = useHealthCheckAfterMutation({
|
||||
initialDelay: 5000,
|
||||
pollInterval: 4000,
|
||||
successMessage: "Traefik dashboard updated successfully",
|
||||
onSuccess: () => {
|
||||
refetchDashboard();
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
execute: executeReloadWithHealthCheck,
|
||||
isExecuting: isReloadHealthCheckExecuting,
|
||||
} = useHealthCheckAfterMutation({
|
||||
initialDelay: 5000,
|
||||
pollInterval: 4000,
|
||||
successMessage: "Traefik Reloaded",
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
disabled={reloadTraefikIsLoading || toggleDashboardIsLoading}
|
||||
disabled={
|
||||
reloadTraefikIsLoading ||
|
||||
toggleDashboardIsLoading ||
|
||||
isHealthCheckExecuting ||
|
||||
isReloadHealthCheckExecuting
|
||||
}
|
||||
>
|
||||
<Button
|
||||
isLoading={reloadTraefikIsLoading || toggleDashboardIsLoading}
|
||||
isLoading={
|
||||
reloadTraefikIsLoading ||
|
||||
toggleDashboardIsLoading ||
|
||||
isHealthCheckExecuting ||
|
||||
isReloadHealthCheckExecuting
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{t("settings.server.webServer.traefik.label")}
|
||||
@@ -54,15 +86,19 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await reloadTraefik({
|
||||
serverId: serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Traefik Reloaded");
|
||||
})
|
||||
.catch(() => {});
|
||||
try {
|
||||
await executeReloadWithHealthCheck(() =>
|
||||
reloadTraefik({ serverId }),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
(error as Error)?.message ||
|
||||
"Failed to reload Traefik. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
disabled={isReloadHealthCheckExecuting}
|
||||
>
|
||||
<span>{t("settings.server.webServer.reload")}</span>
|
||||
</DropdownMenuItem>
|
||||
@@ -108,24 +144,21 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
</div>
|
||||
}
|
||||
onClick={async () => {
|
||||
await toggleDashboard({
|
||||
enableDashboard: !haveTraefikDashboardPortEnabled,
|
||||
serverId: serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(
|
||||
`${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`,
|
||||
);
|
||||
refetchDashboard();
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error?.message ||
|
||||
"Failed to toggle dashboard. Please check if port 8080 is available.";
|
||||
toast.error(errorMessage);
|
||||
});
|
||||
try {
|
||||
await executeWithHealthCheck(() =>
|
||||
toggleDashboard({
|
||||
enableDashboard: !haveTraefikDashboardPortEnabled,
|
||||
serverId: serverId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
(error as Error)?.message ||
|
||||
"Failed to toggle dashboard. Please check if port 8080 is available.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}}
|
||||
disabled={toggleDashboardIsLoading}
|
||||
disabled={toggleDashboardIsLoading || isHealthCheckExecuting}
|
||||
type="default"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -55,7 +55,7 @@ export const AddInvitation = () => {
|
||||
api.notification.getEmailProviders.useQuery();
|
||||
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||
|
||||
const form = useForm<AddInvitation>({
|
||||
defaultValues: {
|
||||
|
||||
@@ -37,7 +37,7 @@ export const ShowUsers = () => {
|
||||
const { mutateAsync } = api.user.remove.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: session } = api.user.session.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -46,6 +47,14 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.settings.writeTraefikEnv.useMutation();
|
||||
|
||||
const {
|
||||
execute: executeWithHealthCheck,
|
||||
isExecuting: isHealthCheckExecuting,
|
||||
} = useHealthCheckAfterMutation({
|
||||
initialDelay: 5000,
|
||||
successMessage: "Traefik Env Updated",
|
||||
});
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
env: data || "",
|
||||
@@ -63,16 +72,16 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
await mutateAsync({
|
||||
env: data.env,
|
||||
serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Traefik Env Updated");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating the Traefik env");
|
||||
});
|
||||
try {
|
||||
await executeWithHealthCheck(() =>
|
||||
mutateAsync({
|
||||
env: data.env,
|
||||
serverId,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
toast.error("Error updating the Traefik env");
|
||||
}
|
||||
};
|
||||
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
@@ -154,8 +163,8 @@ TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_HTTP_CHALLENGE_DNS_PROVIDER=cloudflare
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
disabled={canEdit || isLoading}
|
||||
isLoading={isLoading || isHealthCheckExecuting}
|
||||
disabled={canEdit || isLoading || isHealthCheckExecuting}
|
||||
form="hook-form-update-server-traefik-config"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
@@ -76,11 +77,19 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
});
|
||||
|
||||
const { mutateAsync: updatePorts, isLoading } =
|
||||
api.settings.updateTraefikPorts.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchPorts();
|
||||
},
|
||||
});
|
||||
api.settings.updateTraefikPorts.useMutation();
|
||||
|
||||
const {
|
||||
execute: executeWithHealthCheck,
|
||||
isExecuting: isHealthCheckExecuting,
|
||||
} = useHealthCheckAfterMutation({
|
||||
initialDelay: 5000,
|
||||
successMessage: t("settings.server.webServer.traefik.portsUpdated"),
|
||||
onSuccess: () => {
|
||||
refetchPorts();
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPorts) {
|
||||
@@ -99,11 +108,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
|
||||
const onSubmit = async (data: TraefikPortsForm) => {
|
||||
try {
|
||||
await updatePorts({
|
||||
serverId,
|
||||
additionalPorts: data.ports,
|
||||
});
|
||||
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
||||
await executeWithHealthCheck(() =>
|
||||
updatePorts({
|
||||
serverId,
|
||||
additionalPorts: data.ports,
|
||||
}),
|
||||
);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || "Error updating Traefik ports");
|
||||
@@ -317,7 +327,7 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
||||
type="submit"
|
||||
variant="default"
|
||||
className="text-sm"
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || isHealthCheckExecuting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
@@ -135,7 +135,9 @@ export const UpdateServer = ({
|
||||
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{dokployVersion} | {releaseTag}
|
||||
{dokployVersion}{" "}
|
||||
{(releaseTag === "canary" || releaseTag === "feature") &&
|
||||
`(${releaseTag})`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ChevronDown, ImagePlus, Loader2, Palette, X } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Logo } from "@/components/shared/logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardDescription, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const formSchema = z.object({
|
||||
appName: z.string().max(256).optional().or(z.literal("")),
|
||||
tagline: z.string().max(512).optional().or(z.literal("")),
|
||||
logoUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
.or(z.literal(""))
|
||||
.refine(
|
||||
(v) =>
|
||||
!v || v.startsWith("data:") || z.string().url().safeParse(v).success,
|
||||
"Must be a valid URL or uploaded image",
|
||||
),
|
||||
faviconUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
.or(z.literal(""))
|
||||
.refine(
|
||||
(v) =>
|
||||
!v || v.startsWith("data:") || z.string().url().safeParse(v).success,
|
||||
"Must be a valid URL or uploaded image",
|
||||
),
|
||||
customCss: z.string().max(8192).optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const MAX_LOGO_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
|
||||
const MAX_FAVICON_SIZE_BYTES = 512 * 1024; // 512KB (favicons are small)
|
||||
|
||||
/** Reference of CSS variables used by the app (from globals.css). Use in :root for light, .dark for dark mode. Values: HSL without hsl() e.g. 220 70% 50% */
|
||||
const CSS_VARIABLES_REFERENCE = `/* General */
|
||||
--background, --foreground
|
||||
--card, --card-foreground
|
||||
--popover, --popover-foreground
|
||||
--primary, --primary-foreground
|
||||
--secondary, --secondary-foreground
|
||||
--muted, --muted-foreground
|
||||
--accent, --accent-foreground
|
||||
--destructive, --destructive-foreground
|
||||
--border, --input, --ring
|
||||
--radius (e.g. 0.5rem)
|
||||
--overlay (e.g. rgba(0,0,0,0.2))
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-background, --sidebar-foreground
|
||||
--sidebar-primary, --sidebar-primary-foreground
|
||||
--sidebar-accent, --sidebar-accent-foreground
|
||||
--sidebar-border, --sidebar-ring
|
||||
|
||||
/* Charts */
|
||||
--chart-1, --chart-2, --chart-3, --chart-4, --chart-5`;
|
||||
|
||||
/** Default theme CSS (mirrors globals.css). Load into editor so user can edit variables without writing from scratch. */
|
||||
const DEFAULT_THEME_CSS = `:root {
|
||||
--terminal-paste: rgba(0, 0, 0, 0.2);
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 50.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 10% 3.9%;
|
||||
--radius: 0.5rem;
|
||||
--overlay: rgba(0, 0, 0, 0.2);
|
||||
--chart-1: 173 58% 39%;
|
||||
--chart-2: 12 76% 61%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--terminal-paste: rgba(255, 255, 255, 0.2);
|
||||
--background: 0 0% 0%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 4% 10%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 4% 10%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 84.2% 50.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 4% 10%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--overlay: rgba(0, 0, 0, 0.5);
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 340 75% 55%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 160 60% 45%;
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
}
|
||||
`;
|
||||
|
||||
export const WhitelabelForm = () => {
|
||||
const logoFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const faviconFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { data, isLoading } = api.whitelabel.get.useQuery();
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, isLoading: isPending } =
|
||||
api.whitelabel.update.useMutation();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
appName: "",
|
||||
tagline: "",
|
||||
logoUrl: "",
|
||||
faviconUrl: "",
|
||||
customCss: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
appName: data.appName ?? "",
|
||||
tagline: data.tagline ?? "",
|
||||
logoUrl: data.logoUrl ?? "",
|
||||
faviconUrl: data.faviconUrl ?? "",
|
||||
customCss: data.customCss ?? "",
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
await mutateAsync({
|
||||
appName: values.appName?.trim() || null,
|
||||
tagline: values.tagline?.trim() || null,
|
||||
logoUrl: values.logoUrl?.trim() || null,
|
||||
faviconUrl: values.faviconUrl?.trim() || null,
|
||||
customCss: values.customCss?.trim() || null,
|
||||
})
|
||||
.then(() => {
|
||||
utils.whitelabel.get.invalidate();
|
||||
toast.success("Whitelabel settings saved");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message ?? "Failed to save");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg border p-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="size-6 text-muted-foreground" />
|
||||
<CardTitle className="text-xl">Whitelabelling</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Customize the app name and logos for your self-hosted instance.
|
||||
These will appear on the login page, sidebar, and browser tab.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="appName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>App name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. My Company DevOps" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Replaces "Dokploy" in the UI when set.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tagline"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tagline</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g. The Open Source alternative to Netlify, Vercel, Heroku."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Quote shown on the login/onboarding side panel. Leave empty
|
||||
for default.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="logoUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Logo</FormLabel>
|
||||
<FormControl>
|
||||
<>
|
||||
{field.value?.startsWith("data:") ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3 bg-muted/30">
|
||||
<Logo
|
||||
className="size-12 shrink-0"
|
||||
logoUrl={field.value}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
Uploaded image
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Image is stored and will appear in sidebar and
|
||||
login.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
logoFileInputRef.current?.click()
|
||||
}
|
||||
>
|
||||
<ImagePlus className="mr-1 size-4" />
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => field.onChange("")}
|
||||
>
|
||||
<X className="mr-1 size-4" />
|
||||
Use URL instead
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="https://example.com/logo.png"
|
||||
{...field}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => logoFileInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus className="mr-2 size-4" />
|
||||
Upload image
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={logoFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_LOGO_SIZE_BYTES) {
|
||||
toast.error("Image size must be less than 2MB");
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const result = event.target?.result as string;
|
||||
field.onChange(result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Paste a logo URL or upload an image (max 2MB). Used in
|
||||
sidebar and login.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="faviconUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Favicon</FormLabel>
|
||||
<FormControl>
|
||||
<>
|
||||
{field.value?.startsWith("data:") ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3 bg-muted/30">
|
||||
{field.value && (
|
||||
// biome-ignore lint/performance/noImgElement: favicon preview from data URL
|
||||
<img
|
||||
src={field.value}
|
||||
alt="Favicon"
|
||||
className="size-8 shrink-0 object-contain"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">
|
||||
Uploaded favicon
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Shown in the browser tab.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
faviconFileInputRef.current?.click()
|
||||
}
|
||||
>
|
||||
<ImagePlus className="mr-1 size-4" />
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => field.onChange("")}
|
||||
>
|
||||
<X className="mr-1 size-4" />
|
||||
Use URL instead
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="https://example.com/favicon.ico"
|
||||
{...field}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => faviconFileInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus className="mr-2 size-4" />
|
||||
Upload image
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={faviconFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FAVICON_SIZE_BYTES) {
|
||||
toast.error(
|
||||
"Favicon size must be less than 512KB",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const result = event.target?.result as string;
|
||||
field.onChange(result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Paste a favicon URL or upload an image (max 512KB). Shown in
|
||||
the browser tab.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customCss"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FormLabel>Custom CSS</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
form.setValue("customCss", DEFAULT_THEME_CSS, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
toast.success("Default theme loaded. Edit and save.");
|
||||
}}
|
||||
>
|
||||
Load default theme
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
language="css"
|
||||
lineWrapping
|
||||
lineNumbers={false}
|
||||
wrapperClassName="min-h-[180px] rounded-md border"
|
||||
placeholder={`:root {
|
||||
--primary: 220 70% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
}
|
||||
.dark {
|
||||
--primary: 220 70% 50%;
|
||||
}`}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional. Override theme colors using CSS variables. Use{" "}
|
||||
<code className="rounded bg-muted px-1">:root</code> for
|
||||
light mode and{" "}
|
||||
<code className="rounded bg-muted px-1">.dark</code> for
|
||||
dark mode. Values in HSL without "hsl()".
|
||||
Don't use quotes around colors (e.g.{" "}
|
||||
<code className="rounded bg-muted px-1">red</code>, not
|
||||
"red"). Max 8KB.
|
||||
</FormDescription>
|
||||
<Collapsible className="mt-2 group">
|
||||
<CollapsibleTrigger className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ChevronDown className="size-4 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
|
||||
Available CSS variables
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-2 rounded-md border bg-muted p-3 text-xs font-mono overflow-x-auto whitespace-pre text-muted-foreground">
|
||||
{CSS_VARIABLES_REFERENCE}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.watch("logoUrl") && (
|
||||
<div className="rounded-lg border p-4 bg-muted/30">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Logo preview
|
||||
</p>
|
||||
<Logo
|
||||
className="size-12"
|
||||
logoUrl={form.watch("logoUrl") || undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -257,3 +257,23 @@ export const PushoverIcon = ({ className }: Props) => {
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const ResendIcon = ({ className }: Props) => {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={cn("size-8", className)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.12" />
|
||||
<path
|
||||
d="M8 17V7h6a3 3 0 0 1 0 6H8m6 0 2 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { GithubIcon } from "../icons/data-tools-icons";
|
||||
import { Logo } from "../shared/logo";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
const DEFAULT_TAGLINE =
|
||||
"The Open Source alternative to Netlify, Vercel, Heroku.";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
export const OnboardingLayout = ({ children }: Props) => {
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery();
|
||||
const appName = whitelabel?.appName ?? "Dokploy";
|
||||
const logoUrl = whitelabel?.logoUrl ?? undefined;
|
||||
const tagline = whitelabel?.tagline ?? DEFAULT_TAGLINE;
|
||||
|
||||
return (
|
||||
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
|
||||
<div className="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex">
|
||||
@@ -17,15 +26,12 @@ export const OnboardingLayout = ({ children }: Props) => {
|
||||
href="https://dokploy.com"
|
||||
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
|
||||
>
|
||||
<Logo className="size-10" />
|
||||
Dokploy
|
||||
<Logo className="size-10" logoUrl={logoUrl} />
|
||||
{appName}
|
||||
</Link>
|
||||
<div className="relative z-20 mt-auto">
|
||||
<blockquote className="space-y-2">
|
||||
<p className="text-lg text-primary">
|
||||
“The Open Source alternative to Netlify, Vercel,
|
||||
Heroku.”
|
||||
</p>
|
||||
<p className="text-lg text-primary">“{tagline}”</p>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,11 +18,15 @@ import {
|
||||
Forward,
|
||||
GalleryVerticalEnd,
|
||||
GitBranch,
|
||||
Key,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
LogIn,
|
||||
type LucideIcon,
|
||||
Package,
|
||||
Palette,
|
||||
PieChart,
|
||||
Rocket,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Star,
|
||||
@@ -143,6 +147,12 @@ const MENU: Menu = {
|
||||
url: "/dashboard/projects",
|
||||
icon: Folder,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Deployments",
|
||||
url: "/dashboard/deployments",
|
||||
icon: Rocket,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Monitoring",
|
||||
@@ -396,6 +406,32 @@ const MENU: Menu = {
|
||||
// Only enabled for admins in cloud environments
|
||||
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "License",
|
||||
url: "/dashboard/settings/license",
|
||||
icon: Key,
|
||||
// Only enabled for admins in non-cloud environments
|
||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Whitelabelling",
|
||||
url: "/dashboard/settings/whitelabelling",
|
||||
icon: Palette,
|
||||
// Proprietary: only in non-cloud, admins only
|
||||
isEnabled: ({ auth, isCloud }) =>
|
||||
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "SSO",
|
||||
url: "/dashboard/settings/sso",
|
||||
icon: LogIn,
|
||||
// Enabled for admins in both cloud and self-hosted (enterprise)
|
||||
isEnabled: ({ auth }) =>
|
||||
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||
},
|
||||
],
|
||||
|
||||
help: [
|
||||
@@ -519,8 +555,11 @@ function LogoWrapper() {
|
||||
function SidebarLogo() {
|
||||
const { state } = useSidebar();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery(undefined, {
|
||||
enabled: !isCloud,
|
||||
});
|
||||
const { data: user } = api.user.get.useQuery();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: session } = api.user.session.useQuery();
|
||||
const {
|
||||
data: organizations,
|
||||
refetch,
|
||||
@@ -531,8 +570,7 @@ function SidebarLogo() {
|
||||
const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } =
|
||||
api.organization.setDefault.useMutation();
|
||||
const { isMobile } = useSidebar();
|
||||
const { data: activeOrganization } = authClient.useActiveOrganization();
|
||||
const _utils = api.useUtils();
|
||||
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||
|
||||
const { data: invitations, refetch: refetchInvitations } =
|
||||
api.user.getInvitations.useQuery();
|
||||
@@ -591,7 +629,11 @@ function SidebarLogo() {
|
||||
"transition-all",
|
||||
state === "collapsed" ? "size-4" : "size-5",
|
||||
)}
|
||||
logoUrl={activeOrganization?.logo || undefined}
|
||||
logoUrl={
|
||||
activeOrganization?.logo ||
|
||||
whitelabel?.logoUrl ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -601,7 +643,9 @@ function SidebarLogo() {
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{activeOrganization?.name ?? "Select Organization"}
|
||||
{activeOrganization?.name ??
|
||||
whitelabel?.appName ??
|
||||
"Select Organization"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -611,135 +655,137 @@ function SidebarLogo() {
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="rounded-lg"
|
||||
className="rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
|
||||
align="start"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground shrink-0">
|
||||
Organizations
|
||||
</DropdownMenuLabel>
|
||||
{organizations?.map((org) => {
|
||||
const isDefault = org.members?.[0]?.isDefault ?? false;
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row justify-between"
|
||||
key={org.name}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await authClient.organization.setActive({
|
||||
organizationId: org.id,
|
||||
});
|
||||
window.location.reload();
|
||||
}}
|
||||
className="w-full gap-2 p-2"
|
||||
<div className="overflow-y-auto overflow-x-hidden min-h-0 -mx-1 px-1">
|
||||
{organizations?.map((org) => {
|
||||
const isDefault = org.members?.[0]?.isDefault ?? false;
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row justify-between"
|
||||
key={org.name}
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{org.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex size-6 items-center justify-center rounded-sm border">
|
||||
<Logo
|
||||
className={cn(
|
||||
"transition-all",
|
||||
state === "collapsed" ? "size-6" : "size-10",
|
||||
)}
|
||||
logoUrl={org.logo ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"group",
|
||||
isDefault
|
||||
? "hover:bg-yellow-500/10"
|
||||
: "hover:bg-blue-500/10",
|
||||
)}
|
||||
isLoading={isSettingDefault && !isDefault}
|
||||
disabled={isDefault}
|
||||
onClick={async (e) => {
|
||||
if (isDefault) return;
|
||||
e.stopPropagation();
|
||||
await setDefaultOrganization({
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await authClient.organization.setActive({
|
||||
organizationId: org.id,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Default organization updated");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
error?.message ||
|
||||
"Error setting default organization",
|
||||
);
|
||||
});
|
||||
});
|
||||
window.location.reload();
|
||||
}}
|
||||
title={
|
||||
isDefault
|
||||
? "Default organization"
|
||||
: "Set as default"
|
||||
}
|
||||
className="w-full gap-2 p-2"
|
||||
>
|
||||
{isDefault ? (
|
||||
<Star
|
||||
fill="#eab308"
|
||||
stroke="#eab308"
|
||||
className="size-4 text-yellow-500"
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{org.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex size-6 items-center justify-center rounded-sm border">
|
||||
<Logo
|
||||
className={cn(
|
||||
"transition-all",
|
||||
state === "collapsed" ? "size-6" : "size-10",
|
||||
)}
|
||||
logoUrl={org.logo ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<Star
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{org.ownerId === session?.user?.id && (
|
||||
<>
|
||||
<AddOrganization organizationId={org.id} />
|
||||
<DialogAction
|
||||
title="Delete Organization"
|
||||
description="Are you sure you want to delete this organization?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteOrganization({
|
||||
organizationId: org.id,
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"group",
|
||||
isDefault
|
||||
? "hover:bg-yellow-500/10"
|
||||
: "hover:bg-blue-500/10",
|
||||
)}
|
||||
isLoading={isSettingDefault && !isDefault}
|
||||
disabled={isDefault}
|
||||
onClick={async (e) => {
|
||||
if (isDefault) return;
|
||||
e.stopPropagation();
|
||||
await setDefaultOrganization({
|
||||
organizationId: org.id,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Default organization updated");
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Organization deleted successfully",
|
||||
);
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
error?.message ||
|
||||
"Error setting default organization",
|
||||
);
|
||||
});
|
||||
}}
|
||||
title={
|
||||
isDefault
|
||||
? "Default organization"
|
||||
: "Set as default"
|
||||
}
|
||||
>
|
||||
{isDefault ? (
|
||||
<Star
|
||||
fill="#eab308"
|
||||
stroke="#eab308"
|
||||
className="size-4 text-yellow-500"
|
||||
/>
|
||||
) : (
|
||||
<Star
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{org.ownerId === session?.user?.id && (
|
||||
<>
|
||||
<AddOrganization organizationId={org.id} />
|
||||
<DialogAction
|
||||
title="Delete Organization"
|
||||
description="Are you sure you want to delete this organization?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteOrganization({
|
||||
organizationId: org.id,
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
error?.message ||
|
||||
"Error deleting organization",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Organization deleted successfully",
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
error?.message ||
|
||||
"Error deleting organization",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(user?.role === "owner" ||
|
||||
user?.role === "admin" ||
|
||||
isCloud) && (
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function SignInWithGithub() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: "github",
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("An error occurred while signing in with GitHub", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="w-full mb-4"
|
||||
onClick={handleClick}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<svg viewBox="0 0 438.549 438.549" className="mr-2 size-4">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with GitHub
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function SignInWithGoogle() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: "google",
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("An error occurred while signing in with Google", {
|
||||
description: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="w-full mb-4"
|
||||
onClick={handleClick}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="mr-2 size-4">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
114
apps/dokploy/components/proprietary/enterprise-feature-gate.tsx
Normal file
114
apps/dokploy/components/proprietary/enterprise-feature-gate.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Lock } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface EnterpriseFeatureLockedProps {
|
||||
/** Optional title override */
|
||||
title?: string;
|
||||
/** Optional description override */
|
||||
description?: string;
|
||||
/** Optional custom CTA label */
|
||||
ctaLabel?: string;
|
||||
/** Optional CTA href (default: /dashboard/settings/license) */
|
||||
ctaHref?: string;
|
||||
/** Compact variant (less padding, smaller icon) */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a locked state for enterprise features when the user has no valid license.
|
||||
* Use standalone or via EnterpriseFeatureGate.
|
||||
*/
|
||||
export function EnterpriseFeatureLocked({
|
||||
title = "Enterprise feature",
|
||||
description = "This feature is part of Dokploy Enterprise. Add a valid license to use it.",
|
||||
ctaLabel = "Go to License",
|
||||
ctaHref = "/dashboard/settings/license",
|
||||
compact = false,
|
||||
}: EnterpriseFeatureLockedProps) {
|
||||
return (
|
||||
<Card className="border-dashed bg-transparent">
|
||||
<CardHeader className={compact ? "pb-2" : undefined}>
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? "rounded-full bg-muted p-3"
|
||||
: "rounded-full bg-muted p-4"
|
||||
}
|
||||
>
|
||||
<Lock
|
||||
className={
|
||||
compact
|
||||
? "size-6 text-muted-foreground"
|
||||
: "size-8 text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg">{title}</CardTitle>
|
||||
<CardDescription className="max-w-sm mx-auto">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className={compact ? "pt-0" : undefined}>
|
||||
<div className="flex justify-center">
|
||||
<Button asChild variant="secondary" size={compact ? "sm" : "default"}>
|
||||
<Link href={ctaHref}>{ctaLabel}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface EnterpriseFeatureGateProps {
|
||||
children: React.ReactNode;
|
||||
/** Props for the locked state when license is invalid */
|
||||
lockedProps?: Omit<EnterpriseFeatureLockedProps, "compact">;
|
||||
/** Show loading spinner while checking license */
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders children only when the instance has a valid enterprise license.
|
||||
* Otherwise shows EnterpriseFeatureLocked.
|
||||
*/
|
||||
export function EnterpriseFeatureGate({
|
||||
children,
|
||||
lockedProps,
|
||||
fallback,
|
||||
}: EnterpriseFeatureGateProps) {
|
||||
const { data: haveValidLicense, isLoading } =
|
||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
if (fallback) return <>{fallback}</>;
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
|
||||
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Checking license...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!haveValidLicense) {
|
||||
return <EnterpriseFeatureLocked {...lockedProps} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
237
apps/dokploy/components/proprietary/license-keys/license-key.tsx
Normal file
237
apps/dokploy/components/proprietary/license-keys/license-key.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { Key, Loader2, ShieldCheck } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export function LicenseKeySettings() {
|
||||
const utils = api.useUtils();
|
||||
const { data, isLoading } = api.licenseKey.getEnterpriseSettings.useQuery();
|
||||
const { mutateAsync: updateEnterpriseSettings, isLoading: isSaving } =
|
||||
api.licenseKey.updateEnterpriseSettings.useMutation();
|
||||
const { mutateAsync: activateLicenseKey, isLoading: isActivating } =
|
||||
api.licenseKey.activate.useMutation();
|
||||
const { mutateAsync: validateLicenseKey, isLoading: isValidating } =
|
||||
api.licenseKey.validate.useMutation();
|
||||
const { mutateAsync: deactivateLicenseKey, isLoading: isDeactivating } =
|
||||
api.licenseKey.deactivate.useMutation();
|
||||
const { data: haveValidLicenseKey, isLoading: isCheckingLicenseKey } =
|
||||
api.licenseKey.haveValidLicenseKey.useQuery();
|
||||
const [licenseKey, setLicenseKey] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.licenseKey) {
|
||||
setLicenseKey(data.licenseKey);
|
||||
}
|
||||
}, [data?.licenseKey]);
|
||||
|
||||
const enabled = !!data?.enableEnterpriseFeatures;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg border p-4">
|
||||
{isCheckingLicenseKey ? (
|
||||
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
|
||||
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Checking license key...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="size-6 text-muted-foreground" />
|
||||
<CardTitle className="text-xl">License Key</CardTitle>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={isLoading || isSaving || isDeactivating}
|
||||
onCheckedChange={async (next) => {
|
||||
try {
|
||||
await updateEnterpriseSettings({
|
||||
enableEnterpriseFeatures: next,
|
||||
});
|
||||
await utils.licenseKey.getEnterpriseSettings.invalidate();
|
||||
toast.success("Enterprise features updated");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to update enterprise features");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
To unlock extra features you need an enterprise license key.
|
||||
Contact us{" "}
|
||||
<Link
|
||||
href="https://dokploy.com/contact"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
here
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
{enabled ? (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="licenseKey">
|
||||
License Key
|
||||
</label>
|
||||
<Input
|
||||
id="licenseKey"
|
||||
placeholder="Enter your enterprise license key"
|
||||
value={licenseKey}
|
||||
onChange={(e) => setLicenseKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:justify-self-end flex gap-2">
|
||||
{haveValidLicenseKey && (
|
||||
<DialogAction
|
||||
title="Deactivate License Key"
|
||||
description="Are you sure you want to deactivate this license key? This will disable enterprise features."
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deactivateLicenseKey();
|
||||
await utils.licenseKey.getEnterpriseSettings.invalidate();
|
||||
await utils.licenseKey.haveValidLicenseKey.invalidate();
|
||||
setLicenseKey("");
|
||||
toast.success("License key deactivated");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to deactivate license key",
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={isDeactivating || !haveValidLicenseKey}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={isDeactivating || !haveValidLicenseKey}
|
||||
isLoading={isDeactivating}
|
||||
>
|
||||
Deactivate
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{haveValidLicenseKey && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={
|
||||
isSaving || isCheckingLicenseKey || isDeactivating
|
||||
}
|
||||
isLoading={isValidating}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const valid = await validateLicenseKey();
|
||||
if (valid) {
|
||||
toast.success("License key is valid");
|
||||
} else {
|
||||
toast.error("License key is invalid");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to validate license key",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Validate
|
||||
</Button>
|
||||
)}
|
||||
{!haveValidLicenseKey && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={
|
||||
isSaving ||
|
||||
isValidating ||
|
||||
isDeactivating ||
|
||||
!licenseKey.trim()
|
||||
}
|
||||
isLoading={isActivating}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await activateLicenseKey({ licenseKey });
|
||||
await utils.licenseKey.getEnterpriseSettings.invalidate();
|
||||
await utils.licenseKey.haveValidLicenseKey.invalidate();
|
||||
toast.success("License key activated");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to activate license key",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 justify-center min-h-[30vh] text-center">
|
||||
<div className="flex flex-col items-center gap-2 max-w-[400px]">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<ShieldCheck className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold">Enterprise Features</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Unlock advanced capabilities like SSO, Audit logs,
|
||||
whitelabeling and more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await updateEnterpriseSettings({
|
||||
enableEnterpriseFeatures: true,
|
||||
});
|
||||
await utils.licenseKey.getEnterpriseSettings.invalidate();
|
||||
toast.success("Enterprise features enabled");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to enable enterprise features");
|
||||
}
|
||||
}}
|
||||
isLoading={isSaving}
|
||||
disabled={isLoading || isDeactivating}
|
||||
>
|
||||
Enable Enterprise Features
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
447
apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx
Normal file
447
apps/dokploy/components/proprietary/sso/register-oidc-dialog.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FieldArrayPath } from "react-hook-form";
|
||||
import { useFieldArray, useForm, useWatch } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { useUrl } from "@/utils/hooks/use-url";
|
||||
|
||||
const DEFAULT_SCOPES = ["openid", "email", "profile"];
|
||||
|
||||
const domainsArraySchema = z
|
||||
.array(z.string().trim())
|
||||
.superRefine((arr, ctx) => {
|
||||
const filled = arr.filter((s) => s.length > 0);
|
||||
if (filled.length < 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one domain is required",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const scopesArraySchema = z.array(z.string().trim());
|
||||
|
||||
const oidcProviderSchema = z.object({
|
||||
providerId: z.string().min(1, "Provider ID is required").trim(),
|
||||
issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(),
|
||||
domains: domainsArraySchema,
|
||||
clientId: z.string().min(1, "Client ID is required").trim(),
|
||||
clientSecret: z.string().min(1, "Client secret is required"),
|
||||
scopes: scopesArraySchema,
|
||||
});
|
||||
|
||||
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
|
||||
|
||||
interface RegisterOidcDialogProps {
|
||||
providerId?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const formDefaultValues = {
|
||||
providerId: "",
|
||||
issuer: "",
|
||||
domains: [""],
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
scopes: [...DEFAULT_SCOPES],
|
||||
};
|
||||
|
||||
function parseOidcConfig(oidcConfig: string | null): {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
scopes?: string[];
|
||||
} | null {
|
||||
if (!oidcConfig) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(oidcConfig) as {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
scopes?: string[];
|
||||
};
|
||||
return {
|
||||
clientId: parsed.clientId,
|
||||
clientSecret: parsed.clientSecret,
|
||||
scopes: Array.isArray(parsed.scopes) ? parsed.scopes : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function RegisterOidcDialog({
|
||||
providerId,
|
||||
children,
|
||||
}: RegisterOidcDialogProps) {
|
||||
const utils = api.useUtils();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data } = api.sso.one.useQuery(
|
||||
{ providerId: providerId ?? "" },
|
||||
{ enabled: !!providerId && open },
|
||||
);
|
||||
const registerMutation = api.sso.register.useMutation();
|
||||
const updateMutation = api.sso.update.useMutation();
|
||||
|
||||
const isEdit = !!providerId;
|
||||
const mutateAsync = isEdit
|
||||
? updateMutation.mutateAsync
|
||||
: registerMutation.mutateAsync;
|
||||
const isLoading = isEdit
|
||||
? updateMutation.isLoading
|
||||
: registerMutation.isLoading;
|
||||
|
||||
const form = useForm<OidcProviderForm>({
|
||||
resolver: zodResolver(oidcProviderSchema),
|
||||
defaultValues: formDefaultValues,
|
||||
});
|
||||
|
||||
const watchedProviderId = useWatch({
|
||||
control: form.control,
|
||||
name: "providerId",
|
||||
defaultValue: "",
|
||||
});
|
||||
|
||||
const baseURL = useUrl();
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !open) return;
|
||||
const domains = data.domain
|
||||
? data.domain
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean)
|
||||
: [""];
|
||||
if (domains.length === 0) domains.push("");
|
||||
const oidc = parseOidcConfig(data.oidcConfig);
|
||||
form.reset({
|
||||
providerId: data.providerId,
|
||||
issuer: data.issuer,
|
||||
domains,
|
||||
clientId: oidc?.clientId ?? "",
|
||||
clientSecret: oidc?.clientSecret ?? "",
|
||||
scopes:
|
||||
oidc?.scopes && oidc.scopes.length > 0
|
||||
? oidc.scopes
|
||||
: [...DEFAULT_SCOPES],
|
||||
});
|
||||
}, [data, open, form]);
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "domains" as FieldArrayPath<OidcProviderForm>,
|
||||
});
|
||||
|
||||
const {
|
||||
fields: scopeFields,
|
||||
append: appendScope,
|
||||
remove: removeScope,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "scopes" as FieldArrayPath<OidcProviderForm>,
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const onSubmit = async (data: OidcProviderForm) => {
|
||||
try {
|
||||
const scopes = data.scopes.filter(Boolean).length
|
||||
? data.scopes.filter(Boolean)
|
||||
: DEFAULT_SCOPES;
|
||||
|
||||
const isAzure = data.issuer.includes("login.microsoftonline.com");
|
||||
const mapping = isAzure
|
||||
? {
|
||||
id: "sub",
|
||||
email: "preferred_username",
|
||||
emailVerified: "email_verified",
|
||||
name: "name",
|
||||
}
|
||||
: {
|
||||
id: "sub",
|
||||
email: "email",
|
||||
emailVerified: "email_verified",
|
||||
name: "preferred_username",
|
||||
image: "picture",
|
||||
};
|
||||
await mutateAsync({
|
||||
providerId: data.providerId,
|
||||
issuer: data.issuer,
|
||||
domains: data.domains,
|
||||
oidcConfig: {
|
||||
clientId: data.clientId,
|
||||
clientSecret: data.clientSecret,
|
||||
scopes,
|
||||
pkce: true,
|
||||
mapping,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success(
|
||||
isEdit
|
||||
? "OIDC provider updated successfully"
|
||||
: "OIDC provider registered successfully",
|
||||
);
|
||||
form.reset(formDefaultValues);
|
||||
setOpen(false);
|
||||
await utils.sso.listProviders.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to register SSO provider",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? "Update OIDC provider" : "Register OIDC provider"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Change issuer, domains, client settings or scopes. Provider ID cannot be changed."
|
||||
: "Add any OIDC-compliant identity provider (e.g. Okta, Azure AD, Google Workspace, Auth0, Keycloak). Discovery will fill endpoints from the issuer URL when possible."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g. okta or my-idp"
|
||||
{...field}
|
||||
readOnly={isEdit}
|
||||
className={isEdit ? "bg-muted" : undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Unique identifier; used in callback URL path.
|
||||
{isEdit && " Cannot be changed when editing."}
|
||||
</FormDescription>
|
||||
{baseURL && (
|
||||
<div className="rounded-md bg-muted px-3 py-2 text-xs">
|
||||
<p className="font-medium text-muted-foreground">
|
||||
Callback URL (configure in your IdP)
|
||||
</p>
|
||||
<p className="mt-0.5 break-all font-mono">
|
||||
{baseURL}/api/auth/sso/callback/
|
||||
{watchedProviderId?.trim() || "..."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="issuer"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Issuer URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://idp.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Discovery document is fetched from{" "}
|
||||
<code className="rounded bg-muted px-1">
|
||||
{"{issuer}"}/.well-known/openid-configuration
|
||||
</code>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Domains</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => (append as (value: string) => void)("")}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
Add domain
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Email domains that use this provider (sign-in by email and org
|
||||
assignment; subdomains matched automatically).
|
||||
</p>
|
||||
{fields.map((field, index) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
control={form.control}
|
||||
name={`domains.${index}`}
|
||||
render={({ field: inputField }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="company.com"
|
||||
className="flex-1"
|
||||
{...inputField}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
disabled={fields.length <= 1}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
const err = form.formState.errors.domains;
|
||||
const msg =
|
||||
typeof err?.message === "string"
|
||||
? err.message
|
||||
: (err as { root?: { message?: string } } | undefined)?.root
|
||||
?.message;
|
||||
return msg ? (
|
||||
<p className="text-sm font-medium text-destructive">{msg}</p>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Client ID from IdP" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client secret</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Client secret from IdP"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Scopes (optional)</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => (appendScope as (value: string) => void)("")}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
Add scope
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription>
|
||||
OIDC scopes to request (e.g. openid, email, profile). If empty,
|
||||
openid, email and profile are used.
|
||||
</FormDescription>
|
||||
{scopeFields.map((field, index) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
control={form.control}
|
||||
name={`scopes.${index}`}
|
||||
render={({ field: inputField }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="openid"
|
||||
className="flex-1"
|
||||
{...inputField}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeScope(index)}
|
||||
disabled={scopeFields.length <= 1}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
{isEdit ? "Update provider" : "Register provider"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
419
apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx
Normal file
419
apps/dokploy/components/proprietary/sso/register-saml-dialog.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type FieldArrayPath,
|
||||
useFieldArray,
|
||||
useForm,
|
||||
useWatch,
|
||||
} from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { useUrl } from "@/utils/hooks/use-url";
|
||||
|
||||
const domainsArraySchema = z
|
||||
.array(z.string().trim())
|
||||
.superRefine((arr, ctx) => {
|
||||
const filled = arr.filter((s) => s.length > 0);
|
||||
if (filled.length < 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one domain is required",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const samlProviderSchema = z.object({
|
||||
providerId: z.string().min(1, "Provider ID is required").trim(),
|
||||
issuer: z.string().min(1, "Issuer URL is required").url("Invalid URL").trim(),
|
||||
domains: domainsArraySchema,
|
||||
entryPoint: z
|
||||
.string()
|
||||
.min(1, "IdP SSO URL is required")
|
||||
.url("Invalid URL")
|
||||
.trim(),
|
||||
cert: z.string().min(1, "IdP signing certificate is required"),
|
||||
idpMetadataXml: z.string().optional(),
|
||||
});
|
||||
|
||||
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
|
||||
|
||||
interface RegisterSamlDialogProps {
|
||||
providerId?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const formDefaultValues: SamlProviderForm = {
|
||||
providerId: "",
|
||||
issuer: "",
|
||||
domains: [""],
|
||||
entryPoint: "",
|
||||
cert: "",
|
||||
idpMetadataXml: "",
|
||||
};
|
||||
|
||||
function parseSamlConfig(samlConfig: string | null): {
|
||||
entryPoint?: string;
|
||||
cert?: string;
|
||||
idpMetadataXml?: string;
|
||||
} | null {
|
||||
if (!samlConfig) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(samlConfig) as {
|
||||
entryPoint?: string;
|
||||
cert?: string;
|
||||
idpMetadata?: { metadata?: string };
|
||||
};
|
||||
return {
|
||||
entryPoint: parsed.entryPoint,
|
||||
cert: parsed.cert,
|
||||
idpMetadataXml: parsed.idpMetadata?.metadata,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function RegisterSamlDialog({
|
||||
providerId,
|
||||
children,
|
||||
}: RegisterSamlDialogProps) {
|
||||
const utils = api.useUtils();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data } = api.sso.one.useQuery(
|
||||
{ providerId: providerId ?? "" },
|
||||
{ enabled: !!providerId && open },
|
||||
);
|
||||
const registerMutation = api.sso.register.useMutation();
|
||||
const updateMutation = api.sso.update.useMutation();
|
||||
|
||||
const isEdit = !!providerId;
|
||||
const mutateAsync = isEdit
|
||||
? updateMutation.mutateAsync
|
||||
: registerMutation.mutateAsync;
|
||||
const isLoading = isEdit
|
||||
? updateMutation.isLoading
|
||||
: registerMutation.isLoading;
|
||||
|
||||
const baseURL = useUrl();
|
||||
|
||||
const form = useForm<SamlProviderForm>({
|
||||
resolver: zodResolver(samlProviderSchema),
|
||||
defaultValues: formDefaultValues,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data || !open) return;
|
||||
const domains = data.domain
|
||||
? data.domain
|
||||
.split(",")
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean)
|
||||
: [""];
|
||||
if (domains.length === 0) domains.push("");
|
||||
const saml = parseSamlConfig(data.samlConfig);
|
||||
form.reset({
|
||||
providerId: data.providerId,
|
||||
issuer: data.issuer,
|
||||
domains,
|
||||
entryPoint: saml?.entryPoint ?? "",
|
||||
cert: saml?.cert ?? "",
|
||||
idpMetadataXml: saml?.idpMetadataXml ?? "",
|
||||
});
|
||||
}, [data, open, form]);
|
||||
|
||||
const watchedProviderId = useWatch({
|
||||
control: form.control,
|
||||
name: "providerId",
|
||||
defaultValue: "",
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "domains" as FieldArrayPath<SamlProviderForm>,
|
||||
});
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const onSubmit = async (data: SamlProviderForm) => {
|
||||
try {
|
||||
// maybe add the /saml/metadata endpoint to the baseURL
|
||||
const baseURLWithMetadata = `${baseURL}/saml/metadata`;
|
||||
const generateSpMetadata = (providerId: string) => {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="${baseURL}">
|
||||
<md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="${baseURL}/api/auth/sso/saml2/callback/${providerId}" index="1"/>
|
||||
</md:SPSSODescriptor>
|
||||
</md:EntityDescriptor>`;
|
||||
};
|
||||
|
||||
await mutateAsync({
|
||||
providerId: data.providerId,
|
||||
issuer: data.issuer,
|
||||
domains: data.domains,
|
||||
samlConfig: {
|
||||
entryPoint: data.entryPoint,
|
||||
cert: data.cert,
|
||||
callbackUrl: `${baseURL}/api/auth/sso/saml2/callback/${data.providerId}`,
|
||||
audience: baseURL,
|
||||
idpMetadata: data.idpMetadataXml?.trim()
|
||||
? { metadata: data.idpMetadataXml.trim() }
|
||||
: undefined,
|
||||
spMetadata: {
|
||||
metadata: generateSpMetadata(data.providerId),
|
||||
},
|
||||
mapping: {
|
||||
id: "nameID",
|
||||
email: "email",
|
||||
name: "displayName",
|
||||
firstName: "givenName",
|
||||
lastName: "surname",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
toast.success(
|
||||
isEdit
|
||||
? "SAML provider updated successfully"
|
||||
: "SAML provider registered successfully",
|
||||
);
|
||||
form.reset(formDefaultValues);
|
||||
setOpen(false);
|
||||
await utils.sso.listProviders.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to register SAML provider",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? "Update SAML provider" : "Register SAML provider"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? "Change issuer, domains, entry point or certificate. Provider ID cannot be changed."
|
||||
: "Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML, OneLogin). You need the IdP's SSO URL and signing certificate."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="e.g. okta-saml or azure-saml"
|
||||
{...field}
|
||||
readOnly={isEdit}
|
||||
className={isEdit ? "bg-muted" : undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
{isEdit && (
|
||||
<FormDescription>
|
||||
Cannot be changed when editing.
|
||||
</FormDescription>
|
||||
)}
|
||||
{baseURL && (
|
||||
<div className="rounded-md bg-muted px-3 py-2 text-xs">
|
||||
<p className="font-medium text-muted-foreground">
|
||||
Callback URL (configure in your IdP)
|
||||
</p>
|
||||
<p className="mt-0.5 break-all font-mono">
|
||||
{baseURL}/api/auth/sso/saml2/callback/
|
||||
{watchedProviderId?.trim() || "..."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="issuer"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Issuer URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://idp.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Domains</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => append("")}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
Add domain
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Email domains that use this provider (sign-in by email and org
|
||||
assignment; subdomains matched automatically).
|
||||
</FormDescription>
|
||||
{fields.map((field, index) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
control={form.control}
|
||||
name={`domains.${index}`}
|
||||
render={({ field: inputField }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="company.com"
|
||||
className="flex-1"
|
||||
{...inputField}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
disabled={fields.length <= 1}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
const err = form.formState.errors.domains;
|
||||
const msg =
|
||||
typeof err?.message === "string"
|
||||
? err.message
|
||||
: (err as { root?: { message?: string } } | undefined)?.root
|
||||
?.message;
|
||||
return msg ? (
|
||||
<p className="text-sm font-medium text-destructive">{msg}</p>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="entryPoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IdP SSO URL (Entry point)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://idp.example.com/sso"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Single Sign-On URL from your IdP's SAML setup.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cert"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IdP signing certificate (X.509)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Paste IdP signing certificate (PEM, BEGIN CERTIFICATE / END CERTIFICATE)"
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="idpMetadataXml"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IdP metadata XML (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Paste full IdP metadata XML if you have it (EntityDescriptor). Otherwise leave empty and use Issuer, IdP SSO URL and certificate above."
|
||||
rows={5}
|
||||
className="font-mono text-xs"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Some IdPs require full metadata; paste the XML here to
|
||||
override issuer/entry point/cert.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
{isEdit ? "Update provider" : "Register provider"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
127
apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx
Normal file
127
apps/dokploy/components/proprietary/sso/sign-in-with-sso.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2, LogIn } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
const ssoEmailSchema = z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, "Enter your work email")
|
||||
.email("Enter a valid email address")
|
||||
.transform((v) => v.trim()),
|
||||
});
|
||||
|
||||
type SSOEmailForm = z.infer<typeof ssoEmailSchema>;
|
||||
|
||||
interface SignInWithSSOProps {
|
||||
/** Content shown when SSO is collapsed (e.g. email/password form) */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SignInWithSSO({ children }: SignInWithSSOProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const form = useForm<SSOEmailForm>({
|
||||
resolver: zodResolver(ssoEmailSchema),
|
||||
defaultValues: { email: "" },
|
||||
});
|
||||
|
||||
const onSubmit = async (values: SSOEmailForm) => {
|
||||
try {
|
||||
const { data, error } = await authClient.signIn.sso({
|
||||
email: values.email,
|
||||
callbackURL: "/dashboard/projects",
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to sign in with SSO");
|
||||
return;
|
||||
}
|
||||
if (data?.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to sign in with SSO",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<div className="mb-4 space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 space-y-2">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="you@company.com"
|
||||
className="flex-1"
|
||||
autoComplete="email"
|
||||
disabled={form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Continue"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
Use email and password instead
|
||||
</button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
597
apps/dokploy/components/proprietary/sso/sso-settings.tsx
Normal file
597
apps/dokploy/components/proprietary/sso/sso-settings.tsx
Normal file
@@ -0,0 +1,597 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Eye,
|
||||
Loader2,
|
||||
LogIn,
|
||||
Pencil,
|
||||
Plus,
|
||||
Shield,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { useUrl } from "@/utils/hooks/use-url";
|
||||
import { RegisterOidcDialog } from "./register-oidc-dialog";
|
||||
import { RegisterSamlDialog } from "./register-saml-dialog";
|
||||
|
||||
type ProviderForDetails = {
|
||||
id: string | null;
|
||||
providerId: string;
|
||||
issuer: string;
|
||||
domain: string;
|
||||
oidcConfig: string | null;
|
||||
samlConfig: string | null;
|
||||
organizationId: string | null;
|
||||
};
|
||||
|
||||
function parseOidcConfig(config: string | null): {
|
||||
clientId?: string;
|
||||
scopes?: string[];
|
||||
} | null {
|
||||
if (!config) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(config) as {
|
||||
clientId?: string;
|
||||
scopes?: string[];
|
||||
};
|
||||
return { clientId: parsed.clientId, scopes: parsed.scopes };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSamlConfig(
|
||||
config: string | null,
|
||||
): { entryPoint?: string } | null {
|
||||
if (!config) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(config) as { entryPoint?: string };
|
||||
return { entryPoint: parsed.entryPoint };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const SSOSettings = () => {
|
||||
const utils = api.useUtils();
|
||||
const [detailsProvider, setDetailsProvider] =
|
||||
useState<ProviderForDetails | null>(null);
|
||||
const baseURL = useUrl();
|
||||
const [manageOriginsOpen, setManageOriginsOpen] = useState(false);
|
||||
const [editingOrigin, setEditingOrigin] = useState<string | null>(null);
|
||||
const [editingValue, setEditingValue] = useState("");
|
||||
const [newOriginInput, setNewOriginInput] = useState("");
|
||||
|
||||
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
|
||||
const { data: userData } = api.user.get.useQuery(undefined, {
|
||||
enabled: manageOriginsOpen,
|
||||
});
|
||||
const { mutateAsync: deleteProvider, isLoading: isDeleting } =
|
||||
api.sso.deleteProvider.useMutation();
|
||||
const { mutateAsync: addTrustedOrigin, isLoading: isAddingOrigin } =
|
||||
api.sso.addTrustedOrigin.useMutation();
|
||||
const { mutateAsync: removeTrustedOrigin, isLoading: isRemovingOrigin } =
|
||||
api.sso.removeTrustedOrigin.useMutation();
|
||||
const { mutateAsync: updateTrustedOrigin, isLoading: isUpdatingOrigin } =
|
||||
api.sso.updateTrustedOrigin.useMutation();
|
||||
|
||||
const trustedOrigins = userData?.user?.trustedOrigins ?? [];
|
||||
|
||||
const handleAddOrigin = async () => {
|
||||
const value = newOriginInput.trim();
|
||||
if (!value) return;
|
||||
try {
|
||||
await addTrustedOrigin({ origin: value });
|
||||
toast.success("Trusted origin added");
|
||||
setNewOriginInput("");
|
||||
await utils.user.get.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to add trusted origin",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveOrigin = async (origin: string) => {
|
||||
try {
|
||||
await removeTrustedOrigin({ origin });
|
||||
toast.success("Trusted origin removed");
|
||||
if (editingOrigin === origin) setEditingOrigin(null);
|
||||
await utils.user.get.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to remove trusted origin",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEdit = (origin: string) => {
|
||||
setEditingOrigin(origin);
|
||||
setEditingValue(origin);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (editingOrigin == null || !editingValue.trim()) {
|
||||
setEditingOrigin(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateTrustedOrigin({
|
||||
oldOrigin: editingOrigin,
|
||||
newOrigin: editingValue.trim(),
|
||||
});
|
||||
toast.success("Trusted origin updated");
|
||||
setEditingOrigin(null);
|
||||
setEditingValue("");
|
||||
await utils.user.get.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to update trusted origin",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingOrigin(null);
|
||||
setEditingValue("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg border p-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<LogIn className="size-6 text-muted-foreground" />
|
||||
<CardTitle className="text-xl">Single Sign-On (SSO)</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Configure OIDC or SAML identity providers for enterprise sign-in.
|
||||
Users can sign in with their organization's IdP.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setManageOriginsOpen(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Shield className="mr-2 size-4" />
|
||||
Manage origins
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 justify-center min-h-[25vh]">
|
||||
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Loading providers...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{providers && providers.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<RegisterOidcDialog>
|
||||
<Button variant="secondary" size="sm">
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Add OIDC provider
|
||||
</Button>
|
||||
</RegisterOidcDialog>
|
||||
<RegisterSamlDialog>
|
||||
<Button variant="secondary" size="sm">
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Add SAML provider
|
||||
</Button>
|
||||
</RegisterSamlDialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{providers && providers.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<span className="text-sm font-medium">Registered providers</span>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{providers.map((provider) => {
|
||||
const isOidc = !!provider.oidcConfig;
|
||||
const isSaml = !!provider.samlConfig;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={provider.id}
|
||||
className="overflow-hidden bg-background"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-base font-medium">
|
||||
{provider.providerId}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
{provider.issuer}
|
||||
</CardDescription>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{provider.domain}
|
||||
</Badge>
|
||||
{isOidc && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
OIDC
|
||||
</Badge>
|
||||
)}
|
||||
{isSaml && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
SAML
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2 pt-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setDetailsProvider({
|
||||
id: provider.id,
|
||||
providerId: provider.providerId,
|
||||
issuer: provider.issuer,
|
||||
domain: provider.domain,
|
||||
oidcConfig: provider.oidcConfig,
|
||||
samlConfig: provider.samlConfig,
|
||||
organizationId: provider.organizationId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Eye className="mr-1 size-3" />
|
||||
View details
|
||||
</Button>
|
||||
{isOidc && (
|
||||
<RegisterOidcDialog providerId={provider.providerId}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Pencil className="mr-1 size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
</RegisterOidcDialog>
|
||||
)}
|
||||
{isSaml && (
|
||||
<RegisterSamlDialog providerId={provider.providerId}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Pencil className="mr-1 size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
</RegisterSamlDialog>
|
||||
)}
|
||||
<DialogAction
|
||||
title="Remove SSO provider"
|
||||
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteProvider({
|
||||
providerId: provider.providerId,
|
||||
});
|
||||
toast.success("Provider removed");
|
||||
await utils.sso.listProviders.invalidate();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to remove provider",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="mr-1 size-3" />
|
||||
Remove
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 justify-center min-h-[30vh] text-center">
|
||||
<div className="flex flex-col items-center gap-2 max-w-[400px]">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<LogIn className="size-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold">No SSO providers</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add an OIDC or SAML provider so users can sign in with their
|
||||
organization's IdP (e.g. Okta, Azure AD).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
<RegisterOidcDialog>
|
||||
<Button variant="secondary">
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Add OIDC provider
|
||||
</Button>
|
||||
</RegisterOidcDialog>
|
||||
<RegisterSamlDialog>
|
||||
<Button variant="outline">
|
||||
<LogIn className="mr-2 size-4" />
|
||||
Add SAML provider
|
||||
</Button>
|
||||
</RegisterSamlDialog>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={!!detailsProvider}
|
||||
onOpenChange={(open) => !open && setDetailsProvider(null)}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
{detailsProvider && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>SSO provider details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Use Edit to change provider settings (OIDC or SAML).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 py-2">
|
||||
<div className="grid gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Provider ID
|
||||
</span>
|
||||
<p className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm">
|
||||
{detailsProvider.providerId}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Issuer URL
|
||||
</span>
|
||||
<p className="break-all rounded-md bg-muted px-2 py-1.5 text-sm">
|
||||
{detailsProvider.issuer}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Domain
|
||||
</span>
|
||||
<p className="rounded-md bg-muted px-2 py-1.5 text-sm">
|
||||
{detailsProvider.domain}
|
||||
</p>
|
||||
</div>
|
||||
{detailsProvider.oidcConfig && (
|
||||
<>
|
||||
{(() => {
|
||||
const oidc = parseOidcConfig(detailsProvider.oidcConfig);
|
||||
if (!oidc) return null;
|
||||
return (
|
||||
<>
|
||||
{oidc.clientId && (
|
||||
<div className="grid gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Client ID
|
||||
</span>
|
||||
<p className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm">
|
||||
{oidc.clientId}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{oidc.scopes && oidc.scopes.length > 0 && (
|
||||
<div className="grid gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Scopes
|
||||
</span>
|
||||
<p className="rounded-md bg-muted px-2 py-1.5 text-sm">
|
||||
{oidc.scopes.join(" ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
{detailsProvider.samlConfig && (
|
||||
<>
|
||||
{(() => {
|
||||
const saml = parseSamlConfig(detailsProvider.samlConfig);
|
||||
if (!saml?.entryPoint) return null;
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Entry point
|
||||
</span>
|
||||
<p className="break-all rounded-md bg-muted px-2 py-1.5 text-sm">
|
||||
{saml.entryPoint}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
<div className="grid gap-1">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Callback URL (configure in your IdP)
|
||||
</span>
|
||||
<p className="break-all rounded-md bg-muted px-2 py-1.5 font-mono text-xs">
|
||||
{baseURL || "{baseURL}"}
|
||||
{detailsProvider.samlConfig
|
||||
? "/api/auth/sso/saml2/callback/"
|
||||
: "/api/auth/sso/callback/"}
|
||||
{detailsProvider.providerId}
|
||||
</p>
|
||||
{!baseURL && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Replace {"{baseURL}"} with your Dokploy URL (e.g. https://
|
||||
your-domain.com).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDetailsProvider(null)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={manageOriginsOpen} onOpenChange={setManageOriginsOpen}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="size-5" />
|
||||
Trusted origins
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage allowed origins for SSO callbacks. Add, edit, or remove
|
||||
origins for your account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">Current origins</span>
|
||||
{trustedOrigins.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed bg-muted/30 px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
No trusted origins yet. Add one below.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{trustedOrigins.map((origin) => (
|
||||
<li
|
||||
key={origin}
|
||||
className="flex items-center gap-2 rounded-md border bg-muted/30 px-3 py-2"
|
||||
>
|
||||
{editingOrigin === origin ? (
|
||||
<>
|
||||
<Input
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
placeholder="https://..."
|
||||
className="flex-1 font-mono text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveEdit}
|
||||
disabled={!editingValue.trim() || isUpdatingOrigin}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleCancelEdit}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 break-all font-mono text-sm">
|
||||
{origin}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 shrink-0"
|
||||
onClick={() => handleStartEdit(origin)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<DialogAction
|
||||
title="Remove trusted origin"
|
||||
description={`Remove "${origin}" from trusted origins?`}
|
||||
type="destructive"
|
||||
onClick={async () => handleRemoveOrigin(origin)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 shrink-0 text-destructive hover:text-destructive"
|
||||
disabled={isRemovingOrigin}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm font-medium">Add trusted origin</span>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newOriginInput}
|
||||
onChange={(e) => setNewOriginInput(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
className="font-mono text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleAddOrigin();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAddOrigin}
|
||||
disabled={!newOriginInput.trim() || isAddingOrigin}
|
||||
>
|
||||
<Plus className="mr-1 size-4" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setManageOriginsOpen(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
27
apps/dokploy/components/proprietary/whitelabel-head.tsx
Normal file
27
apps/dokploy/components/proprietary/whitelabel-head.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import Head from "next/head";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
/** When whitelabel is configured (non-cloud), overrides document title, favicon, and optional custom CSS. */
|
||||
export function WhitelabelHead() {
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery();
|
||||
|
||||
// Always render so we can override favicon (same key as _app default replaces it)
|
||||
return (
|
||||
<Head>
|
||||
{whitelabel?.appName && <title>{whitelabel.appName}</title>}
|
||||
{whitelabel?.faviconUrl ? (
|
||||
<link rel="icon" href={whitelabel.faviconUrl} key="favicon" />
|
||||
) : (
|
||||
<link rel="icon" href="/icon.svg" key="favicon" />
|
||||
)}
|
||||
{whitelabel?.customCss?.trim() && (
|
||||
<style
|
||||
id="whitelabel-custom-css"
|
||||
dangerouslySetInnerHTML={{ __html: whitelabel.customCss.trim() }}
|
||||
/>
|
||||
)}
|
||||
</Head>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
type CompletionContext,
|
||||
type CompletionResult,
|
||||
} from "@codemirror/autocomplete";
|
||||
import { css } from "@codemirror/lang-css";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { yaml } from "@codemirror/lang-yaml";
|
||||
import { StreamLanguage } from "@codemirror/language";
|
||||
import { properties } from "@codemirror/legacy-modes/mode/properties";
|
||||
import { shell } from "@codemirror/legacy-modes/mode/shell";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { search, searchKeymap } from "@codemirror/search";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
|
||||
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
|
||||
import { useTheme } from "next-themes";
|
||||
@@ -130,7 +132,7 @@ function dockerComposeComplete(
|
||||
interface Props extends ReactCodeMirrorProps {
|
||||
wrapperClassName?: string;
|
||||
disabled?: boolean;
|
||||
language?: "yaml" | "json" | "properties" | "shell";
|
||||
language?: "yaml" | "json" | "properties" | "shell" | "css";
|
||||
lineWrapping?: boolean;
|
||||
lineNumbers?: boolean;
|
||||
}
|
||||
@@ -155,6 +157,8 @@ export const CodeEditor = ({
|
||||
}}
|
||||
theme={resolvedTheme === "dark" ? githubDark : githubLight}
|
||||
extensions={[
|
||||
search(),
|
||||
keymap.of(searchKeymap),
|
||||
language === "yaml"
|
||||
? yaml()
|
||||
: language === "json"
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
logoUrl?: string;
|
||||
}
|
||||
|
||||
export const Logo = ({ className = "size-14", logoUrl }: Props) => {
|
||||
if (logoUrl) {
|
||||
export const Logo = ({ className = "size-14" }: Props) => {
|
||||
const { data: whitelabel } = api.whitelabel.get.useQuery();
|
||||
if (whitelabel?.logoUrl) {
|
||||
return (
|
||||
<img
|
||||
src={logoUrl}
|
||||
src={whitelabel?.logoUrl}
|
||||
alt="Organization Logo"
|
||||
className={cn(className, "object-contain rounded-sm")}
|
||||
/>
|
||||
|
||||
18
apps/dokploy/drizzle/0137_colossal_sally_floyd.sql
Normal file
18
apps/dokploy/drizzle/0137_colossal_sally_floyd.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE "sso_provider" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"issuer" text NOT NULL,
|
||||
"oidc_config" text,
|
||||
"saml_config" text,
|
||||
"provider_id" text NOT NULL,
|
||||
"user_id" text,
|
||||
"organization_id" text,
|
||||
"domain" text NOT NULL,
|
||||
CONSTRAINT "sso_provider_provider_id_unique" UNIQUE("provider_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "enableEnterpriseFeatures" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "licenseKey" text;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "isValidEnterpriseLicense" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD COLUMN "trustedOrigins" text[];--> statement-breakpoint
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sso_provider" ADD CONSTRAINT "sso_provider_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
|
||||
10
apps/dokploy/drizzle/0138_pretty_ironclad.sql
Normal file
10
apps/dokploy/drizzle/0138_pretty_ironclad.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
ALTER TYPE "public"."notificationType" ADD VALUE 'resend' BEFORE 'gotify';--> statement-breakpoint
|
||||
CREATE TABLE "resend" (
|
||||
"resendId" text PRIMARY KEY NOT NULL,
|
||||
"apiKey" text NOT NULL,
|
||||
"fromAddress" text NOT NULL,
|
||||
"toAddress" text[] NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD COLUMN "resendId" text;--> statement-breakpoint
|
||||
ALTER TABLE "notification" ADD CONSTRAINT "notification_resendId_resend_resendId_fk" FOREIGN KEY ("resendId") REFERENCES "public"."resend"("resendId") ON DELETE cascade ON UPDATE no action;
|
||||
1
apps/dokploy/drizzle/0139_brave_bloodstorm.sql
Normal file
1
apps/dokploy/drizzle/0139_brave_bloodstorm.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "invitation" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;
|
||||
1
apps/dokploy/drizzle/0140_lame_mattie_franklin.sql
Normal file
1
apps/dokploy/drizzle/0140_lame_mattie_franklin.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "gitlab" ADD COLUMN "gitlabInternalUrl" text;
|
||||
1
apps/dokploy/drizzle/0141_plain_earthquake.sql
Normal file
1
apps/dokploy/drizzle/0141_plain_earthquake.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "gitea" ADD COLUMN "giteaInternalUrl" text;
|
||||
6
apps/dokploy/drizzle/0142_outstanding_tusk.sql
Normal file
6
apps/dokploy/drizzle/0142_outstanding_tusk.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "application" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "mariadb" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "mongo" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "mysql" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "postgres" ADD COLUMN "ulimitsSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "redis" ADD COLUMN "ulimitsSwarm" json;
|
||||
1
apps/dokploy/drizzle/0143_brown_ultron.sql
Normal file
1
apps/dokploy/drizzle/0143_brown_ultron.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "sso_provider" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;
|
||||
7172
apps/dokploy/drizzle/meta/0137_snapshot.json
Normal file
7172
apps/dokploy/drizzle/meta/0137_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7229
apps/dokploy/drizzle/meta/0138_snapshot.json
Normal file
7229
apps/dokploy/drizzle/meta/0138_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7236
apps/dokploy/drizzle/meta/0139_snapshot.json
Normal file
7236
apps/dokploy/drizzle/meta/0139_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7242
apps/dokploy/drizzle/meta/0140_snapshot.json
Normal file
7242
apps/dokploy/drizzle/meta/0140_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7248
apps/dokploy/drizzle/meta/0141_snapshot.json
Normal file
7248
apps/dokploy/drizzle/meta/0141_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7284
apps/dokploy/drizzle/meta/0142_snapshot.json
Normal file
7284
apps/dokploy/drizzle/meta/0142_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
7291
apps/dokploy/drizzle/meta/0143_snapshot.json
Normal file
7291
apps/dokploy/drizzle/meta/0143_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -960,6 +960,76 @@
|
||||
"when": 1769580434296,
|
||||
"tag": "0136_tidy_puff_adder",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 137,
|
||||
"version": "7",
|
||||
"when": 1770274109332,
|
||||
"tag": "0137_colossal_sally_floyd",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 138,
|
||||
"version": "7",
|
||||
"when": 1770324882572,
|
||||
"tag": "0138_pretty_ironclad",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 139,
|
||||
"version": "7",
|
||||
"when": 1770442690721,
|
||||
"tag": "0139_brave_bloodstorm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 140,
|
||||
"version": "7",
|
||||
"when": 1770489900075,
|
||||
"tag": "0140_lame_mattie_franklin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 141,
|
||||
"version": "7",
|
||||
"when": 1770490719123,
|
||||
"tag": "0141_plain_earthquake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 142,
|
||||
"version": "7",
|
||||
"when": 1770615019498,
|
||||
"tag": "0142_outstanding_tusk",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 143,
|
||||
"version": "7",
|
||||
"when": 1770961667210,
|
||||
"tag": "0143_brown_ultron",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 144,
|
||||
"version": "7",
|
||||
"when": 1771222951607,
|
||||
"tag": "0144_flaky_psylocke",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 145,
|
||||
"version": "7",
|
||||
"when": 1771226249421,
|
||||
"tag": "0145_modern_risque",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 146,
|
||||
"version": "7",
|
||||
"when": 1771226541422,
|
||||
"tag": "0146_last_kitty_pryde",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -24,6 +24,8 @@ try {
|
||||
.build({
|
||||
entryPoints: {
|
||||
server: "server/server.ts",
|
||||
migration: "migration.ts",
|
||||
"wait-for-postgres": "wait-for-postgres.ts",
|
||||
"reset-password": "reset-password.ts",
|
||||
"reset-2fa": "reset-2fa.ts",
|
||||
},
|
||||
|
||||
92
apps/dokploy/hooks/use-health-check-after-mutation.ts
Normal file
92
apps/dokploy/hooks/use-health-check-after-mutation.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const HEALTH_CHECK_URL = "/api/health";
|
||||
|
||||
export interface UseHealthCheckAfterMutationOptions {
|
||||
/**
|
||||
* Delay in ms before starting to poll the health endpoint.
|
||||
* Gives time for the service (e.g. Traefik) to restart.
|
||||
* @default 5000
|
||||
*/
|
||||
initialDelay?: number;
|
||||
/**
|
||||
* Delay in ms between each health check poll.
|
||||
* @default 2000
|
||||
*/
|
||||
pollInterval?: number;
|
||||
/**
|
||||
* Message shown in toast when the operation completes successfully.
|
||||
*/
|
||||
successMessage: string;
|
||||
/**
|
||||
* Callback when health check passes. Use for refetching data.
|
||||
*/
|
||||
onSuccess?: () => void | Promise<void>;
|
||||
/**
|
||||
* If true, reloads the page when health check passes (e.g. for server update).
|
||||
* @default false
|
||||
*/
|
||||
reloadOnSuccess?: boolean;
|
||||
}
|
||||
|
||||
export const useHealthCheckAfterMutation = ({
|
||||
initialDelay = 5000,
|
||||
pollInterval = 2000,
|
||||
successMessage,
|
||||
onSuccess,
|
||||
reloadOnSuccess = false,
|
||||
}: UseHealthCheckAfterMutationOptions) => {
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
|
||||
const checkHealth = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(HEALTH_CHECK_URL);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pollUntilHealthy = useCallback(async (): Promise<void> => {
|
||||
const isHealthy = await checkHealth();
|
||||
|
||||
if (isHealthy) {
|
||||
toast.success(successMessage);
|
||||
|
||||
if (reloadOnSuccess) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
await onSuccess?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
await pollUntilHealthy();
|
||||
}, [checkHealth, successMessage, reloadOnSuccess, onSuccess, pollInterval]);
|
||||
|
||||
const execute = useCallback(
|
||||
async <T>(mutationFn: () => Promise<T>): Promise<T> => {
|
||||
setIsExecuting(true);
|
||||
|
||||
try {
|
||||
const result = await mutationFn();
|
||||
|
||||
// Give time for the service to restart before polling
|
||||
await new Promise((resolve) => setTimeout(resolve, initialDelay));
|
||||
|
||||
await pollUntilHealthy();
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
},
|
||||
[initialDelay, pollUntilHealthy],
|
||||
);
|
||||
|
||||
return { execute, isExecuting };
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ssoClient } from "@better-auth/sso/client";
|
||||
import {
|
||||
adminClient,
|
||||
apiKeyClient,
|
||||
@@ -13,6 +14,7 @@ export const authClient = createAuthClient({
|
||||
organizationClient(),
|
||||
twoFactorClient(),
|
||||
apiKeyClient(),
|
||||
ssoClient(),
|
||||
adminClient(),
|
||||
inferAdditionalFields({
|
||||
user: {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.26.7",
|
||||
"version": "v0.28.5",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build-server && npm run build-next",
|
||||
"start": "node -r dotenv/config dist/server.mjs",
|
||||
"start": "node -r dotenv/config dist/migration.mjs && node -r dotenv/config dist/server.mjs",
|
||||
"build-server": "tsx esbuild.config.ts",
|
||||
"build-next": "next build",
|
||||
"build-next": "next build --webpack",
|
||||
"setup": "tsx -r dotenv/config setup.ts && sleep 5 && pnpm run migration:run",
|
||||
"wait-for-postgres": "node -r dotenv/config dist/wait-for-postgres.mjs",
|
||||
"wait-for-postgres-dev": "tsx -r dotenv/config wait-for-postgres.ts",
|
||||
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
|
||||
"reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs",
|
||||
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
|
||||
@@ -37,6 +39,8 @@
|
||||
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"resend": "^6.0.2",
|
||||
"@better-auth/sso": "1.4.18",
|
||||
"@ai-sdk/anthropic": "^2.0.5",
|
||||
"@ai-sdk/azure": "^2.0.16",
|
||||
"@ai-sdk/cohere": "^2.0.4",
|
||||
@@ -45,11 +49,13 @@
|
||||
"@ai-sdk/openai": "^2.0.16",
|
||||
"@ai-sdk/openai-compatible": "^1.0.10",
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
"@codemirror/lang-css": "^6.2.1",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/legacy-modes": "6.4.0",
|
||||
"@codemirror/view": "6.29.0",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/view": "^6.39.15",
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@dokploy/trpc-openapi": "0.0.4",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
@@ -94,10 +100,10 @@
|
||||
"ai": "^5.0.17",
|
||||
"ai-sdk-ollama": "^0.5.1",
|
||||
"bcrypt": "5.1.1",
|
||||
"better-auth": "v1.2.8-beta.7",
|
||||
"better-auth": "1.4.18",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "5.4.2",
|
||||
"bullmq": "5.67.3",
|
||||
"shell-quote": "^1.8.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -106,7 +112,7 @@
|
||||
"date-fns": "3.6.0",
|
||||
"dockerode": "4.0.2",
|
||||
"dotenv": "16.4.5",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"fancy-ansi": "^0.1.3",
|
||||
"i18next": "^23.16.8",
|
||||
@@ -116,7 +122,7 @@
|
||||
"lucide-react": "^0.469.0",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
"next": "^16.0.10",
|
||||
"next": "^16.1.6",
|
||||
"next-i18next": "^15.4.2",
|
||||
"next-themes": "^0.2.1",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
@@ -164,7 +170,7 @@
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/lodash": "4.17.4",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "^18.19.104",
|
||||
"@types/node": "^20.16.0",
|
||||
"@types/node-schedule": "2.1.6",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
@@ -174,7 +180,7 @@
|
||||
"@types/swagger-ui-react": "^4.19.0",
|
||||
"@types/ws": "8.5.10",
|
||||
"autoprefixer": "10.4.12",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"esbuild": "0.20.2",
|
||||
"lint-staged": "^15.5.2",
|
||||
"memfs": "^4.17.2",
|
||||
@@ -182,7 +188,7 @@
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.8.3",
|
||||
"vite-tsconfig-paths": "4.3.2",
|
||||
"vitest": "^1.6.1"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.25.2"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ThemeProvider } from "next-themes";
|
||||
import NextTopLoader from "nextjs-toploader";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { SearchCommand } from "@/components/dashboard/search-command";
|
||||
import { WhitelabelHead } from "@/components/proprietary/whitelabel-head";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { Languages } from "@/lib/languages";
|
||||
import { api } from "@/utils/api";
|
||||
@@ -26,9 +27,10 @@ type AppPropsWithLayout = AppProps & {
|
||||
|
||||
const MyApp = ({
|
||||
Component,
|
||||
pageProps: { ...pageProps },
|
||||
pageProps: { whitelabelFaviconUrl, ...pageProps },
|
||||
}: AppPropsWithLayout) => {
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
const faviconHref = whitelabelFaviconUrl ?? "/icon.svg";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -41,7 +43,9 @@ const MyApp = ({
|
||||
</style>
|
||||
<Head>
|
||||
<title>Dokploy</title>
|
||||
<link rel="icon" href={faviconHref} key="favicon" />
|
||||
</Head>
|
||||
<WhitelabelHead />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
|
||||
@@ -4,7 +4,7 @@ export default function Document() {
|
||||
return (
|
||||
<Html lang="en" className="font-sans">
|
||||
<Head>
|
||||
<link rel="icon" href="/icon.svg" />
|
||||
{/* Default favicon; WhitelabelHead overrides with key="favicon" when custom favicon is set */}
|
||||
</Head>
|
||||
<body className="flex h-full w-full flex-col font-sans">
|
||||
<Main />
|
||||
|
||||
@@ -355,6 +355,11 @@ export default async function handler(
|
||||
action === "labeled" ||
|
||||
action === "unlabeled"
|
||||
) {
|
||||
const shouldCreateDeployment =
|
||||
action === "opened" ||
|
||||
action === "synchronize" ||
|
||||
action === "reopened";
|
||||
|
||||
const repository = githubBody?.repository?.name;
|
||||
const deploymentHash = githubBody?.pull_request?.head?.sha;
|
||||
const branch = githubBody?.pull_request?.base?.ref;
|
||||
@@ -475,7 +480,7 @@ export default async function handler(
|
||||
let previewDeploymentId =
|
||||
previewDeploymentResult?.previewDeploymentId || "";
|
||||
|
||||
if (!previewDeploymentResult) {
|
||||
if (!previewDeploymentResult && shouldCreateDeployment) {
|
||||
const previewDeployment = await createPreviewDeployment({
|
||||
applicationId: app.applicationId as string,
|
||||
branch: prBranch,
|
||||
@@ -497,21 +502,23 @@ export default async function handler(
|
||||
previewDeploymentId,
|
||||
};
|
||||
|
||||
if (IS_CLOUD && app.serverId) {
|
||||
jobData.serverId = app.serverId;
|
||||
deploy(jobData).catch((error) => {
|
||||
console.error("Background deployment failed:", error);
|
||||
});
|
||||
continue;
|
||||
if (previewDeploymentId) {
|
||||
if (IS_CLOUD && app.serverId) {
|
||||
jobData.serverId = app.serverId;
|
||||
deploy(jobData).catch((error) => {
|
||||
console.error("Background deployment failed:", error);
|
||||
});
|
||||
continue;
|
||||
}
|
||||
await myQueue.add(
|
||||
"deployments",
|
||||
{ ...jobData },
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
await myQueue.add(
|
||||
"deployments",
|
||||
{ ...jobData },
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
return res.status(200).json({ message: "Apps Deployed" });
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ const parseState = (state: string): string | null => {
|
||||
|
||||
// Helper to fetch access token from Gitea
|
||||
const fetchAccessToken = async (gitea: Gitea, code: string) => {
|
||||
const response = await fetch(`${gitea.giteaUrl}/login/oauth/access_token`, {
|
||||
// Use internal URL for token exchange when Gitea is on same instance as Dokploy
|
||||
const baseUrl = gitea.giteaInternalUrl || gitea.giteaUrl;
|
||||
const response = await fetch(`${baseUrl}/login/oauth/access_token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface Gitea {
|
||||
refreshToken: string | null;
|
||||
expiresAt: number | null;
|
||||
giteaUrl: string;
|
||||
giteaInternalUrl: string | null;
|
||||
clientId: string | null;
|
||||
clientSecret: string | null;
|
||||
organizationName?: string;
|
||||
|
||||
@@ -10,22 +10,29 @@ type Query = {
|
||||
state: string;
|
||||
installation_id: string;
|
||||
setup_action: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const { code, state, installation_id, userId }: Query = req.query as Query;
|
||||
const { code, state, installation_id }: Query = req.query as Query;
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: "Missing code parameter" });
|
||||
}
|
||||
const [action, value] = state?.split(":");
|
||||
// Value could be the organizationId or the githubProviderId
|
||||
const [action, ...rest] = state?.split(":");
|
||||
// For gh_init: rest[0] = organizationId, rest[1] = userId
|
||||
// For gh_setup: rest[0] = githubProviderId
|
||||
|
||||
if (action === "gh_init") {
|
||||
const organizationId = rest[0];
|
||||
const userId = rest[1] || (req.query.userId as string);
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: "Missing userId parameter" });
|
||||
}
|
||||
|
||||
const octokit = new Octokit({});
|
||||
const { data } = await octokit.request(
|
||||
"POST /app-manifests/{code}/conversions",
|
||||
@@ -44,7 +51,7 @@ export default async function handler(
|
||||
githubWebhookSecret: data.webhook_secret,
|
||||
githubPrivateKey: data.pem,
|
||||
},
|
||||
value as string,
|
||||
organizationId as string,
|
||||
userId,
|
||||
);
|
||||
} else if (action === "gh_setup") {
|
||||
@@ -53,7 +60,7 @@ export default async function handler(
|
||||
.set({
|
||||
githubInstallationId: installation_id,
|
||||
})
|
||||
.where(eq(github.githubId, value as string))
|
||||
.where(eq(github.githubId, rest[0] as string))
|
||||
.returning();
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ export default async function handler(
|
||||
}
|
||||
|
||||
const gitlab = await findGitlabById(gitlabId as string);
|
||||
const gitlabUrl = new URL(gitlab.gitlabUrl);
|
||||
// Use internal URL for token exchange when GitLab is on same instance as Dokploy
|
||||
const baseUrl = gitlab.gitlabInternalUrl || gitlab.gitlabUrl;
|
||||
const gitlabUrl = new URL(baseUrl);
|
||||
|
||||
const headers: HeadersInit = {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
|
||||
94
apps/dokploy/pages/dashboard/deployments.tsx
Normal file
94
apps/dokploy/pages/dashboard/deployments.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { Rocket } from "lucide-react";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import type { ReactElement } from "react";
|
||||
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
|
||||
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
const TAB_VALUES = ["deployments", "queue"] as const;
|
||||
type TabValue = (typeof TAB_VALUES)[number];
|
||||
|
||||
function isValidTab(t: string): t is TabValue {
|
||||
return TAB_VALUES.includes(t as TabValue);
|
||||
}
|
||||
|
||||
function DeploymentsPage() {
|
||||
const router = useRouter();
|
||||
const tab =
|
||||
router.query.tab && isValidTab(router.query.tab as string)
|
||||
? (router.query.tab as TabValue)
|
||||
: "deployments";
|
||||
|
||||
const setTab = (value: string) => {
|
||||
if (!isValidTab(value)) return;
|
||||
router.replace(
|
||||
{ pathname: "/dashboard/deployments", query: { tab: value } },
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto min-h-[45vh]">
|
||||
<div className="rounded-xl bg-background shadow-md h-full">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||
<Rocket className="size-5" />
|
||||
Deployments
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
All application and compose deployments in one place.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs value={tab} onValueChange={setTab} className="w-full">
|
||||
<TabsList className="mt-2">
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="queue">Queue</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="deployments" className="mt-0 pt-4">
|
||||
<ShowDeploymentsTable />
|
||||
</TabsContent>
|
||||
<TabsContent value="queue" className="mt-0 pt-4">
|
||||
<ShowQueueTable />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardHeader>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeploymentsPage;
|
||||
|
||||
DeploymentsPage.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout>{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
const { user } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: true,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
@@ -785,7 +785,7 @@ const EnvironmentPage = (
|
||||
}
|
||||
if (success > 0) {
|
||||
toast.success(
|
||||
`${success} service${success !== 1 ? "s" : ""} deployed successfully`,
|
||||
`${success} service${success !== 1 ? "s" : ""} queued for deployment`,
|
||||
);
|
||||
}
|
||||
if (failed > 0) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user