mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Compare commits
548 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb7bae2ef5 | ||
|
|
4533b193a4 | ||
|
|
c22f744e6c | ||
|
|
d3663eba6b | ||
|
|
f9e4a71144 | ||
|
|
985b8bc2e0 | ||
|
|
87ef889114 | ||
|
|
f3494922be | ||
|
|
27252cf58d | ||
|
|
f69fb7684b | ||
|
|
20a7995d73 | ||
|
|
6df66c3871 | ||
|
|
cbfdda1928 | ||
|
|
766279f265 | ||
|
|
f6e4ae700a | ||
|
|
8c8ffe04a7 | ||
|
|
379ea2ac65 | ||
|
|
a172abaee4 | ||
|
|
a325b293b6 | ||
|
|
364c2e192e | ||
|
|
d4a3c5cff9 | ||
|
|
229a9a3a5e | ||
|
|
5c1993a647 | ||
|
|
64a449a09d | ||
|
|
e65e6d225f | ||
|
|
b4fcdc433e | ||
|
|
5ce6172187 | ||
|
|
0affeea5dd | ||
|
|
8f76d520c8 | ||
|
|
566d9e0bee | ||
|
|
da858e215d | ||
|
|
f672c429c4 | ||
|
|
ce34fe3cd8 | ||
|
|
d8dbdb2b9e | ||
|
|
4065ad4428 | ||
|
|
e035062a10 | ||
|
|
36a1daae4b | ||
|
|
830a254837 | ||
|
|
b8580d69d6 | ||
|
|
e3f1518b0d | ||
|
|
ce19a42aee | ||
|
|
28a2ab9aa5 | ||
|
|
0535d780b1 | ||
|
|
e1dd666e24 | ||
|
|
ce71fa4f4d | ||
|
|
364d04f238 | ||
|
|
0db98c0b92 | ||
|
|
8410d94283 | ||
|
|
56cfd35e7d | ||
|
|
e7beb5c75b | ||
|
|
9c5a61e42f | ||
|
|
0ee5a6f13e | ||
|
|
1d35d218ca | ||
|
|
96cdffb5b9 | ||
|
|
353effd720 | ||
|
|
8bfe1632fa | ||
|
|
ed543e5397 | ||
|
|
c6892ba188 | ||
|
|
fa710d4855 | ||
|
|
375decebb2 | ||
|
|
be2e70a17e | ||
|
|
3b4214e040 | ||
|
|
43a493bb5a | ||
|
|
455cae6b8c | ||
|
|
869843d9ac | ||
|
|
d2b662f547 | ||
|
|
31336152ce | ||
|
|
6afd443257 | ||
|
|
1d023ac9f3 | ||
|
|
49616e53ea | ||
|
|
a32e934969 | ||
|
|
eb495b7b99 | ||
|
|
65ddc22010 | ||
|
|
7a5b9e3b76 | ||
|
|
5a302d3c47 | ||
|
|
5c5066bc72 | ||
|
|
f7c8324c4b | ||
|
|
c0e9670daf | ||
|
|
a710728e77 | ||
|
|
80bd80b786 | ||
|
|
6e262cde0d | ||
|
|
21b2cce7a0 | ||
|
|
870e074825 | ||
|
|
055b59e6fa | ||
|
|
8c06296503 | ||
|
|
29ce8908ee | ||
|
|
839e1c0f9f | ||
|
|
54dd531a26 | ||
|
|
7ebf5ad0f9 | ||
|
|
b85163d935 | ||
|
|
a953e59327 | ||
|
|
b2661e4533 | ||
|
|
883459624e | ||
|
|
6e2b2d564b | ||
|
|
065963857c | ||
|
|
a0c9df4bd4 | ||
|
|
68c8c70260 | ||
|
|
a926f28d30 | ||
|
|
59c0636fb0 | ||
|
|
ae159c5678 | ||
|
|
0abf62dd52 | ||
|
|
e42e9bec17 | ||
|
|
978324e2bf | ||
|
|
8f05f06259 | ||
|
|
392be2cfa2 | ||
|
|
18e89df9a5 | ||
|
|
4d2a9f8aa7 | ||
|
|
d08530d451 | ||
|
|
6c9b12cee9 | ||
|
|
a8ff6c7b3f | ||
|
|
8699e024ee | ||
|
|
73782ffd26 | ||
|
|
7a8bb8f71d | ||
|
|
18eae9f7d7 | ||
|
|
1aae523a0b | ||
|
|
f40e802331 | ||
|
|
d979aa17c2 | ||
|
|
e2d20fb0e3 | ||
|
|
62f59c1f9a | ||
|
|
93e1071057 | ||
|
|
788771c5eb | ||
|
|
ab9aa56c48 | ||
|
|
4565b3d7a2 | ||
|
|
c8514e3a1b | ||
|
|
a06dd17aa1 | ||
|
|
256534570b | ||
|
|
2804748118 | ||
|
|
e6bc40e7fe | ||
|
|
196603126b | ||
|
|
a5cd8f18cd | ||
|
|
b842887bc3 | ||
|
|
dd64b06340 | ||
|
|
d9a1976cc0 | ||
|
|
fdfa927532 | ||
|
|
bf2551b0f6 | ||
|
|
ed8be62ff3 | ||
|
|
77336a21f9 | ||
|
|
e05d01788f | ||
|
|
651e81ce6d | ||
|
|
fac29b70a5 | ||
|
|
95eaab43df | ||
|
|
abdef13b93 | ||
|
|
65f397e1b1 | ||
|
|
1ae96297e8 | ||
|
|
c51b502116 | ||
|
|
5a42b78098 | ||
|
|
b39c0ef915 | ||
|
|
844d582147 | ||
|
|
0b51088489 | ||
|
|
9d1cf3736b | ||
|
|
3a95474662 | ||
|
|
3858205e52 | ||
|
|
1dece58cff | ||
|
|
06b8c82484 | ||
|
|
8ea453f444 | ||
|
|
63c0912849 | ||
|
|
6211a19805 | ||
|
|
d22330f983 | ||
|
|
8642d8235e | ||
|
|
b52f57cb0d | ||
|
|
d4d74d3831 | ||
|
|
9d497142db | ||
|
|
852895c382 | ||
|
|
20d5913820 | ||
|
|
f1b4a73158 | ||
|
|
3830f6c4ee | ||
|
|
5c8eda2405 | ||
|
|
6bf85bcfa3 | ||
|
|
bc03e718bf | ||
|
|
a941efb1ff | ||
|
|
fe2de6b899 | ||
|
|
752c9f2818 | ||
|
|
577b126e66 | ||
|
|
be237ae4cf | ||
|
|
3080926a50 | ||
|
|
e3ee89104b | ||
|
|
f98f18b331 | ||
|
|
8505236263 | ||
|
|
b3313cf975 | ||
|
|
4e31d8ac02 | ||
|
|
536507377d | ||
|
|
763219e859 | ||
|
|
3fc5bfc5c5 | ||
|
|
813da8f811 | ||
|
|
5716954665 | ||
|
|
04d3eb9ec0 | ||
|
|
b592a025e4 | ||
|
|
6db9c99080 | ||
|
|
7e8953ff44 | ||
|
|
81c85ce155 | ||
|
|
bd16e03602 | ||
|
|
87a5ce2053 | ||
|
|
ca4820940e | ||
|
|
71fe6de9cb | ||
|
|
9ff4968e61 | ||
|
|
2312ae1c12 | ||
|
|
b03011a94f | ||
|
|
7577e40b25 | ||
|
|
75e34285ef | ||
|
|
8e5b0988cf | ||
|
|
038df9c8a7 | ||
|
|
829aa2a63c | ||
|
|
91e90fc379 | ||
|
|
a1e13ee964 | ||
|
|
341af1bd07 | ||
|
|
8a274d10eb | ||
|
|
6c586f9606 | ||
|
|
dcb1ea37c3 | ||
|
|
58c2ceb355 | ||
|
|
beae03b53d | ||
|
|
55ec25f5e8 | ||
|
|
9382acb40c | ||
|
|
c0acdc5df1 | ||
|
|
413536a336 | ||
|
|
190f45b3a8 | ||
|
|
e6c242a064 | ||
|
|
c2fe1eed01 | ||
|
|
676082fc5b | ||
|
|
b676b1a2de | ||
|
|
5885712c6a | ||
|
|
afedeede16 | ||
|
|
5f09018199 | ||
|
|
9d37876bc4 | ||
|
|
775107ec24 | ||
|
|
7725b3ca36 | ||
|
|
5f297fd984 | ||
|
|
86aba9ce3e | ||
|
|
c6e512bec1 | ||
|
|
fc2b0abdb1 | ||
|
|
d20f86ffe1 | ||
|
|
1157e08aa1 | ||
|
|
e643255a67 | ||
|
|
7521bc8297 | ||
|
|
a63981fa15 | ||
|
|
ea0f797d0f | ||
|
|
181a2ca3c9 | ||
|
|
3fe057c7f8 | ||
|
|
1e834ed1d9 | ||
|
|
9f84545fc7 | ||
|
|
690a2e7467 | ||
|
|
995d9004f3 | ||
|
|
ef89d05077 | ||
|
|
0a3ab7ceac | ||
|
|
2fd4d580d5 | ||
|
|
33e2fa3ce3 | ||
|
|
d320847da4 | ||
|
|
9e84bf324e | ||
|
|
db469e60ad | ||
|
|
0f949b3273 | ||
|
|
166b65c50e | ||
|
|
274c65cbcd | ||
|
|
b538a632d9 | ||
|
|
765c6442cb | ||
|
|
115ed7e7bf | ||
|
|
0644842305 | ||
|
|
a9c62b47ef | ||
|
|
138650d561 | ||
|
|
280be5c9df | ||
|
|
7726fa6112 | ||
|
|
c71d12fd06 | ||
|
|
3df3d187e4 | ||
|
|
8ce9db8dd6 | ||
|
|
6773458da3 | ||
|
|
92c2a83d92 | ||
|
|
3decbd5207 | ||
|
|
8779c67b71 | ||
|
|
4dc7d9e3c8 | ||
|
|
a439286e5f | ||
|
|
e5d5a98bab | ||
|
|
4311ba93f3 | ||
|
|
e0b596ec76 | ||
|
|
379ba20930 | ||
|
|
236e511adc | ||
|
|
0b37e171c5 | ||
|
|
1df1e7b50b | ||
|
|
f15a5bc22d | ||
|
|
469871d383 | ||
|
|
e22b6ab9be | ||
|
|
b01b05077d | ||
|
|
22122361ba | ||
|
|
87c1ce68b9 | ||
|
|
4c8619677b | ||
|
|
7f705e31d3 | ||
|
|
20432ebc3f | ||
|
|
a51a7a82d2 | ||
|
|
5ba19686c8 | ||
|
|
22a2e64563 | ||
|
|
37ee89e6ab | ||
|
|
cb487b8be0 | ||
|
|
26f8719e5f | ||
|
|
3bc1bd5b15 | ||
|
|
ee622b1ba0 | ||
|
|
fe088bad3b | ||
|
|
d374f5eedf | ||
|
|
1c498ee2d2 | ||
|
|
d7e5eb6dfd | ||
|
|
f71e04eaaa | ||
|
|
fc9808e295 | ||
|
|
bc2a286e1d | ||
|
|
6c582eb91d | ||
|
|
e3b2a401a7 | ||
|
|
6c55143e96 | ||
|
|
19a0550b32 | ||
|
|
bb31bef8bc | ||
|
|
abc606d8d9 | ||
|
|
749dd03fe6 | ||
|
|
858d7e5c11 | ||
|
|
079b7b8e72 | ||
|
|
179f3818f0 | ||
|
|
8546031df0 | ||
|
|
16ca198eb4 | ||
|
|
9b5b452d90 | ||
|
|
2fa6f3bfa6 | ||
|
|
42f3105f69 | ||
|
|
a08ba7e8b5 | ||
|
|
a51ada4a1e | ||
|
|
50b1de9594 | ||
|
|
cb90281583 | ||
|
|
20b253e708 | ||
|
|
9a51e0a00d | ||
|
|
49b812e462 | ||
|
|
7233667d49 | ||
|
|
95cd410825 | ||
|
|
5b8ebdaaa4 | ||
|
|
343d5ae6a2 | ||
|
|
e16ce0c817 | ||
|
|
3b2440b1db | ||
|
|
2f72ccbea7 | ||
|
|
be47f6d09a | ||
|
|
9a65bf8e21 | ||
|
|
15959fa91f | ||
|
|
f0c14d144c | ||
|
|
725b763aa8 | ||
|
|
c0bfd7dde7 | ||
|
|
31ba5a784d | ||
|
|
00f9e262a9 | ||
|
|
54b6a850b7 | ||
|
|
029cbf4498 | ||
|
|
8a1cba470c | ||
|
|
84bb98c7e6 | ||
|
|
cbf0f37a49 | ||
|
|
2c22aa3689 | ||
|
|
46289305e8 | ||
|
|
0b3e15aabc | ||
|
|
69a3583717 | ||
|
|
bc55fde6d6 | ||
|
|
1cb1da8097 | ||
|
|
0698ac8318 | ||
|
|
7ced6840fa | ||
|
|
22e6d07f60 | ||
|
|
6a9690fe3c | ||
|
|
1c02478688 | ||
|
|
bbe72ad584 | ||
|
|
83b3176f6f | ||
|
|
7ecd1627c8 | ||
|
|
96fbfa7ef5 | ||
|
|
6874ede933 | ||
|
|
012f8ff2f5 | ||
|
|
9a7ed91a55 | ||
|
|
13e9a50959 | ||
|
|
d424ed23f5 | ||
|
|
bf9abbc37c | ||
|
|
b66e8c8855 | ||
|
|
ce0e9ccddc | ||
|
|
e03aef8e37 | ||
|
|
d0be2a2090 | ||
|
|
30eb1719b0 | ||
|
|
9e7299517f | ||
|
|
cd061916df | ||
|
|
6f818a833c | ||
|
|
00764ffd43 | ||
|
|
e2a16a5723 | ||
|
|
73b622eb2f | ||
|
|
cf3cea6146 | ||
|
|
b8e41e970d | ||
|
|
60b0ae5053 | ||
|
|
ff8263d8f6 | ||
|
|
f65822ca7e | ||
|
|
3feead31d9 | ||
|
|
b1fd1fb306 | ||
|
|
46cd22038b | ||
|
|
5058d9b47d | ||
|
|
ddf95d87bd | ||
|
|
28a5be984d | ||
|
|
1650f5ca79 | ||
|
|
e023cad72d | ||
|
|
49559ebee6 | ||
|
|
6cf0ecf016 | ||
|
|
1562396339 | ||
|
|
f94ee8c299 | ||
|
|
f47335efe5 | ||
|
|
c8b5889414 | ||
|
|
124d81bb1c | ||
|
|
5f987d28c7 | ||
|
|
32b19a0fb6 | ||
|
|
5f71a393be | ||
|
|
f4bd729f65 | ||
|
|
3320e21958 | ||
|
|
2960d81829 | ||
|
|
1c1e52f777 | ||
|
|
64e6919211 | ||
|
|
a53daed434 | ||
|
|
791c8afab7 | ||
|
|
4c45be1447 | ||
|
|
1056810170 | ||
|
|
b3ca81d2e8 | ||
|
|
9d170e5f46 | ||
|
|
3d0b6eb368 | ||
|
|
5db407b674 | ||
|
|
556a847054 | ||
|
|
4c34643287 | ||
|
|
5e590c1ce8 | ||
|
|
2f15f34a19 | ||
|
|
7f53e9cf07 | ||
|
|
48e3d48ab4 | ||
|
|
b9faf4bd1a | ||
|
|
27f43e774a | ||
|
|
c9d3616088 | ||
|
|
7fe8cd03bf | ||
|
|
38e5d244fe | ||
|
|
14573f90f7 | ||
|
|
cbbbe44802 | ||
|
|
00c7ae3f40 | ||
|
|
f10eae40c7 | ||
|
|
df63182f39 | ||
|
|
626bf7c41b | ||
|
|
75abc4758a | ||
|
|
c4c4b459cc | ||
|
|
d8787ec11d | ||
|
|
40c97b8e9c | ||
|
|
fd0a472468 | ||
|
|
db27ec0372 | ||
|
|
841b264257 | ||
|
|
5f6516ab7d | ||
|
|
2daa159e29 | ||
|
|
262a2394a9 | ||
|
|
e7383e1323 | ||
|
|
9a8a40b0f8 | ||
|
|
bcf1ba242e | ||
|
|
a235815a13 | ||
|
|
57594ecb0c | ||
|
|
572579af91 | ||
|
|
63998f71ec | ||
|
|
45fd2d149c | ||
|
|
9a35c85277 | ||
|
|
0cf21cf3f7 | ||
|
|
7400913646 | ||
|
|
e78d354d0d | ||
|
|
bec3ad6bb5 | ||
|
|
5846e429e5 | ||
|
|
0f7652d02c | ||
|
|
fef19056fa | ||
|
|
b07d9939a6 | ||
|
|
301e3480e4 | ||
|
|
976ac053f7 | ||
|
|
f102bae5d5 | ||
|
|
00883dde11 | ||
|
|
e194f3c454 | ||
|
|
cdd39670f5 | ||
|
|
88f7cf2546 | ||
|
|
34ea7ad8c9 | ||
|
|
081a2d8f69 | ||
|
|
a6368ee0b8 | ||
|
|
4132f714ae | ||
|
|
333776a5a1 | ||
|
|
5853117e5f | ||
|
|
9e0e3540f5 | ||
|
|
7bd6e7fd9a | ||
|
|
95ab6af3ac | ||
|
|
69876029b1 | ||
|
|
d4fdf881cd | ||
|
|
3b14ebcaa4 | ||
|
|
22b8fa2c00 | ||
|
|
714865730f | ||
|
|
7469c30992 | ||
|
|
b296b6bbf0 | ||
|
|
37fa139a65 | ||
|
|
a1cf597c2b | ||
|
|
c8e9d9d169 | ||
|
|
d01928a878 | ||
|
|
30c19c5698 | ||
|
|
01dfa7feaf | ||
|
|
58e6462ff1 | ||
|
|
d18876d4fb | ||
|
|
492c912c61 | ||
|
|
6a283c8ee2 | ||
|
|
59dfdd6192 | ||
|
|
3c072d7aa8 | ||
|
|
19d897f3ad | ||
|
|
0477329db7 | ||
|
|
fabe946526 | ||
|
|
daa0c9d5d4 | ||
|
|
afe9b3c113 | ||
|
|
cbfd09786a | ||
|
|
54eb5544ac | ||
|
|
ac33b6b6a1 | ||
|
|
653b1972ca | ||
|
|
7d7eb6a7a2 | ||
|
|
fab7e138b7 | ||
|
|
62b635b2f0 | ||
|
|
2dd352ee76 | ||
|
|
422b6eea82 | ||
|
|
4850305fb6 | ||
|
|
97779f5686 | ||
|
|
d4b8985d71 | ||
|
|
d5686063e0 | ||
|
|
62ca8eec53 | ||
|
|
204143648d | ||
|
|
be8bd78bcc | ||
|
|
9003e43702 | ||
|
|
55ac24ee8e | ||
|
|
f3be56234b | ||
|
|
fd59beaff1 | ||
|
|
4a70d60aed | ||
|
|
f7533c88f6 | ||
|
|
ea8cae7815 | ||
|
|
e9956a66da | ||
|
|
2eeb4017ac | ||
|
|
28f0c9f162 | ||
|
|
4967d3bb31 | ||
|
|
238fa5d02d | ||
|
|
d1436c992e | ||
|
|
0654804821 | ||
|
|
c0876044b0 | ||
|
|
6dff11af22 | ||
|
|
6d674a4c6b | ||
|
|
96b2579d69 | ||
|
|
0708fa05b6 | ||
|
|
597842a99f | ||
|
|
105cf1014f | ||
|
|
b93f36ae77 | ||
|
|
bebb4b973c | ||
|
|
f790530d4d | ||
|
|
b7374549b8 | ||
|
|
366e881d72 | ||
|
|
c2125d82b1 | ||
|
|
32b3a76457 | ||
|
|
5cbdc8fad9 | ||
|
|
3698e8a827 | ||
|
|
a83b62f62b | ||
|
|
ac033cea22 | ||
|
|
58814239d9 | ||
|
|
6fc1ce2fbc | ||
|
|
adde8126ab | ||
|
|
cda66606ec | ||
|
|
28f2c1a3c0 | ||
|
|
1c5fe8a283 | ||
|
|
da005bc511 |
@@ -99,14 +99,14 @@ workflows:
|
|||||||
only:
|
only:
|
||||||
- main
|
- main
|
||||||
- canary
|
- canary
|
||||||
- fix/build-i18n
|
- fix/nixpacks-version
|
||||||
- build-arm64:
|
- build-arm64:
|
||||||
filters:
|
filters:
|
||||||
branches:
|
branches:
|
||||||
only:
|
only:
|
||||||
- main
|
- main
|
||||||
- canary
|
- canary
|
||||||
- fix/build-i18n
|
- fix/nixpacks-version
|
||||||
- combine-manifests:
|
- combine-manifests:
|
||||||
requires:
|
requires:
|
||||||
- build-amd64
|
- build-amd64
|
||||||
@@ -116,4 +116,4 @@ workflows:
|
|||||||
only:
|
only:
|
||||||
- main
|
- main
|
||||||
- canary
|
- canary
|
||||||
- fix/build-i18n
|
- fix/nixpacks-version
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
pnpm commitlint --edit $1
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// Skip Husky install in production and CI
|
|
||||||
if (process.env.NODE_ENV === "production" || process.env.CI === "true") {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
const husky = (await import("husky")).default;
|
|
||||||
console.log(husky());
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
pnpm lint-staged
|
|
||||||
52
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
52
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,6 +1,6 @@
|
|||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: Create a bug report
|
description: Create a bug report
|
||||||
labels: ['bug']
|
labels: ["bug"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
@@ -11,18 +11,27 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: To Reproduce
|
label: To Reproduce
|
||||||
description: A step-by-step description of how to reproduce the issue, or a link to the reproducible repository.
|
description: |
|
||||||
|
A detailed, step-by-step description of how to reproduce the issue is required.
|
||||||
|
Please ensure your report includes clear instructions using numbered lists.
|
||||||
|
|
||||||
|
If possible, provide a link to a repository or project where the issue can be reproduced.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
1. Create a application
|
1. Create a application
|
||||||
2. Click X
|
2. Click X
|
||||||
3. Y will happen
|
3. Y will happen
|
||||||
|
|
||||||
|
Make sure to:
|
||||||
|
- Use numbered lists to outline steps clearly.
|
||||||
|
- Include all relevant commands and configurations.
|
||||||
|
- Provide a link to a reproducible repository if applicable.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Current vs. Expected behavior
|
label: Current vs. Expected behavior
|
||||||
description: A clear and concise description of what the bug is, and what you expected to happen.
|
description: A clear and concise description of what the bug is, and what you expected to happen.
|
||||||
placeholder: 'Following the steps from the previous section, I expected A to happen, but I observed B instead'
|
placeholder: "Following the steps from the previous section, I expected A to happen, but I observed B instead"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@@ -45,12 +54,23 @@ body:
|
|||||||
label: Which area(s) are affected? (Select all that apply)
|
label: Which area(s) are affected? (Select all that apply)
|
||||||
multiple: true
|
multiple: true
|
||||||
options:
|
options:
|
||||||
- 'Installation'
|
- "Installation"
|
||||||
- 'Application'
|
- "Application"
|
||||||
- 'Databases'
|
- "Databases"
|
||||||
- 'Docker Compose'
|
- "Docker Compose"
|
||||||
- 'Traefik'
|
- "Traefik"
|
||||||
- 'Docker'
|
- "Docker"
|
||||||
|
- "Remote server"
|
||||||
|
- "Local Development"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Are you deploying the applications where Dokploy is installed or on a remote server?
|
||||||
|
options:
|
||||||
|
- "Same server where Dokploy is installed"
|
||||||
|
- "Remote server"
|
||||||
|
- "Both"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@@ -59,4 +79,16 @@ body:
|
|||||||
description: |
|
description: |
|
||||||
Any extra information that might help us investigate.
|
Any extra information that might help us investigate.
|
||||||
placeholder: |
|
placeholder: |
|
||||||
I tested on a DigitalOcean VPS with Ubuntu 20.04 and Docker version 20.10.12.
|
I tested on a DigitalOcean VPS with Ubuntu 20.04 and Docker version 20.10.12.
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Will you send a PR to fix it?
|
||||||
|
description: Let us know if you are planning to submit a pull request to address this issue.
|
||||||
|
|
||||||
|
options:
|
||||||
|
- "Yes"
|
||||||
|
- "No"
|
||||||
|
- "Maybe, need help"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|||||||
15
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
15
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -1,6 +1,6 @@
|
|||||||
name: Feature Request
|
name: Feature Request
|
||||||
description: Suggest a new feature or improvement to the project
|
description: Suggest a new feature or improvement to the project
|
||||||
labels: ['enhancement']
|
labels: ["enhancement"]
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
@@ -30,4 +30,15 @@ body:
|
|||||||
label: Additional context
|
label: Additional context
|
||||||
description: Add any other context or screenshots about the feature request here.
|
description: Add any other context or screenshots about the feature request here.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Will you send a PR to implement it?
|
||||||
|
description: Let us know if you are planning to submit a pull request to implement this feature.
|
||||||
|
options:
|
||||||
|
- "Yes"
|
||||||
|
- "No"
|
||||||
|
- "Maybe, need help"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|||||||
BIN
.github/sponsors/startupfame.png
vendored
Normal file
BIN
.github/sponsors/startupfame.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
8
.github/workflows/pull-request.yml
vendored
8
.github/workflows/pull-request.yml
vendored
@@ -4,9 +4,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, canary]
|
branches: [main, canary]
|
||||||
|
|
||||||
env:
|
|
||||||
HUSKY: 0
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-and-typecheck:
|
lint-and-typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -18,8 +15,7 @@ jobs:
|
|||||||
node-version: 18.18.0
|
node-version: 18.18.0
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm run server:build
|
- run: pnpm run server:build
|
||||||
- run: pnpm biome ci
|
|
||||||
- run: pnpm typecheck
|
- run: pnpm typecheck
|
||||||
|
|
||||||
build-and-test:
|
build-and-test:
|
||||||
@@ -46,5 +42,5 @@ jobs:
|
|||||||
node-version: 18.18.0
|
node-version: 18.18.0
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm run server:build
|
- run: pnpm run server:build
|
||||||
- run: pnpm test
|
- run: pnpm test
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -34,7 +34,6 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# Editor
|
# Editor
|
||||||
.vscode
|
|
||||||
.idea
|
.idea
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
npx commitlint --edit "$1"
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// Skip Husky install in production and CI
|
|
||||||
if (process.env.NODE_ENV === "production" || process.env.CI === "true") {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
const husky = (await import("husky")).default;
|
|
||||||
console.log(husky());
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
pnpm run check
|
|
||||||
git add .
|
|
||||||
@@ -241,7 +241,7 @@ export function generate(schema: Schema): Template {
|
|||||||
|
|
||||||
- Use the same name of the folder as the id of the template.
|
- Use the same name of the folder as the id of the template.
|
||||||
- The logo should be in the public folder.
|
- The logo should be in the public folder.
|
||||||
- If you want to show a domain in the UI, please add the prefix \_HOST at the end of the variable name.
|
- If you want to show a domain in the UI, please add the `_HOST` suffix at the end of the variable name.
|
||||||
- Test first on a vps or a server to make sure the template works.
|
- Test first on a vps or a server to make sure the template works.
|
||||||
|
|
||||||
## Docs & Website
|
## Docs & Website
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ RUN apt-get update && apt-get install -y curl unzip apache2-utils && rm -rf /var
|
|||||||
COPY --from=build /prod/dokploy/.next ./.next
|
COPY --from=build /prod/dokploy/.next ./.next
|
||||||
COPY --from=build /prod/dokploy/dist ./dist
|
COPY --from=build /prod/dokploy/dist ./dist
|
||||||
COPY --from=build /prod/dokploy/next.config.mjs ./next.config.mjs
|
COPY --from=build /prod/dokploy/next.config.mjs ./next.config.mjs
|
||||||
COPY --from=build /prod/dokploy/next-i18next.config.cjs ./next-i18next.config.cjs
|
|
||||||
COPY --from=build /prod/dokploy/public ./public
|
COPY --from=build /prod/dokploy/public ./public
|
||||||
COPY --from=build /prod/dokploy/package.json ./package.json
|
COPY --from=build /prod/dokploy/package.json ./package.json
|
||||||
COPY --from=build /prod/dokploy/drizzle ./drizzle
|
COPY --from=build /prod/dokploy/drizzle ./drizzle
|
||||||
@@ -49,6 +48,8 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm
|
|||||||
|
|
||||||
# Install Nixpacks and tsx
|
# Install Nixpacks and tsx
|
||||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||||
|
|
||||||
|
ARG NIXPACKS_VERSION=1.29.1
|
||||||
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
||||||
&& chmod +x install.sh \
|
&& chmod +x install.sh \
|
||||||
&& ./install.sh \
|
&& ./install.sh \
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ RUN apt-get update && apt-get install -y curl unzip apache2-utils && rm -rf /var
|
|||||||
COPY --from=build /prod/dokploy/.next ./.next
|
COPY --from=build /prod/dokploy/.next ./.next
|
||||||
COPY --from=build /prod/dokploy/dist ./dist
|
COPY --from=build /prod/dokploy/dist ./dist
|
||||||
COPY --from=build /prod/dokploy/next.config.mjs ./next.config.mjs
|
COPY --from=build /prod/dokploy/next.config.mjs ./next.config.mjs
|
||||||
COPY --from=build /prod/dokploy/next-i18next.config.cjs ./next-i18next.config.cjs
|
|
||||||
COPY --from=build /prod/dokploy/public ./public
|
COPY --from=build /prod/dokploy/public ./public
|
||||||
COPY --from=build /prod/dokploy/package.json ./package.json
|
COPY --from=build /prod/dokploy/package.json ./package.json
|
||||||
COPY --from=build /prod/dokploy/drizzle ./drizzle
|
COPY --from=build /prod/dokploy/drizzle ./drizzle
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ See the License for the specific language governing permissions and limitations
|
|||||||
|
|
||||||
## Additional Terms for Specific Features
|
## Additional Terms for Specific Features
|
||||||
|
|
||||||
The following additional terms apply to the multi-node support, Docker Compose file and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
||||||
|
|
||||||
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support and Multi Server, will always be free to use in the self-hosted version.
|
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
|
||||||
- **Restriction on Resale**: The multi-node support, Docker Compose file support and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
||||||
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
||||||
|
|
||||||
For further inquiries or permissions, please contact us directly.
|
For further inquiries or permissions, please contact us directly.
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
|||||||
|
|
||||||
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
|
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
|
||||||
<a href="https://lightspeed.run/?ref=dokploy"><img src="https://github.com/lightspeedrun.png" width="60px" alt="Lightspeed.run"/></a>
|
<a href="https://lightspeed.run/?ref=dokploy"><img src="https://github.com/lightspeedrun.png" width="60px" alt="Lightspeed.run"/></a>
|
||||||
<a href="https://cloudblast.io/?ref=dokploy "><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Lightspeed.run"/></a>
|
<a href="https://cloudblast.io/?ref=dokploy "><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
|
||||||
|
<a href="https://startupfa.me/?ref=dokploy "><img src=".github/sponsors/startupfame.png" width="65px" alt="Startupfame"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
### Community Backers 🤝
|
### Community Backers 🤝
|
||||||
@@ -116,7 +117,7 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
|||||||
## Video Tutorial
|
## Video Tutorial
|
||||||
|
|
||||||
<a href="https://youtu.be/mznYKPvhcfw">
|
<a href="https://youtu.be/mznYKPvhcfw">
|
||||||
<img src="https://dokploy.com/banner.webp" alt="Watch the video" width="400" style="border-radius:20px;"/>
|
<img src="https://dokploy.com/banner.png" alt="Watch the video" width="400" style="border-radius:20px;"/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- ## Supported OS
|
<!-- ## Supported OS
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
|||||||
applicationType: z.literal("compose"),
|
applicationType: z.literal("compose"),
|
||||||
serverId: z.string().min(1),
|
serverId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
applicationId: z.string(),
|
||||||
|
previewDeploymentId: z.string(),
|
||||||
|
titleLog: z.string(),
|
||||||
|
descriptionLog: z.string(),
|
||||||
|
server: z.boolean().optional(),
|
||||||
|
type: z.enum(["deploy"]),
|
||||||
|
applicationType: z.literal("application-preview"),
|
||||||
|
serverId: z.string().min(1),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type DeployJob = z.infer<typeof deployJobSchema>;
|
export type DeployJob = z.infer<typeof deployJobSchema>;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
deployRemoteApplication,
|
deployRemoteApplication,
|
||||||
deployRemoteCompose,
|
deployRemoteCompose,
|
||||||
|
deployRemotePreviewApplication,
|
||||||
rebuildRemoteApplication,
|
rebuildRemoteApplication,
|
||||||
rebuildRemoteCompose,
|
rebuildRemoteCompose,
|
||||||
updateApplicationStatus,
|
updateApplicationStatus,
|
||||||
updateCompose,
|
updateCompose,
|
||||||
|
updatePreviewDeployment,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import type { DeployJob } from "./schema";
|
import type { DeployJob } from "./schema";
|
||||||
|
|
||||||
@@ -47,6 +49,20 @@ export const deploy = async (job: DeployJob) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (job.applicationType === "application-preview") {
|
||||||
|
await updatePreviewDeployment(job.previewDeploymentId, {
|
||||||
|
previewStatus: "running",
|
||||||
|
});
|
||||||
|
if (job.server) {
|
||||||
|
if (job.type === "deploy") {
|
||||||
|
await deployRemotePreviewApplication({
|
||||||
|
applicationId: job.applicationId,
|
||||||
|
titleLog: job.titleLog,
|
||||||
|
descriptionLog: job.descriptionLog,
|
||||||
|
previewDeploymentId: job.previewDeploymentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (job.applicationType === "application") {
|
if (job.applicationType === "application") {
|
||||||
@@ -55,6 +71,10 @@ export const deploy = async (job: DeployJob) => {
|
|||||||
await updateCompose(job.composeId, {
|
await updateCompose(job.composeId, {
|
||||||
composeStatus: "error",
|
composeStatus: "error",
|
||||||
});
|
});
|
||||||
|
} else if (job.applicationType === "application-preview") {
|
||||||
|
await updatePreviewDeployment(job.previewDeploymentId, {
|
||||||
|
previewStatus: "error",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ See the License for the specific language governing permissions and limitations
|
|||||||
|
|
||||||
## Additional Terms for Specific Features
|
## Additional Terms for Specific Features
|
||||||
|
|
||||||
The following additional terms apply to the multi-node support, Docker Compose file and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
||||||
|
|
||||||
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support and Multi Server, will always be free to use in the self-hosted version.
|
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
|
||||||
- **Restriction on Resale**: The multi-node support, Docker Compose file support and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
||||||
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
||||||
|
|
||||||
For further inquiries or permissions, please contact us directly.
|
For further inquiries or permissions, please contact us directly.
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ describe("createDomainLabels", () => {
|
|||||||
domainId: "",
|
domainId: "",
|
||||||
path: "/",
|
path: "/",
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
|
previewDeploymentId: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should create basic labels for web entrypoint", async () => {
|
it("should create basic labels for web entrypoint", async () => {
|
||||||
|
|||||||
@@ -26,12 +26,23 @@ if (typeof window === "undefined") {
|
|||||||
|
|
||||||
const baseApp: ApplicationNested = {
|
const baseApp: ApplicationNested = {
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
|
herokuVersion: "",
|
||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
appName: "",
|
appName: "",
|
||||||
autoDeploy: true,
|
autoDeploy: true,
|
||||||
serverId: "",
|
serverId: "",
|
||||||
|
registryUrl: "",
|
||||||
branch: null,
|
branch: null,
|
||||||
dockerBuildStage: "",
|
dockerBuildStage: "",
|
||||||
|
isPreviewDeploymentsActive: false,
|
||||||
|
previewBuildArgs: null,
|
||||||
|
previewCertificateType: "none",
|
||||||
|
previewEnv: null,
|
||||||
|
previewHttps: false,
|
||||||
|
previewPath: "/",
|
||||||
|
previewPort: 3000,
|
||||||
|
previewLimit: 0,
|
||||||
|
previewWildcard: "",
|
||||||
project: {
|
project: {
|
||||||
env: "",
|
env: "",
|
||||||
adminId: "",
|
adminId: "",
|
||||||
|
|||||||
@@ -6,13 +6,24 @@ import { expect, test } from "vitest";
|
|||||||
|
|
||||||
const baseApp: ApplicationNested = {
|
const baseApp: ApplicationNested = {
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
|
herokuVersion: "",
|
||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
appName: "",
|
appName: "",
|
||||||
autoDeploy: true,
|
autoDeploy: true,
|
||||||
serverId: "",
|
serverId: "",
|
||||||
branch: null,
|
branch: null,
|
||||||
dockerBuildStage: "",
|
dockerBuildStage: "",
|
||||||
|
registryUrl: "",
|
||||||
buildArgs: null,
|
buildArgs: null,
|
||||||
|
isPreviewDeploymentsActive: false,
|
||||||
|
previewBuildArgs: null,
|
||||||
|
previewCertificateType: "none",
|
||||||
|
previewEnv: null,
|
||||||
|
previewHttps: false,
|
||||||
|
previewPath: "/",
|
||||||
|
previewPort: 3000,
|
||||||
|
previewLimit: 0,
|
||||||
|
previewWildcard: "",
|
||||||
project: {
|
project: {
|
||||||
env: "",
|
env: "",
|
||||||
adminId: "",
|
adminId: "",
|
||||||
@@ -94,6 +105,7 @@ const baseDomain: Domain = {
|
|||||||
composeId: "",
|
composeId: "",
|
||||||
domainType: "application",
|
domainType: "application",
|
||||||
uniqueConfigKey: 1,
|
uniqueConfigKey: 1,
|
||||||
|
previewDeploymentId: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRedirect: Redirect = {
|
const baseRedirect: Redirect = {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const Login2FA = ({ authId }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CardTitle className="text-xl font-bold">2FA Setup</CardTitle>
|
<CardTitle className="text-xl font-bold">2FA Login</CardTitle>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -18,7 +19,6 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@@ -150,7 +150,7 @@ export const AddVolumes = ({
|
|||||||
<DialogTrigger className="" asChild>
|
<DialogTrigger className="" asChild>
|
||||||
<Button>{children}</Button>
|
<Button>{children}</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Volumes / Mounts</DialogTitle>
|
<DialogTitle>Volumes / Mounts</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -303,9 +303,12 @@ export const AddVolumes = ({
|
|||||||
<FormLabel>Content</FormLabel>
|
<FormLabel>Content</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<CodeEditor
|
||||||
placeholder="Any content"
|
language="properties"
|
||||||
className="h-64"
|
placeholder={`NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
`}
|
||||||
|
className="h-96 font-mono"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
@@ -119,7 +118,7 @@ export const UpdateVolume = ({
|
|||||||
} else if (typeForm === "file") {
|
} else if (typeForm === "file") {
|
||||||
form.reset({
|
form.reset({
|
||||||
content: data.content || "",
|
content: data.content || "",
|
||||||
mountPath: "/",
|
mountPath: serviceType === "compose" ? "/" : data.mountPath,
|
||||||
filePath: data.filePath || "",
|
filePath: data.filePath || "",
|
||||||
type: "file",
|
type: "file",
|
||||||
});
|
});
|
||||||
@@ -182,7 +181,7 @@ export const UpdateVolume = ({
|
|||||||
<Pencil className="size-4 text-muted-foreground" />
|
<Pencil className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Update</DialogTitle>
|
<DialogTitle>Update</DialogTitle>
|
||||||
<DialogDescription>Update the mount</DialogDescription>
|
<DialogDescription>Update the mount</DialogDescription>
|
||||||
@@ -247,9 +246,12 @@ export const UpdateVolume = ({
|
|||||||
<FormLabel>Content</FormLabel>
|
<FormLabel>Content</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<CodeEditor
|
||||||
placeholder="Any content"
|
language="properties"
|
||||||
className="h-64"
|
placeholder={`NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
`}
|
||||||
|
className="h-96 font-mono"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const mySchema = z.discriminatedUnion("buildType", [
|
|||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal("heroku_buildpacks"),
|
buildType: z.literal("heroku_buildpacks"),
|
||||||
|
herokuVersion: z.string().nullable().default(""),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal("paketo_buildpacks"),
|
buildType: z.literal("paketo_buildpacks"),
|
||||||
@@ -90,6 +91,13 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
dockerBuildStage: data.dockerBuildStage || "",
|
dockerBuildStage: data.dockerBuildStage || "",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
} else if (data.buildType === "heroku_buildpacks") {
|
||||||
|
form.reset({
|
||||||
|
buildType: data.buildType,
|
||||||
|
...(data.buildType && {
|
||||||
|
herokuVersion: data.herokuVersion || "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
form.reset({
|
form.reset({
|
||||||
buildType: data.buildType,
|
buildType: data.buildType,
|
||||||
@@ -110,6 +118,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
data.buildType === "dockerfile" ? data.dockerContextPath : null,
|
data.buildType === "dockerfile" ? data.dockerContextPath : null,
|
||||||
dockerBuildStage:
|
dockerBuildStage:
|
||||||
data.buildType === "dockerfile" ? data.dockerBuildStage : null,
|
data.buildType === "dockerfile" ? data.dockerBuildStage : null,
|
||||||
|
herokuVersion:
|
||||||
|
data.buildType === "heroku_buildpacks" ? data.herokuVersion : null,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Build type saved");
|
toast.success("Build type saved");
|
||||||
@@ -200,6 +210,28 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{buildType === "heroku_buildpacks" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="herokuVersion"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Heroku Version (Optional)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={"Heroku Version (Default: 24)"}
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{buildType === "dockerfile" && (
|
{buildType === "dockerfile" && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
|
|||||||
@@ -1,63 +1,161 @@
|
|||||||
import {
|
import { Badge } from "@/components/ui/badge";
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Copy, TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const deleteApplicationSchema = z.object({
|
||||||
|
projectName: z.string().min(1, {
|
||||||
|
message: "Application name is required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type DeleteApplication = z.infer<typeof deleteApplicationSchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteApplication = ({ applicationId }: Props) => {
|
export const DeleteApplication = ({ applicationId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { mutateAsync, isLoading } = api.application.delete.useMutation();
|
const { mutateAsync, isLoading } = api.application.delete.useMutation();
|
||||||
|
const { data } = api.application.one.useQuery(
|
||||||
|
{ applicationId },
|
||||||
|
{ enabled: !!applicationId },
|
||||||
|
);
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
|
const form = useForm<DeleteApplication>({
|
||||||
|
defaultValues: {
|
||||||
|
projectName: "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(deleteApplicationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: DeleteApplication) => {
|
||||||
|
const expectedName = `${data?.name}/${data?.appName}`;
|
||||||
|
if (formData.projectName === expectedName) {
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId,
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
push(`/dashboard/project/${data?.projectId}`);
|
||||||
|
toast.success("Application deleted successfully");
|
||||||
|
setIsOpen(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting the application");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.setError("projectName", {
|
||||||
|
message: "Project name does not match",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<AlertDialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<TrashIcon className="size-4 text-muted-foreground" />
|
<TrashIcon className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</DialogTrigger>
|
||||||
<AlertDialogContent>
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
<AlertDialogHeader>
|
<DialogHeader>
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||||
<AlertDialogDescription>
|
<DialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
application
|
application. If you are sure please enter the application name to
|
||||||
</AlertDialogDescription>
|
delete this application.
|
||||||
</AlertDialogHeader>
|
</DialogDescription>
|
||||||
<AlertDialogFooter>
|
</DialogHeader>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<div className="grid gap-4">
|
||||||
<AlertDialogAction
|
<Form {...form}>
|
||||||
onClick={async () => {
|
<form
|
||||||
await mutateAsync({
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
applicationId,
|
id="hook-form-delete-application"
|
||||||
})
|
className="grid w-full gap-4"
|
||||||
.then((data) => {
|
>
|
||||||
push(`/dashboard/project/${data?.projectId}`);
|
<FormField
|
||||||
|
control={form.control}
|
||||||
toast.success("Application delete succesfully");
|
name="projectName"
|
||||||
})
|
render={({ field }) => (
|
||||||
.catch(() => {
|
<FormItem>
|
||||||
toast.error("Error to delete Application");
|
<FormLabel className="flex items-center gap-2">
|
||||||
});
|
<span>
|
||||||
|
To confirm, type{" "}
|
||||||
|
<Badge
|
||||||
|
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (data?.name && data?.appName) {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`${data.name}/${data.appName}`,
|
||||||
|
);
|
||||||
|
toast.success("Copied to clipboard. Be careful!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data?.name}/{data?.appName}
|
||||||
|
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||||
|
</Badge>{" "}
|
||||||
|
in the box below:
|
||||||
|
</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter application name to confirm"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
form="hook-form-delete-application"
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</AlertDialogAction>
|
</Button>
|
||||||
</AlertDialogFooter>
|
</DialogFooter>
|
||||||
</AlertDialogContent>
|
</DialogContent>
|
||||||
</AlertDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -5,7 +7,10 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { TerminalLine } from "../../docker/logs/terminal-line";
|
||||||
|
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logPath: string | null;
|
logPath: string | null;
|
||||||
@@ -15,8 +20,25 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
|
export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
|
||||||
const [data, setData] = useState("");
|
const [data, setData] = useState("");
|
||||||
const endOfLogsRef = useRef<HTMLDivElement>(null);
|
const [showExtraLogs, setShowExtraLogs] = useState(false);
|
||||||
|
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||||
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
|
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
if (autoScroll && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!scrollRef.current) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||||
|
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
|
||||||
|
setAutoScroll(isAtBottom);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !logPath) return;
|
if (!open || !logPath) return;
|
||||||
@@ -48,13 +70,34 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
|
|||||||
};
|
};
|
||||||
}, [logPath, open]);
|
}, [logPath, open]);
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
useEffect(() => {
|
||||||
endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" });
|
const logs = parseLogs(data);
|
||||||
};
|
let filteredLogsResult = logs;
|
||||||
|
if (serverId) {
|
||||||
|
let hideSubsequentLogs = false;
|
||||||
|
filteredLogsResult = logs.filter((log) => {
|
||||||
|
if (
|
||||||
|
log.message.includes(
|
||||||
|
"===================================EXTRA LOGS============================================",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
hideSubsequentLogs = true;
|
||||||
|
return showExtraLogs;
|
||||||
|
}
|
||||||
|
return showExtraLogs ? true : !hideSubsequentLogs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredLogs(filteredLogsResult);
|
||||||
|
}, [data, showExtraLogs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [data]);
|
|
||||||
|
if (autoScroll && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [filteredLogs, autoScroll]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -75,18 +118,49 @@ export const ShowDeployment = ({ logPath, open, onClose, serverId }: Props) => {
|
|||||||
<DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
|
<DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Deployment</DialogTitle>
|
<DialogTitle>Deployment</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription className="flex items-center gap-2">
|
||||||
See all the details of this deployment
|
<span>
|
||||||
|
See all the details of this deployment |{" "}
|
||||||
|
<Badge variant="blank" className="text-xs">
|
||||||
|
{filteredLogs.length} lines
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{serverId && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="show-extra-logs"
|
||||||
|
checked={showExtraLogs}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setShowExtraLogs(checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="show-extra-logs"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
Show Extra Logs
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem]">
|
<div
|
||||||
<code>
|
ref={scrollRef}
|
||||||
<pre className="whitespace-pre-wrap break-words">
|
onScroll={handleScroll}
|
||||||
{data || "Loading..."}
|
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||||
</pre>
|
>
|
||||||
<div ref={endOfLogsRef} />
|
{" "}
|
||||||
</code>
|
{filteredLogs.length > 0 ? (
|
||||||
|
filteredLogs.map((log: LogLine, index: number) => (
|
||||||
|
<TerminalLine key={index} log={log} noTimestamp />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const ShowDeployments = ({ applicationId }: Props) => {
|
|||||||
refetchInterval: 1000,
|
refetchInterval: 1000,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const [url, setUrl] = React.useState("");
|
const [url, setUrl] = React.useState("");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setUrl(document.location.origin);
|
setUrl(document.location.origin);
|
||||||
|
|||||||
@@ -264,21 +264,21 @@ export const AddDomain = ({
|
|||||||
name="certificateType"
|
name="certificateType"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-2">
|
<FormItem className="col-span-2">
|
||||||
<FormLabel>Certificate</FormLabel>
|
<FormLabel>Certificate Provider</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={field.value || ""}
|
defaultValue={field.value || ""}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a certificate" />
|
<SelectValue placeholder="Select a certificate provider" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">None</SelectItem>
|
<SelectItem value="none">None</SelectItem>
|
||||||
<SelectItem value={"letsencrypt"}>
|
<SelectItem value={"letsencrypt"}>
|
||||||
Letsencrypt (Default)
|
Let's Encrypt
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="flex w-full flex-col gap-5 "
|
className="flex w-full flex-col gap-5 "
|
||||||
>
|
>
|
||||||
<Card className="bg-background">
|
<Card className="bg-background p-6">
|
||||||
<Secrets
|
<Secrets
|
||||||
name="env"
|
name="env"
|
||||||
title="Environment Settings"
|
title="Environment Settings"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -18,6 +19,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DeployApplication = ({ applicationId }: Props) => {
|
export const DeployApplication = ({ applicationId }: Props) => {
|
||||||
|
const router = useRouter();
|
||||||
const { data, refetch } = api.application.one.useQuery(
|
const { data, refetch } = api.application.one.useQuery(
|
||||||
{
|
{
|
||||||
applicationId,
|
applicationId,
|
||||||
@@ -51,6 +53,9 @@ export const DeployApplication = ({ applicationId }: Props) => {
|
|||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Application deployed succesfully");
|
toast.success("Application deployed succesfully");
|
||||||
await refetch();
|
await refetch();
|
||||||
|
router.push(
|
||||||
|
`/dashboard/project/${data?.projectId}/services/application/${applicationId}?tab=deployments`,
|
||||||
|
);
|
||||||
})
|
})
|
||||||
|
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -202,7 +202,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between !bg-input",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
@@ -281,7 +280,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between !bg-input",
|
" w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const DockerProviderSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
username: z.string().optional(),
|
username: z.string().optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
|
registryURL: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||||
@@ -33,12 +34,12 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
|||||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
|
|
||||||
const { mutateAsync } = api.application.saveDockerProvider.useMutation();
|
const { mutateAsync } = api.application.saveDockerProvider.useMutation();
|
||||||
|
|
||||||
const form = useForm<DockerProvider>({
|
const form = useForm<DockerProvider>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
dockerImage: "",
|
dockerImage: "",
|
||||||
password: "",
|
password: "",
|
||||||
username: "",
|
username: "",
|
||||||
|
registryURL: "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(DockerProviderSchema),
|
resolver: zodResolver(DockerProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -49,6 +50,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
|||||||
dockerImage: data.dockerImage || "",
|
dockerImage: data.dockerImage || "",
|
||||||
password: data.password || "",
|
password: data.password || "",
|
||||||
username: data.username || "",
|
username: data.username || "",
|
||||||
|
registryURL: data.registryUrl || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
@@ -59,6 +61,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
|||||||
password: values.password || null,
|
password: values.password || null,
|
||||||
applicationId,
|
applicationId,
|
||||||
username: values.username || null,
|
username: values.username || null,
|
||||||
|
registryUrl: values.registryURL || null,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Docker Provider Saved");
|
toast.success("Docker Provider Saved");
|
||||||
@@ -76,7 +79,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
|||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<div className="grid md:grid-cols-2 gap-4 ">
|
<div className="grid md:grid-cols-2 gap-4 ">
|
||||||
<div className="md:col-span-2 space-y-4">
|
<div className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="dockerImage"
|
name="dockerImage"
|
||||||
@@ -91,6 +94,19 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="registryURL"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Registry URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Registry URL" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -193,7 +193,6 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between !bg-input",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
@@ -272,7 +271,6 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between !bg-input",
|
" w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
|
|||||||
@@ -209,7 +209,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between !bg-input",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
@@ -297,7 +296,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between !bg-input",
|
" w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { ShowBuildChooseForm } from "@/components/dashboard/application/build/sh
|
|||||||
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";
|
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Toggle } from "@/components/ui/toggle";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { CheckCircle2, Terminal } from "lucide-react";
|
import { Terminal } from "lucide-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
||||||
@@ -39,27 +39,6 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Toggle
|
|
||||||
aria-label="Toggle italic"
|
|
||||||
pressed={data?.autoDeploy || false}
|
|
||||||
onPressedChange={async (enabled) => {
|
|
||||||
await update({
|
|
||||||
applicationId,
|
|
||||||
autoDeploy: enabled,
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
toast.success("Auto Deploy Updated");
|
|
||||||
await refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error to update Auto Deploy");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="flex flex-row gap-2 items-center"
|
|
||||||
>
|
|
||||||
Autodeploy
|
|
||||||
{data?.autoDeploy && <CheckCircle2 className="size-4" />}
|
|
||||||
</Toggle>
|
|
||||||
<RedbuildApplication applicationId={applicationId} />
|
<RedbuildApplication applicationId={applicationId} />
|
||||||
{data?.applicationStatus === "idle" ? (
|
{data?.applicationStatus === "idle" ? (
|
||||||
<StartApplication applicationId={applicationId} />
|
<StartApplication applicationId={applicationId} />
|
||||||
@@ -75,6 +54,27 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
|||||||
Open Terminal
|
Open Terminal
|
||||||
</Button>
|
</Button>
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
|
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||||
|
<span className="text-sm font-medium">Autodeploy</span>
|
||||||
|
<Switch
|
||||||
|
aria-label="Toggle italic"
|
||||||
|
checked={data?.autoDeploy || false}
|
||||||
|
onCheckedChange={async (enabled) => {
|
||||||
|
await update({
|
||||||
|
applicationId,
|
||||||
|
autoDeploy: enabled,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Auto Deploy Updated");
|
||||||
|
await refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error to update Auto Deploy");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="flex flex-row gap-2 items-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<ShowProviderForm applicationId={applicationId} />
|
<ShowProviderForm applicationId={applicationId} />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
@@ -29,28 +31,67 @@ export const DockerLogs = dynamic(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const badgeStateColor = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return "green";
|
||||||
|
case "exited":
|
||||||
|
case "shutdown":
|
||||||
|
return "red";
|
||||||
|
case "accepted":
|
||||||
|
case "created":
|
||||||
|
return "blue";
|
||||||
|
default:
|
||||||
|
return "default";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
appName: string;
|
appName: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||||
const { data, isLoading } = api.docker.getContainersByAppNameMatch.useQuery(
|
|
||||||
{
|
|
||||||
appName,
|
|
||||||
serverId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!appName,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const [containerId, setContainerId] = useState<string | undefined>();
|
const [containerId, setContainerId] = useState<string | undefined>();
|
||||||
|
const [option, setOption] = useState<"swarm" | "native">("native");
|
||||||
|
|
||||||
|
const { data: services, isLoading: servicesLoading } =
|
||||||
|
api.docker.getServiceContainersByAppName.useQuery(
|
||||||
|
{
|
||||||
|
appName,
|
||||||
|
serverId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!appName && option === "swarm",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: containers, isLoading: containersLoading } =
|
||||||
|
api.docker.getContainersByAppNameMatch.useQuery(
|
||||||
|
{
|
||||||
|
appName,
|
||||||
|
serverId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!appName && option === "native",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && data?.length > 0) {
|
if (option === "native") {
|
||||||
setContainerId(data[0]?.containerId);
|
if (containers && containers?.length > 0) {
|
||||||
|
setContainerId(containers[0]?.containerId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (services && services?.length > 0) {
|
||||||
|
setContainerId(services[0]?.containerId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [option, services, containers]);
|
||||||
|
|
||||||
|
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||||
|
const containersLenght =
|
||||||
|
option === "native" ? containers?.length : services?.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
@@ -62,7 +103,21 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<Label>Select a container to view logs</Label>
|
<div className="flex flex-row justify-between items-center gap-2">
|
||||||
|
<Label>Select a container to view logs</Label>
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{option === "native" ? "Native" : "Swarm"}
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={option === "native"}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setOption(checked ? "native" : "swarm");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Select onValueChange={setContainerId} value={containerId}>
|
<Select onValueChange={setContainerId} value={containerId}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -76,22 +131,45 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{data?.map((container) => (
|
{option === "native" ? (
|
||||||
<SelectItem
|
<div>
|
||||||
key={container.containerId}
|
{containers?.map((container) => (
|
||||||
value={container.containerId}
|
<SelectItem
|
||||||
>
|
key={container.containerId}
|
||||||
{container.name} ({container.containerId}) {container.state}
|
value={container.containerId}
|
||||||
</SelectItem>
|
>
|
||||||
))}
|
{container.name} ({container.containerId}){" "}
|
||||||
<SelectLabel>Containers ({data?.length})</SelectLabel>
|
<Badge variant={badgeStateColor(container.state)}>
|
||||||
|
{container.state}
|
||||||
|
</Badge>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{services?.map((container) => (
|
||||||
|
<SelectItem
|
||||||
|
key={container.containerId}
|
||||||
|
value={container.containerId}
|
||||||
|
>
|
||||||
|
{container.name} ({container.containerId}@{container.node}
|
||||||
|
)
|
||||||
|
<Badge variant={badgeStateColor(container.state)}>
|
||||||
|
{container.state}
|
||||||
|
</Badge>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SelectLabel>Containers ({containersLenght})</SelectLabel>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<DockerLogs
|
<DockerLogs
|
||||||
serverId={serverId || ""}
|
serverId={serverId || ""}
|
||||||
id="terminal"
|
|
||||||
containerId={containerId || "select-a-container"}
|
containerId={containerId || "select-a-container"}
|
||||||
|
runType={option}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
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, NumberInput } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { domain } from "@/server/db/validations/domain";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Dices } from "lucide-react";
|
||||||
|
import type z from "zod";
|
||||||
|
|
||||||
|
type Domain = z.infer<typeof domain>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
previewDeploymentId: string;
|
||||||
|
domainId?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AddPreviewDomain = ({
|
||||||
|
previewDeploymentId,
|
||||||
|
domainId = "",
|
||||||
|
children,
|
||||||
|
}: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { data, refetch } = api.domain.one.useQuery(
|
||||||
|
{
|
||||||
|
domainId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!domainId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: previewDeployment } = api.previewDeployment.one.useQuery(
|
||||||
|
{
|
||||||
|
previewDeploymentId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!previewDeploymentId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync, isError, error, isLoading } = domainId
|
||||||
|
? api.domain.update.useMutation()
|
||||||
|
: api.domain.create.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
|
||||||
|
api.domain.generateDomain.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<Domain>({
|
||||||
|
resolver: zodResolver(domain),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
form.reset({
|
||||||
|
...data,
|
||||||
|
/* Convert null to undefined */
|
||||||
|
path: data?.path || undefined,
|
||||||
|
port: data?.port || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!domainId) {
|
||||||
|
form.reset({});
|
||||||
|
}
|
||||||
|
}, [form, form.reset, data, isLoading]);
|
||||||
|
|
||||||
|
const dictionary = {
|
||||||
|
success: domainId ? "Domain Updated" : "Domain Created",
|
||||||
|
error: domainId
|
||||||
|
? "Error to update the domain"
|
||||||
|
: "Error to create the domain",
|
||||||
|
submit: domainId ? "Update" : "Create",
|
||||||
|
dialogDescription: domainId
|
||||||
|
? "In this section you can edit a domain"
|
||||||
|
: "In this section you can add domains",
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: Domain) => {
|
||||||
|
await mutateAsync({
|
||||||
|
domainId,
|
||||||
|
previewDeploymentId,
|
||||||
|
...data,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success(dictionary.success);
|
||||||
|
await utils.previewDeployment.all.invalidate({
|
||||||
|
applicationId: previewDeployment?.applicationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (domainId) {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(dictionary.error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger className="" asChild>
|
||||||
|
{children}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Domain</DialogTitle>
|
||||||
|
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="hook-form"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="grid w-full gap-8 "
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="host"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Host</FormLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="api.dokploy.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingGenerate}
|
||||||
|
onClick={() => {
|
||||||
|
generateDomain({
|
||||||
|
appName: previewDeployment?.appName || "",
|
||||||
|
serverId:
|
||||||
|
previewDeployment?.application
|
||||||
|
?.serverId || "",
|
||||||
|
})
|
||||||
|
.then((domain) => {
|
||||||
|
field.onChange(domain);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(err.message);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dices className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>Generate traefik.me domain</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="path"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Path</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={"/"} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="port"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Container Port</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<NumberInput placeholder={"3000"} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="https"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>HTTPS</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Automatically provision SSL Certificate.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{form.getValues().https && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="certificateType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="col-span-2">
|
||||||
|
<FormLabel>Certificate Provider</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value || ""}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a certificate provider" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
<SelectItem value={"letsencrypt"}>
|
||||||
|
Let's Encrypt
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button isLoading={isLoading} form="hook-form" type="submit">
|
||||||
|
{dictionary.submit}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
|
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@/utils/api";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ShowDeployment } from "../deployments/show-deployment";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
deployments: RouterOutputs["deployment"]["all"];
|
||||||
|
serverId?: string;
|
||||||
|
trigger?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowPreviewBuilds = ({
|
||||||
|
deployments,
|
||||||
|
serverId,
|
||||||
|
trigger,
|
||||||
|
}: Props) => {
|
||||||
|
const [activeLog, setActiveLog] = useState<string | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{trigger ? (
|
||||||
|
trigger
|
||||||
|
) : (
|
||||||
|
<Button className="sm:w-auto w-full" size="sm" variant="outline">
|
||||||
|
View Builds
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Preview Builds</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
See all the preview builds for this application on this Pull Request
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{deployments?.map((deployment) => (
|
||||||
|
<div
|
||||||
|
key={deployment.deploymentId}
|
||||||
|
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||||
|
{deployment.status}
|
||||||
|
|
||||||
|
<StatusTooltip
|
||||||
|
status={deployment?.status}
|
||||||
|
className="size-2.5"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{deployment.title}
|
||||||
|
</span>
|
||||||
|
{deployment.description && (
|
||||||
|
<span className="break-all text-sm text-muted-foreground">
|
||||||
|
{deployment.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<div className="text-sm capitalize text-muted-foreground">
|
||||||
|
<DateTooltip date={deployment.createdAt} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveLog(deployment.logPath);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
<ShowDeployment
|
||||||
|
serverId={serverId || ""}
|
||||||
|
open={activeLog !== null}
|
||||||
|
onClose={() => setActiveLog(null)}
|
||||||
|
logPath={activeLog}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||||
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import {
|
||||||
|
ExternalLink,
|
||||||
|
FileText,
|
||||||
|
GitPullRequest,
|
||||||
|
Layers,
|
||||||
|
PenSquare,
|
||||||
|
RocketIcon,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
||||||
|
import { AddPreviewDomain } from "./add-preview-domain";
|
||||||
|
import { ShowPreviewBuilds } from "./show-preview-builds";
|
||||||
|
import { ShowPreviewSettings } from "./show-preview-settings";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
applicationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||||
|
const { data } = api.application.one.useQuery({ applicationId });
|
||||||
|
|
||||||
|
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
||||||
|
api.previewDeployment.delete.useMutation();
|
||||||
|
|
||||||
|
const { data: previewDeployments, refetch: refetchPreviewDeployments } =
|
||||||
|
api.previewDeployment.all.useQuery(
|
||||||
|
{ applicationId },
|
||||||
|
{
|
||||||
|
enabled: !!applicationId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeletePreviewDeployment = async (previewDeploymentId: string) => {
|
||||||
|
deletePreviewDeployment({
|
||||||
|
previewDeploymentId: previewDeploymentId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
refetchPreviewDeployments();
|
||||||
|
toast.success("Preview deployment deleted");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-background">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<CardTitle className="text-xl">Preview Deployments</CardTitle>
|
||||||
|
<CardDescription>See all the preview deployments</CardDescription>
|
||||||
|
</div>
|
||||||
|
{data?.isPreviewDeploymentsActive && (
|
||||||
|
<ShowPreviewSettings applicationId={applicationId} />
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
{data?.isPreviewDeploymentsActive ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
|
<span>
|
||||||
|
Preview deployments are a way to test your application before it
|
||||||
|
is deployed to production. It will create a new deployment for
|
||||||
|
each pull request you create.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!previewDeployments?.length ? (
|
||||||
|
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||||
|
<RocketIcon className="size-8 text-muted-foreground" />
|
||||||
|
<span className="text-base text-muted-foreground">
|
||||||
|
No preview deployments found
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{previewDeployments?.map((deployment) => {
|
||||||
|
const deploymentUrl = `${deployment.domain?.https ? "https" : "http"}://${deployment.domain?.host}${deployment.domain?.path || "/"}`;
|
||||||
|
const status = deployment.previewStatus;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={deployment.previewDeploymentId}
|
||||||
|
className="group relative overflow-hidden border rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`absolute left-0 top-0 w-1 h-full ${
|
||||||
|
status === "done"
|
||||||
|
? "bg-green-500"
|
||||||
|
: status === "running"
|
||||||
|
? "bg-yellow-500"
|
||||||
|
: "bg-red-500"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<GitPullRequest className="size-5 text-muted-foreground mt-1 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">
|
||||||
|
{deployment.pullRequestTitle}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground mt-1">
|
||||||
|
{deployment.branch}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="gap-2">
|
||||||
|
<StatusTooltip
|
||||||
|
status={deployment.previewStatus}
|
||||||
|
className="size-2"
|
||||||
|
/>
|
||||||
|
<DateTooltip date={deployment.createdAt} />
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pl-8 space-y-3">
|
||||||
|
<div className="relative flex-grow">
|
||||||
|
<Input
|
||||||
|
value={deploymentUrl}
|
||||||
|
readOnly
|
||||||
|
className="pr-8 text-sm text-blue-500 hover:text-blue-600 cursor-pointer"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(deploymentUrl, "_blank")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ExternalLink className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 opacity-80 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(deployment.pullRequestURL, "_blank")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<GithubIcon className="size-4" />
|
||||||
|
Pull Request
|
||||||
|
</Button>
|
||||||
|
<ShowModalLogs
|
||||||
|
appName={deployment.appName}
|
||||||
|
serverId={data?.serverId || ""}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<FileText className="size-4" />
|
||||||
|
Logs
|
||||||
|
</Button>
|
||||||
|
</ShowModalLogs>
|
||||||
|
|
||||||
|
<ShowPreviewBuilds
|
||||||
|
deployments={deployment.deployments || []}
|
||||||
|
serverId={data?.serverId || ""}
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Layers className="size-4" />
|
||||||
|
Builds
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AddPreviewDomain
|
||||||
|
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
||||||
|
domainId={deployment.domain?.domainId}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<PenSquare className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</AddPreviewDomain>
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Preview"
|
||||||
|
description="Are you sure you want to delete this preview?"
|
||||||
|
onClick={() =>
|
||||||
|
handleDeletePreviewDeployment(
|
||||||
|
deployment.previewDeploymentId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
isLoading={isLoading}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||||
|
<RocketIcon className="size-8 text-muted-foreground" />
|
||||||
|
<span className="text-base text-muted-foreground">
|
||||||
|
Preview deployments are disabled for this application, please
|
||||||
|
enable it
|
||||||
|
</span>
|
||||||
|
<ShowPreviewSettings applicationId={applicationId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
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, NumberInput } from "@/components/ui/input";
|
||||||
|
import { Secrets } from "@/components/ui/secrets";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Settings2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
env: z.string(),
|
||||||
|
buildArgs: z.string(),
|
||||||
|
wildcardDomain: z.string(),
|
||||||
|
port: z.number(),
|
||||||
|
previewLimit: z.number(),
|
||||||
|
previewHttps: z.boolean(),
|
||||||
|
previewPath: z.string(),
|
||||||
|
previewCertificateType: z.enum(["letsencrypt", "none"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Schema = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
applicationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isEnabled, setIsEnabled] = useState(false);
|
||||||
|
const { mutateAsync: updateApplication, isLoading } =
|
||||||
|
api.application.update.useMutation();
|
||||||
|
|
||||||
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
|
|
||||||
|
const form = useForm<Schema>({
|
||||||
|
defaultValues: {
|
||||||
|
env: "",
|
||||||
|
wildcardDomain: "*.traefik.me",
|
||||||
|
port: 3000,
|
||||||
|
previewLimit: 3,
|
||||||
|
previewHttps: false,
|
||||||
|
previewPath: "/",
|
||||||
|
previewCertificateType: "none",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewHttps = form.watch("previewHttps");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
||||||
|
}, [data?.isPreviewDeploymentsActive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
form.reset({
|
||||||
|
env: data.previewEnv || "",
|
||||||
|
buildArgs: data.previewBuildArgs || "",
|
||||||
|
wildcardDomain: data.previewWildcard || "*.traefik.me",
|
||||||
|
port: data.previewPort || 3000,
|
||||||
|
previewLimit: data.previewLimit || 3,
|
||||||
|
previewHttps: data.previewHttps || false,
|
||||||
|
previewPath: data.previewPath || "/",
|
||||||
|
previewCertificateType: data.previewCertificateType || "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: Schema) => {
|
||||||
|
updateApplication({
|
||||||
|
previewEnv: formData.env,
|
||||||
|
previewBuildArgs: formData.buildArgs,
|
||||||
|
previewWildcard: formData.wildcardDomain,
|
||||||
|
previewPort: formData.port,
|
||||||
|
applicationId,
|
||||||
|
previewLimit: formData.previewLimit,
|
||||||
|
previewHttps: formData.previewHttps,
|
||||||
|
previewPath: formData.previewPath,
|
||||||
|
previewCertificateType: formData.previewCertificateType,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Preview Deployments settings updated");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline">
|
||||||
|
<Settings2 className="size-4" />
|
||||||
|
Configure
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl w-full">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Preview Deployment Settings</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Adjust the settings for preview deployments of this application,
|
||||||
|
including environment variables, build options, and deployment
|
||||||
|
rules.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
id="hook-form-delete-application"
|
||||||
|
className="grid w-full gap-4"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="wildcardDomain"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Wildcard Domain</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="*.traefik.me" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="previewPath"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Preview Path</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="/" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="port"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Port</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<NumberInput placeholder="3000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="previewLimit"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Preview Limit</FormLabel>
|
||||||
|
{/* <FormDescription>
|
||||||
|
Set the limit of preview deployments that can be
|
||||||
|
created for this app.
|
||||||
|
</FormDescription> */}
|
||||||
|
<FormControl>
|
||||||
|
<NumberInput placeholder="3000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="previewHttps"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>HTTPS</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Automatically provision SSL Certificate.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{previewHttps && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="previewCertificateType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Certificate Provider</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value || ""}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a certificate provider" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
<SelectItem value={"letsencrypt"}>
|
||||||
|
Let's Encrypt
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className="flex flex-row items-center justify-between rounded-lg border p-4 col-span-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel className="text-base">
|
||||||
|
Enable preview deployments
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Enable or disable preview deployments for this
|
||||||
|
application.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isEnabled}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
updateApplication({
|
||||||
|
isPreviewDeploymentsActive: checked,
|
||||||
|
applicationId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
refetch();
|
||||||
|
toast.success("Preview deployments enabled");
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="env"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Secrets
|
||||||
|
name="env"
|
||||||
|
title="Environment Settings"
|
||||||
|
description="You can add environment variables to your resource."
|
||||||
|
placeholder={[
|
||||||
|
"NODE_ENV=production",
|
||||||
|
"PORT=3000",
|
||||||
|
].join("\n")}
|
||||||
|
/>
|
||||||
|
{/* <CodeEditor
|
||||||
|
lineWrapping
|
||||||
|
language="properties"
|
||||||
|
wrapperClassName="h-[25rem] font-mono"
|
||||||
|
placeholder={`NODE_ENV=production
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
`}
|
||||||
|
{...field}
|
||||||
|
/> */}
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{data?.buildType === "dockerfile" && (
|
||||||
|
<Secrets
|
||||||
|
name="buildArgs"
|
||||||
|
title="Build-time Variables"
|
||||||
|
description={
|
||||||
|
<span>
|
||||||
|
Available only at build-time. See documentation
|
||||||
|
<a
|
||||||
|
className="text-primary"
|
||||||
|
href="https://docs.docker.com/build/guide/build-args/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
placeholder="NPM_TOKEN=xyz"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
form="hook-form-delete-application"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
{/* */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -91,7 +92,7 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-xl">Run Command</CardTitle>
|
<CardTitle className="text-xl">Run Command</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Append a custom command to the compose file
|
Override a custom command to the compose file
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -101,6 +102,12 @@ export const AddCommandCompose = ({ composeId }: Props) => {
|
|||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="grid w-full gap-4"
|
className="grid w-full gap-4"
|
||||||
>
|
>
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
Modifying the default command may affect deployment stability,
|
||||||
|
impacting logs and monitoring. Proceed carefully and test
|
||||||
|
thoroughly. By default, the command starts with{" "}
|
||||||
|
<strong>docker</strong>.
|
||||||
|
</AlertBlock>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -1,63 +1,186 @@
|
|||||||
import {
|
import { Badge } from "@/components/ui/badge";
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
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 { api } from "@/utils/api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Copy } from "lucide-react";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const deleteComposeSchema = z.object({
|
||||||
|
projectName: z.string().min(1, {
|
||||||
|
message: "Compose name is required",
|
||||||
|
}),
|
||||||
|
deleteVolumes: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type DeleteCompose = z.infer<typeof deleteComposeSchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
composeId: string;
|
composeId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteCompose = ({ composeId }: Props) => {
|
export const DeleteCompose = ({ composeId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { mutateAsync, isLoading } = api.compose.delete.useMutation();
|
const { mutateAsync, isLoading } = api.compose.delete.useMutation();
|
||||||
|
const { data } = api.compose.one.useQuery(
|
||||||
|
{ composeId },
|
||||||
|
{ enabled: !!composeId },
|
||||||
|
);
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
|
const form = useForm<DeleteCompose>({
|
||||||
|
defaultValues: {
|
||||||
|
projectName: "",
|
||||||
|
deleteVolumes: false,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(deleteComposeSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: DeleteCompose) => {
|
||||||
|
const expectedName = `${data?.name}/${data?.appName}`;
|
||||||
|
if (formData.projectName === expectedName) {
|
||||||
|
const { deleteVolumes } = formData;
|
||||||
|
await mutateAsync({ composeId, deleteVolumes })
|
||||||
|
.then((result) => {
|
||||||
|
push(`/dashboard/project/${result?.projectId}`);
|
||||||
|
toast.success("Compose deleted successfully");
|
||||||
|
setIsOpen(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting the compose");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.setError("projectName", {
|
||||||
|
message: `Project name must match "${expectedName}"`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<AlertDialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<TrashIcon className="size-4 text-muted-foreground" />
|
<TrashIcon className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</DialogTrigger>
|
||||||
<AlertDialogContent>
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
<AlertDialogHeader>
|
<DialogHeader>
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||||
<AlertDialogDescription>
|
<DialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
compose and all its services.
|
compose. If you are sure please enter the compose name to delete
|
||||||
</AlertDialogDescription>
|
this compose.
|
||||||
</AlertDialogHeader>
|
</DialogDescription>
|
||||||
<AlertDialogFooter>
|
</DialogHeader>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<div className="grid gap-4">
|
||||||
<AlertDialogAction
|
<Form {...form}>
|
||||||
onClick={async () => {
|
<form
|
||||||
await mutateAsync({
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
composeId,
|
id="hook-form-delete-compose"
|
||||||
})
|
className="grid w-full gap-4"
|
||||||
.then((data) => {
|
>
|
||||||
push(`/dashboard/project/${data?.projectId}`);
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="projectName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
To confirm, type{" "}
|
||||||
|
<Badge
|
||||||
|
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (data?.name && data?.appName) {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`${data.name}/${data.appName}`,
|
||||||
|
);
|
||||||
|
toast.success("Copied to clipboard. Be careful!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data?.name}/{data?.appName}
|
||||||
|
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||||
|
</Badge>{" "}
|
||||||
|
in the box below:
|
||||||
|
</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter compose name to confirm"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="deleteVolumes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
toast.success("Compose delete succesfully");
|
<FormLabel className="ml-2">
|
||||||
})
|
Delete volumes associated with this compose
|
||||||
.catch(() => {
|
</FormLabel>
|
||||||
toast.error("Error to delete the compose");
|
</div>
|
||||||
});
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
form="hook-form-delete-compose"
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</AlertDialogAction>
|
</Button>
|
||||||
</AlertDialogFooter>
|
</DialogFooter>
|
||||||
</AlertDialogContent>
|
</DialogContent>
|
||||||
</AlertDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -5,7 +7,10 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { TerminalLine } from "../../docker/logs/terminal-line";
|
||||||
|
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logPath: string | null;
|
logPath: string | null;
|
||||||
@@ -20,8 +25,25 @@ export const ShowDeploymentCompose = ({
|
|||||||
serverId,
|
serverId,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [data, setData] = useState("");
|
const [data, setData] = useState("");
|
||||||
const endOfLogsRef = useRef<HTMLDivElement>(null);
|
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||||
|
const [showExtraLogs, setShowExtraLogs] = useState(false);
|
||||||
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
|
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
if (autoScroll && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!scrollRef.current) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||||
|
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
|
||||||
|
setAutoScroll(isAtBottom);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !logPath) return;
|
if (!open || !logPath) return;
|
||||||
@@ -54,13 +76,34 @@ export const ShowDeploymentCompose = ({
|
|||||||
};
|
};
|
||||||
}, [logPath, open]);
|
}, [logPath, open]);
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
useEffect(() => {
|
||||||
endOfLogsRef.current?.scrollIntoView({ behavior: "smooth" });
|
const logs = parseLogs(data);
|
||||||
};
|
let filteredLogsResult = logs;
|
||||||
|
if (serverId) {
|
||||||
|
let hideSubsequentLogs = false;
|
||||||
|
filteredLogsResult = logs.filter((log) => {
|
||||||
|
if (
|
||||||
|
log.message.includes(
|
||||||
|
"===================================EXTRA LOGS============================================",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
hideSubsequentLogs = true;
|
||||||
|
return showExtraLogs;
|
||||||
|
}
|
||||||
|
return showExtraLogs ? true : !hideSubsequentLogs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredLogs(filteredLogsResult);
|
||||||
|
}, [data, showExtraLogs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [data]);
|
|
||||||
|
if (autoScroll && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [filteredLogs, autoScroll]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -78,21 +121,50 @@ export const ShowDeploymentCompose = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent className={"sm:max-w-5xl overflow-y-auto max-h-screen"}>
|
<DialogContent className={"sm:max-w-5xl max-h-screen"}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Deployment</DialogTitle>
|
<DialogTitle>Deployment</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription className="flex items-center gap-2">
|
||||||
See all the details of this deployment
|
<span>
|
||||||
|
See all the details of this deployment |{" "}
|
||||||
|
<Badge variant="blank" className="text-xs">
|
||||||
|
{filteredLogs.length} lines
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
{serverId && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="show-extra-logs"
|
||||||
|
checked={showExtraLogs}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setShowExtraLogs(checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="show-extra-logs"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
Show Extra Logs
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem]">
|
<div
|
||||||
<code>
|
ref={scrollRef}
|
||||||
<pre className="whitespace-pre-wrap break-words">
|
onScroll={handleScroll}
|
||||||
{data || "Loading..."}
|
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||||
</pre>
|
>
|
||||||
<div ref={endOfLogsRef} />
|
{filteredLogs.length > 0 ? (
|
||||||
</code>
|
filteredLogs.map((log: LogLine, index: number) => (
|
||||||
|
<TerminalLine key={index} log={log} noTimestamp />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -400,21 +400,21 @@ export const AddDomainCompose = ({
|
|||||||
name="certificateType"
|
name="certificateType"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="col-span-2">
|
<FormItem className="col-span-2">
|
||||||
<FormLabel>Certificate</FormLabel>
|
<FormLabel>Certificate Provider</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={field.onChange}
|
||||||
defaultValue={field.value || ""}
|
defaultValue={field.value || ""}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a certificate" />
|
<SelectValue placeholder="Select a certificate provider" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">None</SelectItem>
|
<SelectItem value="none">None</SelectItem>
|
||||||
<SelectItem value={"letsencrypt"}>
|
<SelectItem value={"letsencrypt"}>
|
||||||
Letsencrypt (Default)
|
Let's Encrypt
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Toggle } from "@/components/ui/toggle";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { CheckCircle2, ExternalLink, Globe, Terminal } from "lucide-react";
|
import { CheckCircle2, ExternalLink, Globe, Terminal } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal";
|
||||||
|
import { StartCompose } from "../start-compose";
|
||||||
import { DeployCompose } from "./deploy-compose";
|
import { DeployCompose } from "./deploy-compose";
|
||||||
import { RedbuildCompose } from "./rebuild-compose";
|
import { RedbuildCompose } from "./rebuild-compose";
|
||||||
import { StopCompose } from "./stop-compose";
|
import { StopCompose } from "./stop-compose";
|
||||||
@@ -50,28 +51,11 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-row gap-4 w-full flex-wrap ">
|
<div className="flex flex-row gap-4 w-full flex-wrap ">
|
||||||
<DeployCompose composeId={composeId} />
|
<DeployCompose composeId={composeId} />
|
||||||
<Toggle
|
|
||||||
aria-label="Toggle italic"
|
|
||||||
pressed={data?.autoDeploy || false}
|
|
||||||
onPressedChange={async (enabled) => {
|
|
||||||
await update({
|
|
||||||
composeId,
|
|
||||||
autoDeploy: enabled,
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
toast.success("Auto Deploy Updated");
|
|
||||||
await refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error to update Auto Deploy");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="flex flex-row gap-2 items-center"
|
|
||||||
>
|
|
||||||
Autodeploy {data?.autoDeploy && <CheckCircle2 className="size-4" />}
|
|
||||||
</Toggle>
|
|
||||||
<RedbuildCompose composeId={composeId} />
|
<RedbuildCompose composeId={composeId} />
|
||||||
{data?.composeType === "docker-compose" && (
|
{data?.composeType === "docker-compose" &&
|
||||||
|
data?.composeStatus === "idle" ? (
|
||||||
|
<StartCompose composeId={composeId} />
|
||||||
|
) : (
|
||||||
<StopCompose composeId={composeId} />
|
<StopCompose composeId={composeId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -84,6 +68,27 @@ export const ComposeActions = ({ composeId }: Props) => {
|
|||||||
Open Terminal
|
Open Terminal
|
||||||
</Button>
|
</Button>
|
||||||
</DockerTerminalModal>
|
</DockerTerminalModal>
|
||||||
|
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||||
|
<span className="text-sm font-medium">Autodeploy</span>
|
||||||
|
<Switch
|
||||||
|
aria-label="Toggle italic"
|
||||||
|
checked={data?.autoDeploy || false}
|
||||||
|
onCheckedChange={async (enabled) => {
|
||||||
|
await update({
|
||||||
|
composeId,
|
||||||
|
autoDeploy: enabled,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Auto Deploy Updated");
|
||||||
|
await refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error to update Auto Deploy");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="flex flex-row gap-2 items-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{domains.length > 0 && (
|
{domains.length > 0 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -18,6 +19,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DeployCompose = ({ composeId }: Props) => {
|
export const DeployCompose = ({ composeId }: Props) => {
|
||||||
|
const router = useRouter();
|
||||||
const { data, refetch } = api.compose.one.useQuery(
|
const { data, refetch } = api.compose.one.useQuery(
|
||||||
{
|
{
|
||||||
composeId,
|
composeId,
|
||||||
@@ -48,9 +50,15 @@ export const DeployCompose = ({ composeId }: Props) => {
|
|||||||
await refetch();
|
await refetch();
|
||||||
await deploy({
|
await deploy({
|
||||||
composeId,
|
composeId,
|
||||||
}).catch(() => {
|
})
|
||||||
toast.error("Error to deploy Compose");
|
.then(async () => {
|
||||||
});
|
router.push(
|
||||||
|
`/dashboard/project/${data?.project.projectId}/services/compose/${composeId}?tab=deployments`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error to deploy Compose");
|
||||||
|
});
|
||||||
|
|
||||||
await refetch();
|
await refetch();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -204,7 +204,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between !bg-input",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
@@ -283,7 +282,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between !bg-input",
|
" w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
|
|||||||
@@ -195,7 +195,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between !bg-input",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
@@ -274,7 +273,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between !bg-input",
|
" w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
|
|||||||
@@ -211,7 +211,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between !bg-input",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
@@ -299,7 +298,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between !bg-input",
|
" w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
|
|||||||
165
apps/dokploy/components/dashboard/compose/logs/show-stack.tsx
Normal file
165
apps/dokploy/components/dashboard/compose/logs/show-stack.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
export const DockerLogs = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/dashboard/docker/logs/docker-logs-id").then(
|
||||||
|
(e) => e.DockerLogsId,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
appName: string;
|
||||||
|
serverId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeStateColor;
|
||||||
|
|
||||||
|
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||||
|
const [option, setOption] = useState<"swarm" | "native">("native");
|
||||||
|
const [containerId, setContainerId] = useState<string | undefined>();
|
||||||
|
|
||||||
|
const { data: services, isLoading: servicesLoading } =
|
||||||
|
api.docker.getStackContainersByAppName.useQuery(
|
||||||
|
{
|
||||||
|
appName,
|
||||||
|
serverId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!appName && option === "swarm",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: containers, isLoading: containersLoading } =
|
||||||
|
api.docker.getContainersByAppNameMatch.useQuery(
|
||||||
|
{
|
||||||
|
appName,
|
||||||
|
appType: "stack",
|
||||||
|
serverId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!appName && option === "native",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (option === "native") {
|
||||||
|
if (containers && containers?.length > 0) {
|
||||||
|
setContainerId(containers[0]?.containerId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (services && services?.length > 0) {
|
||||||
|
setContainerId(services[0]?.containerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [option, services, containers]);
|
||||||
|
|
||||||
|
const isLoading = option === "native" ? containersLoading : servicesLoading;
|
||||||
|
const containersLenght =
|
||||||
|
option === "native" ? containers?.length : services?.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-background">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl">Logs</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Watch the logs of the application in real time
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-row justify-between items-center gap-2">
|
||||||
|
<Label>Select a container to view logs</Label>
|
||||||
|
<div className="flex flex-row gap-2 items-center">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{option === "native" ? "Native" : "Swarm"}
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={option === "native"}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setOption(checked ? "native" : "swarm");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Select onValueChange={setContainerId} value={containerId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<span>Loading...</span>
|
||||||
|
<Loader2 className="animate-spin size-4" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SelectValue placeholder="Select a container" />
|
||||||
|
)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{option === "native" ? (
|
||||||
|
<div>
|
||||||
|
{containers?.map((container) => (
|
||||||
|
<SelectItem
|
||||||
|
key={container.containerId}
|
||||||
|
value={container.containerId}
|
||||||
|
>
|
||||||
|
{container.name} ({container.containerId}){" "}
|
||||||
|
<Badge variant={badgeStateColor(container.state)}>
|
||||||
|
{container.state}
|
||||||
|
</Badge>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{services?.map((container) => (
|
||||||
|
<SelectItem
|
||||||
|
key={container.containerId}
|
||||||
|
value={container.containerId}
|
||||||
|
>
|
||||||
|
{container.name} ({container.containerId}@{container.node}
|
||||||
|
)
|
||||||
|
<Badge variant={badgeStateColor(container.state)}>
|
||||||
|
{container.state}
|
||||||
|
</Badge>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SelectLabel>Containers ({containersLenght})</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<DockerLogs
|
||||||
|
serverId={serverId || ""}
|
||||||
|
containerId={containerId || "select-a-container"}
|
||||||
|
runType={option}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -87,7 +89,10 @@ export const ShowDockerLogsCompose = ({
|
|||||||
key={container.containerId}
|
key={container.containerId}
|
||||||
value={container.containerId}
|
value={container.containerId}
|
||||||
>
|
>
|
||||||
{container.name} ({container.containerId}) {container.state}
|
{container.name} ({container.containerId}){" "}
|
||||||
|
<Badge variant={badgeStateColor(container.state)}>
|
||||||
|
{container.state}
|
||||||
|
</Badge>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
<SelectLabel>Containers ({data?.length})</SelectLabel>
|
<SelectLabel>Containers ({data?.length})</SelectLabel>
|
||||||
@@ -96,8 +101,8 @@ export const ShowDockerLogsCompose = ({
|
|||||||
</Select>
|
</Select>
|
||||||
<DockerLogs
|
<DockerLogs
|
||||||
serverId={serverId || ""}
|
serverId={serverId || ""}
|
||||||
id="terminal"
|
|
||||||
containerId={containerId || "select-a-container"}
|
containerId={containerId || "select-a-container"}
|
||||||
|
runType="native"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
65
apps/dokploy/components/dashboard/compose/start-compose.tsx
Normal file
65
apps/dokploy/components/dashboard/compose/start-compose.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
composeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StartCompose = ({ composeId }: Props) => {
|
||||||
|
const { mutateAsync, isLoading } = api.compose.start.useMutation();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="secondary" isLoading={isLoading}>
|
||||||
|
Start
|
||||||
|
<CheckCircle2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Are you sure to start the compose?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will start the compose
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={async () => {
|
||||||
|
await mutateAsync({
|
||||||
|
composeId,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
await utils.compose.one.invalidate({
|
||||||
|
composeId,
|
||||||
|
});
|
||||||
|
toast.success("Compose started succesfully");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error to start the Compose");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
65
apps/dokploy/components/dashboard/compose/stop-compose.tsx
Normal file
65
apps/dokploy/components/dashboard/compose/stop-compose.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { Ban } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
composeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StopCompose = ({ composeId }: Props) => {
|
||||||
|
const { mutateAsync, isLoading } = api.compose.stop.useMutation();
|
||||||
|
const utils = api.useUtils();
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive" isLoading={isLoading}>
|
||||||
|
Stop
|
||||||
|
<Ban className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
Are you absolutely sure to stop the compose?
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will stop the compose
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={async () => {
|
||||||
|
await mutateAsync({
|
||||||
|
composeId,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
await utils.compose.one.invalidate({
|
||||||
|
composeId,
|
||||||
|
});
|
||||||
|
toast.success("Compose stopped succesfully");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error to stop the Compose");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -160,7 +160,6 @@ export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between !bg-input",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
|
|||||||
@@ -144,7 +144,6 @@ export const UpdateBackup = ({ backupId, refetch }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between !bg-input",
|
"w-full justify-between !bg-input",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -34,7 +35,7 @@ export const ShowContainerConfig = ({ containerId, serverId }: Props) => {
|
|||||||
View Config
|
View Config
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className={"w-full md:w-[70vw] max-w-max"}>
|
<DialogContent className={"w-full md:w-[70vw] min-w-[70vw]"}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Container Config</DialogTitle>
|
<DialogTitle>Container Config</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -44,7 +45,13 @@ export const ShowContainerConfig = ({ containerId, serverId }: Props) => {
|
|||||||
<div className="text-wrap rounded-lg border p-4 text-sm bg-card overflow-y-auto max-h-[80vh]">
|
<div className="text-wrap rounded-lg border p-4 text-sm bg-card overflow-y-auto max-h-[80vh]">
|
||||||
<code>
|
<code>
|
||||||
<pre className="whitespace-pre-wrap break-words">
|
<pre className="whitespace-pre-wrap break-words">
|
||||||
{JSON.stringify(data, null, 2)}
|
<CodeEditor
|
||||||
|
language="json"
|
||||||
|
lineWrapping
|
||||||
|
lineNumbers={false}
|
||||||
|
readOnly
|
||||||
|
value={JSON.stringify(data, null, 2)}
|
||||||
|
/>
|
||||||
</pre>
|
</pre>
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,114 +1,296 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { api } from "@/utils/api";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import { Download as DownloadIcon, Loader2 } from "lucide-react";
|
||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef } from "react";
|
||||||
import { FitAddon } from "xterm-addon-fit";
|
import { LineCountFilter } from "./line-count-filter";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
|
||||||
|
import { StatusLogsFilter } from "./status-logs-filter";
|
||||||
|
import { TerminalLine } from "./terminal-line";
|
||||||
|
import { type LogLine, getLogType, parseLogs } from "./utils";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
|
||||||
containerId: string;
|
containerId: string;
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
|
runType: "swarm" | "native";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const priorities = [
|
||||||
|
{
|
||||||
|
label: "Info",
|
||||||
|
value: "info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Success",
|
||||||
|
value: "success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Warning",
|
||||||
|
value: "warning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Debug",
|
||||||
|
value: "debug",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Error",
|
||||||
|
value: "error",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const DockerLogsId: React.FC<Props> = ({
|
export const DockerLogsId: React.FC<Props> = ({
|
||||||
id,
|
|
||||||
containerId,
|
containerId,
|
||||||
serverId,
|
serverId,
|
||||||
|
runType,
|
||||||
}) => {
|
}) => {
|
||||||
const [term, setTerm] = React.useState<Terminal>();
|
const { data } = api.docker.getConfig.useQuery(
|
||||||
const [lines, setLines] = React.useState<number>(40);
|
{
|
||||||
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
|
containerId,
|
||||||
|
serverId: serverId ?? undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!containerId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const [rawLogs, setRawLogs] = React.useState("");
|
||||||
|
const [filteredLogs, setFilteredLogs] = React.useState<LogLine[]>([]);
|
||||||
|
const [autoScroll, setAutoScroll] = React.useState(true);
|
||||||
|
const [lines, setLines] = React.useState<number>(100);
|
||||||
|
const [search, setSearch] = React.useState<string>("");
|
||||||
|
const [showTimestamp, setShowTimestamp] = React.useState(true);
|
||||||
|
const [since, setSince] = React.useState<TimeFilter>("all");
|
||||||
|
const [typeFilter, setTypeFilter] = React.useState<string[]>([]);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
if (autoScroll && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!scrollRef.current) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||||
|
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
|
||||||
|
setAutoScroll(isAtBottom);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearch(e.target.value || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLines = (lines: number) => {
|
||||||
|
setRawLogs("");
|
||||||
|
setFilteredLogs([]);
|
||||||
|
setLines(lines);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSince = (value: TimeFilter) => {
|
||||||
|
setRawLogs("");
|
||||||
|
setFilteredLogs([]);
|
||||||
|
setSince(value);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// if (containerId === "select-a-container") {
|
if (!containerId) return;
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
const container = document.getElementById(id);
|
|
||||||
if (container) {
|
|
||||||
container.innerHTML = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wsRef.current) {
|
let isCurrentConnection = true;
|
||||||
if (wsRef.current.readyState === WebSocket.OPEN) {
|
let noDataTimeout: NodeJS.Timeout;
|
||||||
wsRef.current.close();
|
setIsLoading(true);
|
||||||
}
|
setRawLogs("");
|
||||||
wsRef.current = null;
|
setFilteredLogs([]);
|
||||||
}
|
|
||||||
const termi = new Terminal({
|
|
||||||
cursorBlink: true,
|
|
||||||
cols: 80,
|
|
||||||
rows: 30,
|
|
||||||
lineHeight: 1.25,
|
|
||||||
fontWeight: 400,
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily:
|
|
||||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
|
||||||
|
|
||||||
convertEol: true,
|
|
||||||
theme: {
|
|
||||||
cursor: "transparent",
|
|
||||||
background: "rgba(0, 0, 0, 0)",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
const params = new globalThis.URLSearchParams({
|
||||||
|
containerId,
|
||||||
|
tail: lines.toString(),
|
||||||
|
since,
|
||||||
|
search,
|
||||||
|
runType,
|
||||||
|
});
|
||||||
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/docker-container-logs?containerId=${containerId}&tail=${lines}${serverId ? `&serverId=${serverId}` : ""}`;
|
if (serverId) {
|
||||||
|
params.append("serverId", serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsUrl = `${protocol}//${
|
||||||
|
window.location.host
|
||||||
|
}/docker-container-logs?${params.toString()}`;
|
||||||
|
console.log("Connecting to WebSocket:", wsUrl);
|
||||||
const ws = new WebSocket(wsUrl);
|
const ws = new WebSocket(wsUrl);
|
||||||
wsRef.current = ws;
|
|
||||||
const fitAddon = new FitAddon();
|
|
||||||
termi.loadAddon(fitAddon);
|
|
||||||
// @ts-ignore
|
|
||||||
termi.open(container);
|
|
||||||
fitAddon.fit();
|
|
||||||
termi.focus();
|
|
||||||
setTerm(termi);
|
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
const resetNoDataTimeout = () => {
|
||||||
console.error("WebSocket error: ", error);
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||||
|
noDataTimeout = setTimeout(() => {
|
||||||
|
if (isCurrentConnection) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, 2000); // Wait 2 seconds for data before showing "No logs found"
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (!isCurrentConnection) {
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("WebSocket connected");
|
||||||
|
resetNoDataTimeout();
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
termi.write(e.data);
|
if (!isCurrentConnection) return;
|
||||||
|
setRawLogs((prev) => prev + e.data);
|
||||||
|
setIsLoading(false);
|
||||||
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
if (!isCurrentConnection) return;
|
||||||
|
console.error("WebSocket error:", error);
|
||||||
|
setIsLoading(false);
|
||||||
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = (e) => {
|
ws.onclose = (e) => {
|
||||||
console.log(e.reason);
|
if (!isCurrentConnection) return;
|
||||||
|
console.log("WebSocket closed:", e.reason);
|
||||||
termi.write(`Connection closed!\nReason: ${e.reason}\n`);
|
setIsLoading(false);
|
||||||
wsRef.current = null;
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
isCurrentConnection = false;
|
||||||
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.close();
|
ws.close();
|
||||||
wsRef.current = null;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [lines, containerId]);
|
}, [containerId, serverId, lines, search, since]);
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const logContent = filteredLogs
|
||||||
|
.map(
|
||||||
|
({ timestamp, message }: { timestamp: Date | null; message: string }) =>
|
||||||
|
`${timestamp?.toISOString() || "No timestamp"} ${message}`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const blob = new Blob([logContent], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
const appName = data.Name.replace("/", "") || "app";
|
||||||
|
const isoDate = new Date().toISOString();
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${appName}-${isoDate.slice(0, 10).replace(/-/g, "")}_${isoDate
|
||||||
|
.slice(11, 19)
|
||||||
|
.replace(/:/g, "")}.log.txt`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilter = (logs: LogLine[]) => {
|
||||||
|
return logs.filter((log) => {
|
||||||
|
const logType = getLogType(log.message).type;
|
||||||
|
|
||||||
|
if (typeFilter.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeFilter.includes(logType);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
term?.clear();
|
setRawLogs("");
|
||||||
}, [lines, term]);
|
setFilteredLogs([]);
|
||||||
|
}, [containerId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const logs = parseLogs(rawLogs);
|
||||||
|
const filtered = handleFilter(logs);
|
||||||
|
setFilteredLogs(filtered);
|
||||||
|
}, [rawLogs, search, lines, since, typeFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
if (autoScroll && scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [filteredLogs, autoScroll]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="rounded-lg overflow-hidden">
|
||||||
<Label>
|
<div className="space-y-4">
|
||||||
<span>Number of lines to show</span>
|
<div className="flex flex-wrap justify-between items-start sm:items-center gap-4">
|
||||||
</Label>
|
<div className="flex flex-wrap gap-4">
|
||||||
<Input
|
<LineCountFilter value={lines} onValueChange={handleLines} />
|
||||||
type="text"
|
|
||||||
placeholder="Number of lines to show (Defaults to 35)"
|
|
||||||
value={lines}
|
|
||||||
onChange={(e) => {
|
|
||||||
setLines(Number(e.target.value) || 1);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-full h-full rounded-lg p-2 bg-[#19191A]">
|
<SinceLogsFilter
|
||||||
<div id={id} />
|
value={since}
|
||||||
|
onValueChange={handleSince}
|
||||||
|
showTimestamp={showTimestamp}
|
||||||
|
onTimestampChange={setShowTimestamp}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatusLogsFilter
|
||||||
|
value={typeFilter}
|
||||||
|
setValue={setTypeFilter}
|
||||||
|
title="Log type"
|
||||||
|
options={priorities}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search logs..."
|
||||||
|
value={search}
|
||||||
|
onChange={handleSearch}
|
||||||
|
className="inline-flex h-9 text-sm placeholder-gray-400 w-full sm:w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 sm:w-auto w-full"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={filteredLogs.length === 0 || !data?.Name}
|
||||||
|
>
|
||||||
|
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||||
|
Download logs
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||||
|
>
|
||||||
|
{filteredLogs.length > 0 ? (
|
||||||
|
filteredLogs.map((filteredLog: LogLine, index: number) => (
|
||||||
|
<TerminalLine
|
||||||
|
key={index}
|
||||||
|
log={filteredLog}
|
||||||
|
searchTerm={search}
|
||||||
|
noTimestamp={!showTimestamp}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : isLoading ? (
|
||||||
|
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||||
|
No logs found
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import { CheckIcon, Hash } from "lucide-react";
|
||||||
|
import React, { useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
const lineCountOptions = [
|
||||||
|
{ label: "100 lines", value: 100 },
|
||||||
|
{ label: "300 lines", value: 300 },
|
||||||
|
{ label: "500 lines", value: 500 },
|
||||||
|
{ label: "1000 lines", value: 1000 },
|
||||||
|
{ label: "5000 lines", value: 5000 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface LineCountFilterProps {
|
||||||
|
value: number;
|
||||||
|
onValueChange: (value: number) => void;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LineCountFilter({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
title = "Limit to",
|
||||||
|
}: LineCountFilterProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [inputValue, setInputValue] = React.useState("");
|
||||||
|
const pendingValueRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const isPresetValue = lineCountOptions.some(
|
||||||
|
(option) => option.value === value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const debouncedValueChange = useCallback(
|
||||||
|
debounce((numValue: number) => {
|
||||||
|
if (numValue > 0 && numValue !== value) {
|
||||||
|
onValueChange(numValue);
|
||||||
|
pendingValueRef.current = null;
|
||||||
|
}
|
||||||
|
}, 500),
|
||||||
|
[onValueChange, value],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputChange = (input: string) => {
|
||||||
|
setInputValue(input);
|
||||||
|
|
||||||
|
// Extract numbers from input and convert
|
||||||
|
const numValue = Number.parseInt(input.replace(/[^0-9]/g, ""));
|
||||||
|
if (!Number.isNaN(numValue)) {
|
||||||
|
pendingValueRef.current = numValue;
|
||||||
|
debouncedValueChange(numValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (selectedValue: string) => {
|
||||||
|
const preset = lineCountOptions.find((opt) => opt.label === selectedValue);
|
||||||
|
if (preset) {
|
||||||
|
if (preset.value !== value) {
|
||||||
|
onValueChange(preset.value);
|
||||||
|
}
|
||||||
|
setInputValue("");
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numValue = Number.parseInt(selectedValue);
|
||||||
|
if (
|
||||||
|
!Number.isNaN(numValue) &&
|
||||||
|
numValue > 0 &&
|
||||||
|
numValue !== value &&
|
||||||
|
numValue !== pendingValueRef.current
|
||||||
|
) {
|
||||||
|
onValueChange(numValue);
|
||||||
|
setInputValue("");
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
debouncedValueChange.cancel();
|
||||||
|
};
|
||||||
|
}, [debouncedValueChange]);
|
||||||
|
|
||||||
|
const displayValue = isPresetValue
|
||||||
|
? lineCountOptions.find((option) => option.value === value)?.label
|
||||||
|
: `${value} lines`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 bg-input text-sm placeholder-gray-400 w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||||
|
<div className="space-x-1 flex">
|
||||||
|
<Badge variant="blank" className="rounded-sm px-1 font-normal">
|
||||||
|
{displayValue}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<CommandPrimitive className="overflow-hidden rounded-md border border-none bg-popover text-popover-foreground">
|
||||||
|
<div className="flex items-center border-b px-3">
|
||||||
|
<Hash className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
placeholder="Number of lines"
|
||||||
|
value={inputValue}
|
||||||
|
onValueChange={handleInputChange}
|
||||||
|
className="flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const numValue = Number.parseInt(
|
||||||
|
inputValue.replace(/[^0-9]/g, ""),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!Number.isNaN(numValue) &&
|
||||||
|
numValue > 0 &&
|
||||||
|
numValue !== value &&
|
||||||
|
numValue !== pendingValueRef.current
|
||||||
|
) {
|
||||||
|
handleSelect(inputValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<CommandPrimitive.List className="max-h-[300px] overflow-y-auto overflow-x-hidden">
|
||||||
|
<CommandPrimitive.Group className="px-2 py-1.5">
|
||||||
|
{lineCountOptions.map((option) => {
|
||||||
|
const isSelected = value === option.value;
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
key={option.value}
|
||||||
|
onSelect={() => handleSelect(option.label)}
|
||||||
|
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 aria-selected:bg-accent aria-selected:text-accent-foreground"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-4 w-4 items-center justify-center rounded-sm border border-primary mr-2",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon className={cn("h-4 w-4")} />
|
||||||
|
</div>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</CommandPrimitive.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandPrimitive.Group>
|
||||||
|
</CommandPrimitive.List>
|
||||||
|
</CommandPrimitive>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LineCountFilter;
|
||||||
@@ -47,9 +47,9 @@ export const ShowDockerModalLogs = ({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<DockerLogsId
|
<DockerLogsId
|
||||||
id="terminal"
|
|
||||||
containerId={containerId || ""}
|
containerId={containerId || ""}
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
|
runType="native"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import type React from "react";
|
||||||
|
export const DockerLogsId = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/components/dashboard/docker/logs/docker-logs-id").then(
|
||||||
|
(e) => e.DockerLogsId,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
containerId: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
serverId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowDockerModalStackLogs = ({
|
||||||
|
containerId,
|
||||||
|
children,
|
||||||
|
serverId,
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer space-x-3"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>View Logs</DialogTitle>
|
||||||
|
<DialogDescription>View the logs for {containerId}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
|
<DockerLogsId
|
||||||
|
containerId={containerId || ""}
|
||||||
|
serverId={serverId}
|
||||||
|
runType="swarm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export type TimeFilter = "all" | "1h" | "6h" | "24h" | "168h" | "720h";
|
||||||
|
|
||||||
|
const timeRanges: Array<{ label: string; value: TimeFilter }> = [
|
||||||
|
{
|
||||||
|
label: "All time",
|
||||||
|
value: "all",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last hour",
|
||||||
|
value: "1h",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last 6 hours",
|
||||||
|
value: "6h",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last 24 hours",
|
||||||
|
value: "24h",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last 7 days",
|
||||||
|
value: "168h",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last 30 days",
|
||||||
|
value: "720h",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface SinceLogsFilterProps {
|
||||||
|
value: TimeFilter;
|
||||||
|
onValueChange: (value: TimeFilter) => void;
|
||||||
|
showTimestamp: boolean;
|
||||||
|
onTimestampChange: (show: boolean) => void;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SinceLogsFilter({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
showTimestamp,
|
||||||
|
onTimestampChange,
|
||||||
|
title = "Time range",
|
||||||
|
}: SinceLogsFilterProps) {
|
||||||
|
const selectedLabel =
|
||||||
|
timeRanges.find((range) => range.value === value)?.label ??
|
||||||
|
"Select time range";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 bg-input text-sm placeholder-gray-400 w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||||
|
<div className="space-x-1 flex">
|
||||||
|
<Badge variant="blank" className="rounded-sm px-1 font-normal">
|
||||||
|
{selectedLabel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandList>
|
||||||
|
<CommandGroup>
|
||||||
|
{timeRanges.map((range) => {
|
||||||
|
const isSelected = value === range.value;
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={range.value}
|
||||||
|
onSelect={() => {
|
||||||
|
if (!isSelected) {
|
||||||
|
onValueChange(range.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mr-2 flex h-4 w-4 items-center rounded-sm border border-primary",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon className={cn("h-4 w-4")} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm">{range.label}</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<div className="p-2 flex items-center justify-between">
|
||||||
|
<span className="text-sm">Show timestamps</span>
|
||||||
|
<Switch checked={showTimestamp} onCheckedChange={onTimestampChange} />
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
interface StatusLogsFilterProps {
|
||||||
|
value?: string[];
|
||||||
|
setValue?: (value: string[]) => void;
|
||||||
|
title?: string;
|
||||||
|
options: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusLogsFilter({
|
||||||
|
value = [],
|
||||||
|
setValue,
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
}: StatusLogsFilterProps) {
|
||||||
|
const selectedValues = new Set(value as string[]);
|
||||||
|
const allSelected = selectedValues.size === 0;
|
||||||
|
|
||||||
|
const getSelectedBadges = () => {
|
||||||
|
if (allSelected) {
|
||||||
|
return (
|
||||||
|
<Badge variant="blank" className="rounded-sm px-1 font-normal">
|
||||||
|
All
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedValues.size >= 1) {
|
||||||
|
const selected = options.find((opt) => selectedValues.has(opt.value));
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
selected?.value === "success"
|
||||||
|
? "green"
|
||||||
|
: selected?.value === "error"
|
||||||
|
? "red"
|
||||||
|
: selected?.value === "warning"
|
||||||
|
? "orange"
|
||||||
|
: selected?.value === "info"
|
||||||
|
? "blue"
|
||||||
|
: selected?.value === "debug"
|
||||||
|
? "yellow"
|
||||||
|
: "blank"
|
||||||
|
}
|
||||||
|
className="rounded-sm px-1 font-normal"
|
||||||
|
>
|
||||||
|
{selected?.label}
|
||||||
|
</Badge>
|
||||||
|
{selectedValues.size > 1 && (
|
||||||
|
<Badge variant="blank" className="rounded-sm px-1 font-normal">
|
||||||
|
+{selectedValues.size - 1}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 bg-input text-sm placeholder-gray-400 w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||||
|
<div className="space-x-1 flex">{getSelectedBadges()}</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[200px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandList>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
setValue?.([]); // Empty array means "All"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mr-2 flex h-4 w-4 items-center rounded-sm border border-primary",
|
||||||
|
allSelected
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon className={cn("h-4 w-4")} />
|
||||||
|
</div>
|
||||||
|
<Badge variant="blank">All</Badge>
|
||||||
|
</CommandItem>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = selectedValues.has(option.value);
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
onSelect={() => {
|
||||||
|
const newValues = new Set(selectedValues);
|
||||||
|
if (isSelected) {
|
||||||
|
newValues.delete(option.value);
|
||||||
|
} else {
|
||||||
|
newValues.add(option.value);
|
||||||
|
}
|
||||||
|
setValue?.(Array.from(newValues));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mr-2 flex h-4 w-4 items-center rounded-sm border border-primary",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "opacity-50 [&_svg]:invisible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon className={cn("h-4 w-4")} />
|
||||||
|
</div>
|
||||||
|
{option.icon && (
|
||||||
|
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
option.value === "success"
|
||||||
|
? "green"
|
||||||
|
: option.value === "error"
|
||||||
|
? "red"
|
||||||
|
: option.value === "warning"
|
||||||
|
? "orange"
|
||||||
|
: option.value === "info"
|
||||||
|
? "blue"
|
||||||
|
: option.value === "debug"
|
||||||
|
? "yellow"
|
||||||
|
: "blank"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Badge>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx
Normal file
139
apps/dokploy/components/dashboard/docker/logs/terminal-line.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipPortal,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { FancyAnsi } from "fancy-ansi";
|
||||||
|
import { escapeRegExp } from "lodash";
|
||||||
|
import React from "react";
|
||||||
|
import { type LogLine, getLogType } from "./utils";
|
||||||
|
|
||||||
|
interface LogLineProps {
|
||||||
|
log: LogLine;
|
||||||
|
noTimestamp?: boolean;
|
||||||
|
searchTerm?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fancyAnsi = new FancyAnsi();
|
||||||
|
|
||||||
|
export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
||||||
|
const { timestamp, message, rawTimestamp } = log;
|
||||||
|
const { type, variant, color } = getLogType(message);
|
||||||
|
|
||||||
|
const formattedTime = timestamp
|
||||||
|
? timestamp.toLocaleString([], {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
year: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
})
|
||||||
|
: "--- No time found ---";
|
||||||
|
|
||||||
|
const highlightMessage = (text: string, term: string) => {
|
||||||
|
if (!term) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="transition-colors"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: fancyAnsi.toHtml(text),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlContent = fancyAnsi.toHtml(text);
|
||||||
|
const modifiedContent = htmlContent.replace(
|
||||||
|
/<span([^>]*)>([^<]*)<\/span>/g,
|
||||||
|
(match, attrs, content) => {
|
||||||
|
const searchRegex = new RegExp(`(${escapeRegExp(term)})`, "gi");
|
||||||
|
if (!content.match(searchRegex)) return match;
|
||||||
|
|
||||||
|
const segments = content.split(searchRegex);
|
||||||
|
const wrappedSegments = segments
|
||||||
|
.map((segment: string) =>
|
||||||
|
segment.toLowerCase() === term.toLowerCase()
|
||||||
|
? `<span${attrs} class="bg-yellow-200/50 dark:bg-yellow-900/50">${segment}</span>`
|
||||||
|
: segment,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return `<span${attrs}>${wrappedSegments}</span>`;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="transition-colors"
|
||||||
|
dangerouslySetInnerHTML={{ __html: modifiedContent }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tooltip = (color: string, timestamp: string | null) => {
|
||||||
|
const square = (
|
||||||
|
<div className={cn("w-2 h-full flex-shrink-0 rounded-[3px]", color)} />
|
||||||
|
);
|
||||||
|
return timestamp ? (
|
||||||
|
<TooltipProvider delayDuration={0} disableHoverableContent>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{square}</TooltipTrigger>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent
|
||||||
|
sideOffset={5}
|
||||||
|
className="bg-popover border-border z-[99999]"
|
||||||
|
>
|
||||||
|
<p className="text text-xs text-muted-foreground break-all max-w-md">
|
||||||
|
<pre>{timestamp}</pre>
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
square
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"font-mono text-xs flex flex-row gap-3 py-2 sm:py-0.5 group",
|
||||||
|
type === "error"
|
||||||
|
? "bg-red-500/10 hover:bg-red-500/15"
|
||||||
|
: type === "warning"
|
||||||
|
? "bg-yellow-500/10 hover:bg-yellow-500/15"
|
||||||
|
: type === "debug"
|
||||||
|
? "bg-orange-500/10 hover:bg-orange-500/15"
|
||||||
|
: "hover:bg-gray-200/50 dark:hover:bg-gray-800/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{" "}
|
||||||
|
<div className="flex items-start gap-x-2">
|
||||||
|
{/* Icon to expand the log item maybe implement a colapsible later */}
|
||||||
|
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
|
||||||
|
{tooltip(color, rawTimestamp)}
|
||||||
|
{!noTimestamp && (
|
||||||
|
<span className="select-none pl-2 text-muted-foreground w-full sm:w-40 flex-shrink-0">
|
||||||
|
{formattedTime}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
variant={variant}
|
||||||
|
className="w-14 justify-center text-[10px] px-1 py-0"
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<span className="dark:text-gray-200 font-mono text-foreground whitespace-pre-wrap break-all">
|
||||||
|
{highlightMessage(message, searchTerm || "")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
apps/dokploy/components/dashboard/docker/logs/utils.ts
Normal file
152
apps/dokploy/components/dashboard/docker/logs/utils.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
export type LogType = "error" | "warning" | "success" | "info" | "debug";
|
||||||
|
export type LogVariant = "red" | "yellow" | "green" | "blue" | "orange";
|
||||||
|
|
||||||
|
export interface LogLine {
|
||||||
|
rawTimestamp: string | null;
|
||||||
|
timestamp: Date | null;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogStyle {
|
||||||
|
type: LogType;
|
||||||
|
variant: LogVariant;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_STYLES: Record<LogType, LogStyle> = {
|
||||||
|
error: {
|
||||||
|
type: "error",
|
||||||
|
variant: "red",
|
||||||
|
color: "bg-red-500/40",
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
type: "warning",
|
||||||
|
variant: "orange",
|
||||||
|
color: "bg-orange-500/40",
|
||||||
|
},
|
||||||
|
debug: {
|
||||||
|
type: "debug",
|
||||||
|
variant: "yellow",
|
||||||
|
color: "bg-yellow-500/40",
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
type: "success",
|
||||||
|
variant: "green",
|
||||||
|
color: "bg-green-500/40",
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
type: "info",
|
||||||
|
variant: "blue",
|
||||||
|
color: "bg-blue-600/40",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function parseLogs(logString: string): LogLine[] {
|
||||||
|
// Regex to match the log line format
|
||||||
|
// Exemple of return :
|
||||||
|
// 1 2024-12-10T10:00:00.000Z The server is running on port 8080
|
||||||
|
// Should return :
|
||||||
|
// { timestamp: new Date("2024-12-10T10:00:00.000Z"),
|
||||||
|
// message: "The server is running on port 8080" }
|
||||||
|
const logRegex =
|
||||||
|
/^(?:(\d+)\s+)?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC)?\s*(.*)$/;
|
||||||
|
|
||||||
|
return logString
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line !== "")
|
||||||
|
.map((line) => {
|
||||||
|
const match = line.match(logRegex);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const [, , timestamp, message] = match;
|
||||||
|
|
||||||
|
if (!message?.trim()) return null;
|
||||||
|
|
||||||
|
// Delete other timestamps and keep only the one from --timestamps
|
||||||
|
const cleanedMessage = message
|
||||||
|
?.replace(
|
||||||
|
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC/g,
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
rawTimestamp: timestamp ?? null,
|
||||||
|
timestamp: timestamp ? new Date(timestamp.replace(" UTC", "Z")) : null,
|
||||||
|
message: cleanedMessage,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((log) => log !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect log type based on message content
|
||||||
|
export const getLogType = (message: string): LogStyle => {
|
||||||
|
const lowerMessage = message.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
/(?:^|\s)(?:info|inf|information):?\s/i.test(lowerMessage) ||
|
||||||
|
/\[(?:info|information)\]/i.test(lowerMessage) ||
|
||||||
|
/\b(?:status|state|current|progress)\b:?\s/i.test(lowerMessage) ||
|
||||||
|
/\b(?:processing|executing|performing)\b/i.test(lowerMessage)
|
||||||
|
) {
|
||||||
|
return LOG_STYLES.info;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
/(?:^|\s)(?:error|err):?\s/i.test(lowerMessage) ||
|
||||||
|
/\b(?:exception|failed|failure)\b/i.test(lowerMessage) ||
|
||||||
|
/(?:stack\s?trace):\s*$/i.test(lowerMessage) ||
|
||||||
|
/^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(lowerMessage) ||
|
||||||
|
/\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(lowerMessage) ||
|
||||||
|
/Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(lowerMessage) ||
|
||||||
|
/\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(lowerMessage) ||
|
||||||
|
/\[(?:error|err|fatal)\]/i.test(lowerMessage) ||
|
||||||
|
/\b(?:crash|critical|fatal)\b/i.test(lowerMessage) ||
|
||||||
|
/\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(lowerMessage)
|
||||||
|
) {
|
||||||
|
return LOG_STYLES.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
/(?:^|\s)(?:warning|warn):?\s/i.test(lowerMessage) ||
|
||||||
|
/\[(?:warn(?:ing)?|attention)\]/i.test(lowerMessage) ||
|
||||||
|
/(?:deprecated|obsolete)\s+(?:since|in|as\s+of)/i.test(lowerMessage) ||
|
||||||
|
/\b(?:caution|attention|notice):\s/i.test(lowerMessage) ||
|
||||||
|
/(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) ||
|
||||||
|
/(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) ||
|
||||||
|
/\b(?:deprecated|obsolete)\b/i.test(lowerMessage) ||
|
||||||
|
/\b(?:unstable|experimental)\b/i.test(lowerMessage)
|
||||||
|
) {
|
||||||
|
return LOG_STYLES.warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
/(?:successfully|complete[d]?)\s+(?:initialized|started|completed|created|done|deployed)/i.test(
|
||||||
|
lowerMessage,
|
||||||
|
) ||
|
||||||
|
/\[(?:success|ok|done)\]/i.test(lowerMessage) ||
|
||||||
|
/(?:listening|running)\s+(?:on|at)\s+(?:port\s+)?\d+/i.test(lowerMessage) ||
|
||||||
|
/(?:connected|established|ready)\s+(?:to|for|on)/i.test(lowerMessage) ||
|
||||||
|
/\b(?:loaded|mounted|initialized)\s+successfully\b/i.test(lowerMessage) ||
|
||||||
|
/✓|√|✅|\[ok\]|done!/i.test(lowerMessage) ||
|
||||||
|
/\b(?:success(?:ful)?|completed|ready)\b/i.test(lowerMessage) ||
|
||||||
|
/\b(?:started|starting|active)\b/i.test(lowerMessage)
|
||||||
|
) {
|
||||||
|
return LOG_STYLES.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
/(?:^|\s)(?:info|inf):?\s/i.test(lowerMessage) ||
|
||||||
|
/\[(info|log|debug|trace|server|db|api|http|request|response)\]/i.test(
|
||||||
|
lowerMessage,
|
||||||
|
) ||
|
||||||
|
/\b(?:version|config|import|load|get|HTTP|PATCH|POST|debug)\b:?/i.test(
|
||||||
|
lowerMessage,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return LOG_STYLES.debug;
|
||||||
|
}
|
||||||
|
|
||||||
|
return LOG_STYLES.info;
|
||||||
|
};
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
const Terminal = dynamic(
|
const Terminal = dynamic(
|
||||||
() => import("./docker-terminal").then((e) => e.DockerTerminal),
|
() => import("./docker-terminal").then((e) => e.DockerTerminal),
|
||||||
@@ -27,8 +30,27 @@ export const DockerTerminalModal = ({
|
|||||||
containerId,
|
containerId,
|
||||||
serverId,
|
serverId,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const [mainDialogOpen, setMainDialogOpen] = useState(false);
|
||||||
|
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleMainDialogOpenChange = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
setConfirmDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
setMainDialogOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
setConfirmDialogOpen(false);
|
||||||
|
setMainDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setConfirmDialogOpen(false);
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={mainDialogOpen} onOpenChange={handleMainDialogOpenChange}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer space-x-3"
|
className="w-full cursor-pointer space-x-3"
|
||||||
@@ -37,7 +59,10 @@ export const DockerTerminalModal = ({
|
|||||||
{children}
|
{children}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-7xl">
|
<DialogContent
|
||||||
|
className="max-h-screen overflow-y-auto sm:max-w-7xl"
|
||||||
|
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Docker Terminal</DialogTitle>
|
<DialogTitle>Docker Terminal</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
@@ -50,6 +75,24 @@ export const DockerTerminalModal = ({
|
|||||||
containerId={containerId}
|
containerId={containerId}
|
||||||
serverId={serverId || ""}
|
serverId={serverId || ""}
|
||||||
/>
|
/>
|
||||||
|
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
|
||||||
|
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Are you sure you want to close the terminal?
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
By clicking the confirm button, the terminal will be closed.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleConfirm}>Confirm</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FitAddon } from "xterm-addon-fit";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { AttachAddon } from "@xterm/addon-attach";
|
import { AttachAddon } from "@xterm/addon-attach";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +19,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const termRef = useRef(null);
|
const termRef = useRef(null);
|
||||||
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
|
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = document.getElementById(id);
|
const container = document.getElementById(id);
|
||||||
if (container) {
|
if (container) {
|
||||||
@@ -25,13 +27,12 @@ export const DockerTerminal: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cols: 80,
|
|
||||||
rows: 30,
|
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
convertEol: true,
|
convertEol: true,
|
||||||
theme: {
|
theme: {
|
||||||
cursor: "transparent",
|
cursor: resolvedTheme === "light" ? "#000000" : "transparent",
|
||||||
background: "rgba(0, 0, 0, 0)",
|
background: "rgba(0, 0, 0, 0)",
|
||||||
|
foreground: "currentColor",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const addonFit = new FitAddon();
|
const addonFit = new FitAddon();
|
||||||
@@ -45,6 +46,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
|||||||
const addonAttach = new AttachAddon(ws);
|
const addonAttach = new AttachAddon(ws);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
term.open(termRef.current);
|
term.open(termRef.current);
|
||||||
|
// @ts-ignore
|
||||||
term.loadAddon(addonFit);
|
term.loadAddon(addonFit);
|
||||||
term.loadAddon(addonAttach);
|
term.loadAddon(addonAttach);
|
||||||
addonFit.fit();
|
addonFit.fit();
|
||||||
@@ -66,7 +68,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full h-full rounded-lg p-2 bg-[#19191A]">
|
<div className="w-full h-full rounded-lg p-2 bg-transparent border">
|
||||||
<div id={id} ref={termRef} />
|
<div id={id} ref={termRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,62 +1,158 @@
|
|||||||
import {
|
import { Badge } from "@/components/ui/badge";
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Copy, TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const deleteMariadbSchema = z.object({
|
||||||
|
projectName: z.string().min(1, {
|
||||||
|
message: "Database name is required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type DeleteMariadb = z.infer<typeof deleteMariadbSchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mariadbId: string;
|
mariadbId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteMariadb = ({ mariadbId }: Props) => {
|
export const DeleteMariadb = ({ mariadbId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { mutateAsync, isLoading } = api.mariadb.remove.useMutation();
|
const { mutateAsync, isLoading } = api.mariadb.remove.useMutation();
|
||||||
|
const { data } = api.mariadb.one.useQuery(
|
||||||
|
{ mariadbId },
|
||||||
|
{ enabled: !!mariadbId },
|
||||||
|
);
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
|
const form = useForm<DeleteMariadb>({
|
||||||
|
defaultValues: {
|
||||||
|
projectName: "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(deleteMariadbSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: DeleteMariadb) => {
|
||||||
|
const expectedName = `${data?.name}/${data?.appName}`;
|
||||||
|
if (formData.projectName === expectedName) {
|
||||||
|
await mutateAsync({ mariadbId })
|
||||||
|
.then((data) => {
|
||||||
|
push(`/dashboard/project/${data?.projectId}`);
|
||||||
|
toast.success("Database deleted successfully");
|
||||||
|
setIsOpen(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting the database");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.setError("projectName", {
|
||||||
|
message: "Database name does not match",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<AlertDialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<TrashIcon className="size-4 text-muted-foreground " />
|
<TrashIcon className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</DialogTrigger>
|
||||||
<AlertDialogContent>
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
<AlertDialogHeader>
|
<DialogHeader>
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||||
<AlertDialogDescription>
|
<DialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
database
|
database. If you are sure please enter the database name to delete
|
||||||
</AlertDialogDescription>
|
this database.
|
||||||
</AlertDialogHeader>
|
</DialogDescription>
|
||||||
<AlertDialogFooter>
|
</DialogHeader>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<div className="grid gap-4">
|
||||||
<AlertDialogAction
|
<Form {...form}>
|
||||||
onClick={async () => {
|
<form
|
||||||
await mutateAsync({
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
mariadbId,
|
id="hook-form-delete-mariadb"
|
||||||
})
|
className="grid w-full gap-4"
|
||||||
.then((data) => {
|
>
|
||||||
push(`/dashboard/project/${data?.projectId}`);
|
<FormField
|
||||||
toast.success("Database delete succesfully");
|
control={form.control}
|
||||||
})
|
name="projectName"
|
||||||
.catch(() => {
|
render={({ field }) => (
|
||||||
toast.error("Error to delete the database");
|
<FormItem>
|
||||||
});
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
To confirm, type{" "}
|
||||||
|
<Badge
|
||||||
|
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (data?.name && data?.appName) {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`${data.name}/${data.appName}`,
|
||||||
|
);
|
||||||
|
toast.success("Copied to clipboard. Be careful!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data?.name}/{data?.appName}
|
||||||
|
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||||
|
</Badge>{" "}
|
||||||
|
in the box below:
|
||||||
|
</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter database name to confirm"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
form="hook-form-delete-mariadb"
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</AlertDialogAction>
|
</Button>
|
||||||
</AlertDialogFooter>
|
</DialogFooter>
|
||||||
</AlertDialogContent>
|
</DialogContent>
|
||||||
</AlertDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,62 +1,157 @@
|
|||||||
import {
|
import { Badge } from "@/components/ui/badge";
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Copy, TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const deleteMongoSchema = z.object({
|
||||||
|
projectName: z.string().min(1, {
|
||||||
|
message: "Database name is required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type DeleteMongo = z.infer<typeof deleteMongoSchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mongoId: string;
|
mongoId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// commen
|
||||||
|
|
||||||
export const DeleteMongo = ({ mongoId }: Props) => {
|
export const DeleteMongo = ({ mongoId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { mutateAsync, isLoading } = api.mongo.remove.useMutation();
|
const { mutateAsync, isLoading } = api.mongo.remove.useMutation();
|
||||||
|
const { data } = api.mongo.one.useQuery({ mongoId }, { enabled: !!mongoId });
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
|
const form = useForm<DeleteMongo>({
|
||||||
|
defaultValues: {
|
||||||
|
projectName: "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(deleteMongoSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: DeleteMongo) => {
|
||||||
|
const expectedName = `${data?.name}/${data?.appName}`;
|
||||||
|
if (formData.projectName === expectedName) {
|
||||||
|
await mutateAsync({ mongoId })
|
||||||
|
.then((data) => {
|
||||||
|
push(`/dashboard/project/${data?.projectId}`);
|
||||||
|
toast.success("Database deleted successfully");
|
||||||
|
setIsOpen(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting the database");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.setError("projectName", {
|
||||||
|
message: "Database name does not match",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<AlertDialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<TrashIcon className="size-4 text-muted-foreground " />
|
<TrashIcon className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</DialogTrigger>
|
||||||
<AlertDialogContent>
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
<AlertDialogHeader>
|
<DialogHeader>
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||||
<AlertDialogDescription>
|
<DialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
database
|
database. If you are sure please enter the database name to delete
|
||||||
</AlertDialogDescription>
|
this database.
|
||||||
</AlertDialogHeader>
|
</DialogDescription>
|
||||||
<AlertDialogFooter>
|
</DialogHeader>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<div className="grid gap-4">
|
||||||
<AlertDialogAction
|
<Form {...form}>
|
||||||
onClick={async () => {
|
<form
|
||||||
await mutateAsync({
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
mongoId,
|
id="hook-form-delete-mongo"
|
||||||
})
|
className="grid w-full gap-4"
|
||||||
.then((data) => {
|
>
|
||||||
push(`/dashboard/project/${data?.projectId}`);
|
<FormField
|
||||||
toast.success("Database delete succesfully");
|
control={form.control}
|
||||||
})
|
name="projectName"
|
||||||
.catch(() => {
|
render={({ field }) => (
|
||||||
toast.error("Error to delete the database");
|
<FormItem>
|
||||||
});
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
To confirm, type{" "}
|
||||||
|
<Badge
|
||||||
|
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (data?.name && data?.appName) {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`${data.name}/${data.appName}`,
|
||||||
|
);
|
||||||
|
toast.success("Copied to clipboard. Be careful!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data?.name}/{data?.appName}
|
||||||
|
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||||
|
</Badge>{" "}
|
||||||
|
in the box below:
|
||||||
|
</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter database name to confirm"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
form="hook-form-delete-mongo"
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</AlertDialogAction>
|
</Button>
|
||||||
</AlertDialogFooter>
|
</DialogFooter>
|
||||||
</AlertDialogContent>
|
</DialogContent>
|
||||||
</AlertDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,62 +1,156 @@
|
|||||||
import {
|
import { Badge } from "@/components/ui/badge";
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Copy, TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const deleteMysqlSchema = z.object({
|
||||||
|
projectName: z.string().min(1, {
|
||||||
|
message: "Database name is required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type DeleteMysql = z.infer<typeof deleteMysqlSchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mysqlId: string;
|
mysqlId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteMysql = ({ mysqlId }: Props) => {
|
export const DeleteMysql = ({ mysqlId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { mutateAsync, isLoading } = api.mysql.remove.useMutation();
|
const { mutateAsync, isLoading } = api.mysql.remove.useMutation();
|
||||||
|
const { data } = api.mysql.one.useQuery({ mysqlId }, { enabled: !!mysqlId });
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
|
const form = useForm<DeleteMysql>({
|
||||||
|
defaultValues: {
|
||||||
|
projectName: "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(deleteMysqlSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: DeleteMysql) => {
|
||||||
|
const expectedName = `${data?.name}/${data?.appName}`;
|
||||||
|
if (formData.projectName === expectedName) {
|
||||||
|
await mutateAsync({ mysqlId })
|
||||||
|
.then((data) => {
|
||||||
|
push(`/dashboard/project/${data?.projectId}`);
|
||||||
|
toast.success("Database deleted successfully");
|
||||||
|
setIsOpen(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting the database");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.setError("projectName", {
|
||||||
|
message: "Database name does not match",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<AlertDialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<TrashIcon className="size-4 text-muted-foreground " />
|
<TrashIcon className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</DialogTrigger>
|
||||||
<AlertDialogContent>
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
<AlertDialogHeader>
|
<DialogHeader>
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||||
<AlertDialogDescription>
|
<DialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
database
|
database. If you are sure please enter the database name to delete
|
||||||
</AlertDialogDescription>
|
this database.
|
||||||
</AlertDialogHeader>
|
</DialogDescription>
|
||||||
<AlertDialogFooter>
|
</DialogHeader>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<div className="grid gap-4">
|
||||||
<AlertDialogAction
|
<Form {...form}>
|
||||||
onClick={async () => {
|
<form
|
||||||
await mutateAsync({
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
mysqlId,
|
id="hook-form-delete-mysql"
|
||||||
})
|
className="grid w-full gap-4"
|
||||||
.then((data) => {
|
>
|
||||||
push(`/dashboard/project/${data?.projectId}`);
|
<FormField
|
||||||
toast.success("Database delete succesfully");
|
control={form.control}
|
||||||
})
|
name="projectName"
|
||||||
.catch(() => {
|
render={({ field }) => (
|
||||||
toast.error("Error to delete the database");
|
<FormItem>
|
||||||
});
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
To confirm, type{" "}
|
||||||
|
<Badge
|
||||||
|
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (data?.name && data?.appName) {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`${data.name}/${data.appName}`,
|
||||||
|
);
|
||||||
|
toast.success("Copied to clipboard. Be careful!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data?.name}/{data?.appName}
|
||||||
|
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||||
|
</Badge>{" "}
|
||||||
|
in the box below:
|
||||||
|
</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter database name to confirm"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
form="hook-form-delete-mysql"
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</AlertDialogAction>
|
</Button>
|
||||||
</AlertDialogFooter>
|
</DialogFooter>
|
||||||
</AlertDialogContent>
|
</DialogContent>
|
||||||
</AlertDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -86,14 +86,12 @@ export const ShowVolumes = ({ mysqlId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{mount.type === "file" && (
|
{mount.type === "file" && (
|
||||||
<>
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col gap-1">
|
<span className="font-medium">Content</span>
|
||||||
<span className="font-medium">Content</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
<span className="text-sm text-muted-foreground">
|
{mount.content}
|
||||||
{mount.content}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
{mount.type === "bind" && (
|
{mount.type === "bind" && (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -1,62 +1,159 @@
|
|||||||
import {
|
import { Badge } from "@/components/ui/badge";
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Copy, TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const deletePostgresSchema = z.object({
|
||||||
|
projectName: z.string().min(1, {
|
||||||
|
message: "Database name is required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type DeletePostgres = z.infer<typeof deletePostgresSchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
postgresId: string;
|
postgresId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeletePostgres = ({ postgresId }: Props) => {
|
export const DeletePostgres = ({ postgresId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { mutateAsync, isLoading } = api.postgres.remove.useMutation();
|
const { mutateAsync, isLoading } = api.postgres.remove.useMutation();
|
||||||
|
const { data } = api.postgres.one.useQuery(
|
||||||
|
{ postgresId },
|
||||||
|
{ enabled: !!postgresId },
|
||||||
|
);
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
|
const form = useForm<DeletePostgres>({
|
||||||
|
defaultValues: {
|
||||||
|
projectName: "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(deletePostgresSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: DeletePostgres) => {
|
||||||
|
const expectedName = `${data?.name}/${data?.appName}`;
|
||||||
|
if (formData.projectName === expectedName) {
|
||||||
|
await mutateAsync({ postgresId })
|
||||||
|
.then((data) => {
|
||||||
|
push(`/dashboard/project/${data?.projectId}`);
|
||||||
|
toast.success("Database deleted successfully");
|
||||||
|
setIsOpen(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting the database");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.setError("projectName", {
|
||||||
|
message: "Database name does not match",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<AlertDialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<TrashIcon className="size-4 text-muted-foreground " />
|
<TrashIcon className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</DialogTrigger>
|
||||||
<AlertDialogContent>
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
<AlertDialogHeader>
|
<DialogHeader>
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||||
<AlertDialogDescription>
|
<DialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
database
|
database. If you are sure please enter the database name to delete
|
||||||
</AlertDialogDescription>
|
this database.
|
||||||
</AlertDialogHeader>
|
</DialogDescription>
|
||||||
<AlertDialogFooter>
|
</DialogHeader>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<div className="grid gap-4">
|
||||||
<AlertDialogAction
|
<Form {...form}>
|
||||||
onClick={async () => {
|
<form
|
||||||
await mutateAsync({
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
postgresId,
|
id="hook-form-delete-postgres"
|
||||||
})
|
className="grid w-full gap-4"
|
||||||
.then((data) => {
|
>
|
||||||
push(`/dashboard/project/${data?.projectId}`);
|
<FormField
|
||||||
toast.success("Database delete succesfully");
|
control={form.control}
|
||||||
})
|
name="projectName"
|
||||||
.catch(() => {
|
render={({ field }) => (
|
||||||
toast.error("Error to delete the database");
|
<FormItem>
|
||||||
});
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
To confirm, type{" "}
|
||||||
|
<Badge
|
||||||
|
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (data?.name && data?.appName) {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`${data.name}/${data.appName}`,
|
||||||
|
);
|
||||||
|
toast.success("Copied to clipboard. Be careful!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data?.name}/{data?.appName}
|
||||||
|
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||||
|
</Badge>{" "}
|
||||||
|
in the box below:
|
||||||
|
</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter database name to confirm"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
form="hook-form-delete-postgres"
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</AlertDialogAction>
|
</Button>
|
||||||
</AlertDialogFooter>
|
</DialogFooter>
|
||||||
</AlertDialogContent>
|
</DialogContent>
|
||||||
</AlertDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
|
|||||||
name="appName"
|
name="appName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>AppName</FormLabel>
|
<FormLabel>App Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="my-app" {...field} />
|
<Input placeholder="my-app" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
|
|||||||
name="appName"
|
name="appName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>AppName</FormLabel>
|
<FormLabel>App Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="my-app" {...field} />
|
<Input placeholder="my-app" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
@@ -35,6 +36,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { slugify } from "@/lib/slug";
|
import { slugify } from "@/lib/slug";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -95,6 +97,7 @@ const mySchema = z.discriminatedUnion("type", [
|
|||||||
.object({
|
.object({
|
||||||
type: z.literal("mongo"),
|
type: z.literal("mongo"),
|
||||||
databaseUser: z.string().default("mongo"),
|
databaseUser: z.string().default("mongo"),
|
||||||
|
replicaSets: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
.merge(baseDatabaseSchema),
|
.merge(baseDatabaseSchema),
|
||||||
z
|
z
|
||||||
@@ -216,6 +219,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
|
|||||||
databaseUser:
|
databaseUser:
|
||||||
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
|
||||||
serverId: data.serverId,
|
serverId: data.serverId,
|
||||||
|
replicaSets: data.replicaSets,
|
||||||
});
|
});
|
||||||
} else if (data.type === "redis") {
|
} else if (data.type === "redis") {
|
||||||
promise = redisMutation.mutateAsync({
|
promise = redisMutation.mutateAsync({
|
||||||
@@ -412,7 +416,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
|
|||||||
name="appName"
|
name="appName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>AppName</FormLabel>
|
<FormLabel>App Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="my-app" {...field} />
|
<Input placeholder="my-app" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -471,6 +475,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={`Default ${databasesUserDefaultPlaceholder[type]}`}
|
placeholder={`Default ${databasesUserDefaultPlaceholder[type]}`}
|
||||||
|
autoComplete="off"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -491,6 +496,7 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
|
|||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="******************"
|
placeholder="******************"
|
||||||
|
autoComplete="off"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -540,6 +546,30 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{type === "mongo" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="replicaSets"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Use Replica Sets</FormLabel>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
template.tags.some((tag) => selectedTags.includes(tag));
|
template.tags.some((tag) => selectedTags.includes(tag));
|
||||||
const matchesQuery =
|
const matchesQuery =
|
||||||
query === "" ||
|
query === "" ||
|
||||||
template.name.toLowerCase().includes(query.toLowerCase());
|
template.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
template.description.toLowerCase().includes(query.toLowerCase());
|
||||||
return matchesTags && matchesQuery;
|
return matchesTags && matchesQuery;
|
||||||
}) || [];
|
}) || [];
|
||||||
|
|
||||||
@@ -127,7 +128,6 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"md:max-w-[15rem] w-full justify-between !bg-input",
|
"md:max-w-[15rem] w-full justify-between !bg-input",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,11 +19,9 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { AlertTriangle, FileIcon, SquarePen } from "lucide-react";
|
import { FileIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -37,9 +35,10 @@ type UpdateProject = z.infer<typeof updateProjectSchema>;
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddEnv = ({ projectId }: Props) => {
|
export const ProjectEnviroment = ({ projectId, children }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { mutateAsync, error, isError, isLoading } =
|
const { mutateAsync, error, isError, isLoading } =
|
||||||
@@ -53,7 +52,6 @@ export const AddEnv = ({ projectId }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(data);
|
|
||||||
const form = useForm<UpdateProject>({
|
const form = useForm<UpdateProject>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
env: data?.env ?? "",
|
env: data?.env ?? "",
|
||||||
@@ -86,34 +84,29 @@ export const AddEnv = ({ projectId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<DropdownMenuItem
|
{children ?? (
|
||||||
className="w-full cursor-pointer space-x-3"
|
<DropdownMenuItem
|
||||||
onSelect={(e) => e.preventDefault()}
|
className="w-full cursor-pointer space-x-3"
|
||||||
>
|
onSelect={(e) => e.preventDefault()}
|
||||||
<FileIcon className="size-4" />
|
>
|
||||||
<span>Add Env</span>
|
<FileIcon className="size-4" />
|
||||||
</DropdownMenuItem>
|
<span>Project Enviroment</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-6xl">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-6xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Modify Shared Env</DialogTitle>
|
<DialogTitle>Project Enviroment</DialogTitle>
|
||||||
<DialogDescription>Update the env variables</DialogDescription>
|
<DialogDescription>
|
||||||
|
Update the env Environment variables that are accessible to all
|
||||||
|
services of this project. Use this syntax to reference project-level
|
||||||
|
variables in your service environments:
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
<AlertBlock type="info">
|
<AlertBlock type="info">
|
||||||
To use a shared env, in one of your services, you need to use like
|
Use this syntax to reference project-level variables in your service
|
||||||
this: Let's say you have a shared env ENVIROMENT="development" and you
|
environments: <code>DATABASE_URL=${"{{project.DATABASE_URL}}"}</code>
|
||||||
want to use it in your service, you need to use like this:
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<code>ENVIRONMENT=${"{{project.ENVIRONMENT}}"}</code>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>DATABASE_URL=${"{{project.DATABASE_URL}}"}</code>
|
|
||||||
</li>
|
|
||||||
</ul>{" "}
|
|
||||||
This allows the service to inherit and use the shared variables from
|
|
||||||
the project level, ensuring consistency across services.
|
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid items-center gap-4">
|
<div className="grid items-center gap-4">
|
||||||
@@ -25,7 +25,6 @@ import { api } from "@/utils/api";
|
|||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
BookIcon,
|
BookIcon,
|
||||||
CircuitBoard,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
FolderInput,
|
FolderInput,
|
||||||
@@ -35,7 +34,7 @@ import {
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AddEnv } from "./add-env";
|
import { ProjectEnviroment } from "./project-enviroment";
|
||||||
import { UpdateProject } from "./update";
|
import { UpdateProject } from "./update";
|
||||||
|
|
||||||
export const ShowProjects = () => {
|
export const ShowProjects = () => {
|
||||||
@@ -110,7 +109,9 @@ export const ShowProjects = () => {
|
|||||||
<Link
|
<Link
|
||||||
className="space-x-4 text-xs cursor-pointer justify-between"
|
className="space-x-4 text-xs cursor-pointer justify-between"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
href={`${domain.https ? "https" : "http"}://${
|
||||||
|
domain.host
|
||||||
|
}${domain.path}`}
|
||||||
>
|
>
|
||||||
<span>{domain.host}</span>
|
<span>{domain.host}</span>
|
||||||
<ExternalLink className="size-4 shrink-0" />
|
<ExternalLink className="size-4 shrink-0" />
|
||||||
@@ -154,7 +155,9 @@ export const ShowProjects = () => {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`${flattedDomains[0].https ? "https" : "http"}://${flattedDomains[0].host}${flattedDomains[0].path}`}
|
href={`${
|
||||||
|
flattedDomains[0].https ? "https" : "http"
|
||||||
|
}://${flattedDomains[0].host}${flattedDomains[0].path}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<ExternalLinkIcon className="size-3.5" />
|
<ExternalLinkIcon className="size-3.5" />
|
||||||
@@ -192,7 +195,9 @@ export const ShowProjects = () => {
|
|||||||
Actions
|
Actions
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<AddEnv projectId={project.projectId} />
|
<ProjectEnviroment
|
||||||
|
projectId={project.projectId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<UpdateProject projectId={project.projectId} />
|
<UpdateProject projectId={project.projectId} />
|
||||||
|
|||||||
@@ -1,62 +1,156 @@
|
|||||||
import {
|
import { Badge } from "@/components/ui/badge";
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Copy, TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const deleteRedisSchema = z.object({
|
||||||
|
projectName: z.string().min(1, {
|
||||||
|
message: "Database name is required",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
type DeleteRedis = z.infer<typeof deleteRedisSchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
redisId: string;
|
redisId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteRedis = ({ redisId }: Props) => {
|
export const DeleteRedis = ({ redisId }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { mutateAsync, isLoading } = api.redis.remove.useMutation();
|
const { mutateAsync, isLoading } = api.redis.remove.useMutation();
|
||||||
|
const { data } = api.redis.one.useQuery({ redisId }, { enabled: !!redisId });
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
|
const form = useForm<DeleteRedis>({
|
||||||
|
defaultValues: {
|
||||||
|
projectName: "",
|
||||||
|
},
|
||||||
|
resolver: zodResolver(deleteRedisSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (formData: DeleteRedis) => {
|
||||||
|
const expectedName = `${data?.name}/${data?.appName}`;
|
||||||
|
if (formData.projectName === expectedName) {
|
||||||
|
await mutateAsync({ redisId })
|
||||||
|
.then((data) => {
|
||||||
|
push(`/dashboard/project/${data?.projectId}`);
|
||||||
|
toast.success("Database deleted successfully");
|
||||||
|
setIsOpen(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting the database");
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.setError("projectName", {
|
||||||
|
message: "Database name does not match",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<AlertDialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" isLoading={isLoading}>
|
<Button variant="ghost" isLoading={isLoading}>
|
||||||
<TrashIcon className="size-4 text-muted-foreground " />
|
<TrashIcon className="size-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</DialogTrigger>
|
||||||
<AlertDialogContent>
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
<AlertDialogHeader>
|
<DialogHeader>
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||||
<AlertDialogDescription>
|
<DialogDescription>
|
||||||
This action cannot be undone. This will permanently delete the
|
This action cannot be undone. This will permanently delete the
|
||||||
database
|
database. If you are sure please enter the database name to delete
|
||||||
</AlertDialogDescription>
|
this database.
|
||||||
</AlertDialogHeader>
|
</DialogDescription>
|
||||||
<AlertDialogFooter>
|
</DialogHeader>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<div className="grid gap-4">
|
||||||
<AlertDialogAction
|
<Form {...form}>
|
||||||
onClick={async () => {
|
<form
|
||||||
await mutateAsync({
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
redisId,
|
id="hook-form-delete-redis"
|
||||||
})
|
className="grid w-full gap-4"
|
||||||
.then((data) => {
|
>
|
||||||
push(`/dashboard/project/${data?.projectId}`);
|
<FormField
|
||||||
toast.success("Database delete succesfully");
|
control={form.control}
|
||||||
})
|
name="projectName"
|
||||||
.catch(() => {
|
render={({ field }) => (
|
||||||
toast.error("Error to delete the database");
|
<FormItem>
|
||||||
});
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
To confirm, type{" "}
|
||||||
|
<Badge
|
||||||
|
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (data?.name && data?.appName) {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`${data.name}/${data.appName}`,
|
||||||
|
);
|
||||||
|
toast.success("Copied to clipboard. Be careful!");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data?.name}/{data?.appName}
|
||||||
|
<Copy className="h-4 w-4 ml-1 text-muted-foreground" />
|
||||||
|
</Badge>{" "}
|
||||||
|
in the box below:
|
||||||
|
</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter database name to confirm"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
form="hook-form-delete-redis"
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</AlertDialogAction>
|
</Button>
|
||||||
</AlertDialogFooter>
|
</DialogFooter>
|
||||||
</AlertDialogContent>
|
</DialogContent>
|
||||||
</AlertDialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,8 +49,11 @@ export const columns: ColumnDef<LogEntry>[] = [
|
|||||||
const log = row.original;
|
const log = row.original;
|
||||||
return (
|
return (
|
||||||
<div className=" flex flex-col gap-2">
|
<div className=" flex flex-col gap-2">
|
||||||
<div className="flex flex-row gap-3 ">
|
<div className="flex items-center flex-row gap-3 ">
|
||||||
{log.RequestMethod}{" "}
|
{log.RequestMethod}{" "}
|
||||||
|
<div className="inline-flex items-center gap-2 bg-muted p-1 rounded">
|
||||||
|
<span>{log.RequestAddr}</span>
|
||||||
|
</div>
|
||||||
{log.RequestPath.length > 100
|
{log.RequestPath.length > 100
|
||||||
? `${log.RequestPath.slice(0, 82)}...`
|
? `${log.RequestPath.slice(0, 82)}...`
|
||||||
: log.RequestPath}
|
: log.RequestPath}
|
||||||
|
|||||||
189
apps/dokploy/components/dashboard/search-command.tsx
Normal file
189
apps/dokploy/components/dashboard/search-command.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MariadbIcon,
|
||||||
|
MongodbIcon,
|
||||||
|
MysqlIcon,
|
||||||
|
PostgresqlIcon,
|
||||||
|
RedisIcon,
|
||||||
|
} from "@/components/icons/data-tools-icons";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
type Services,
|
||||||
|
extractServices,
|
||||||
|
} from "@/pages/dashboard/project/[projectId]";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import type { findProjectById } from "@dokploy/server/services/project";
|
||||||
|
import { BookIcon, CircuitBoard, GlobeIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import React from "react";
|
||||||
|
import { StatusTooltip } from "../shared/status-tooltip";
|
||||||
|
|
||||||
|
type Project = Awaited<ReturnType<typeof findProjectById>>;
|
||||||
|
|
||||||
|
export const SearchCommand = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [search, setSearch] = React.useState("");
|
||||||
|
|
||||||
|
const { data } = api.project.all.useQuery();
|
||||||
|
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const down = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((open) => !open);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", down);
|
||||||
|
return () => document.removeEventListener("keydown", down);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<CommandInput
|
||||||
|
placeholder={"Search projects or settings"}
|
||||||
|
value={search}
|
||||||
|
onValueChange={setSearch}
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>
|
||||||
|
No projects added yet. Click on Create project.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup heading={"Projects"}>
|
||||||
|
<CommandList>
|
||||||
|
{data?.map((project) => (
|
||||||
|
<CommandItem
|
||||||
|
key={project.projectId}
|
||||||
|
onSelect={() => {
|
||||||
|
router.push(`/dashboard/project/${project.projectId}`);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BookIcon className="size-4 text-muted-foreground mr-2" />
|
||||||
|
{project.name}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading={"Services"}>
|
||||||
|
<CommandList>
|
||||||
|
{data?.map((project) => {
|
||||||
|
const applications: Services[] = extractServices(project);
|
||||||
|
return applications.map((application) => (
|
||||||
|
<CommandItem
|
||||||
|
key={application.id}
|
||||||
|
onSelect={() => {
|
||||||
|
router.push(
|
||||||
|
`/dashboard/project/${project.projectId}/services/${application.type}/${application.id}`,
|
||||||
|
);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{application.type === "postgres" && (
|
||||||
|
<PostgresqlIcon className="h-6 w-6 mr-2" />
|
||||||
|
)}
|
||||||
|
{application.type === "redis" && (
|
||||||
|
<RedisIcon className="h-6 w-6 mr-2" />
|
||||||
|
)}
|
||||||
|
{application.type === "mariadb" && (
|
||||||
|
<MariadbIcon className="h-6 w-6 mr-2" />
|
||||||
|
)}
|
||||||
|
{application.type === "mongo" && (
|
||||||
|
<MongodbIcon className="h-6 w-6 mr-2" />
|
||||||
|
)}
|
||||||
|
{application.type === "mysql" && (
|
||||||
|
<MysqlIcon className="h-6 w-6 mr-2" />
|
||||||
|
)}
|
||||||
|
{application.type === "application" && (
|
||||||
|
<GlobeIcon className="h-6 w-6 mr-2" />
|
||||||
|
)}
|
||||||
|
{application.type === "compose" && (
|
||||||
|
<CircuitBoard className="h-6 w-6 mr-2" />
|
||||||
|
)}
|
||||||
|
<span className="flex-grow">
|
||||||
|
{project.name} / {application.name}{" "}
|
||||||
|
<div style={{ display: "none" }}>{application.id}</div>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<StatusTooltip status={application.status} />
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
));
|
||||||
|
})}
|
||||||
|
</CommandList>
|
||||||
|
</CommandGroup>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading={"Application"} hidden={true}>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
router.push("/dashboard/projects");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Projects
|
||||||
|
</CommandItem>
|
||||||
|
{!isCloud && (
|
||||||
|
<>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
router.push("/dashboard/monitoring");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Monitoring
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
router.push("/dashboard/traefik");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Traefik
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
router.push("/dashboard/docker");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Docker
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
router.push("/dashboard/requests");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Requests
|
||||||
|
</CommandItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => {
|
||||||
|
router.push("/dashboard/settings/server");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Languages } from "@/lib/languages";
|
||||||
import useLocale from "@/utils/hooks/use-locale";
|
import useLocale from "@/utils/hooks/use-locale";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
@@ -37,7 +38,7 @@ const appearanceFormSchema = z.object({
|
|||||||
theme: z.enum(["light", "dark", "system"], {
|
theme: z.enum(["light", "dark", "system"], {
|
||||||
required_error: "Please select a theme.",
|
required_error: "Please select a theme.",
|
||||||
}),
|
}),
|
||||||
language: z.enum(["en", "pl", "zh-Hans"], {
|
language: z.nativeEnum(Languages, {
|
||||||
required_error: "Please select a language.",
|
required_error: "Please select a language.",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -47,7 +48,7 @@ type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
|
|||||||
// This can come from your database or API.
|
// This can come from your database or API.
|
||||||
const defaultValues: Partial<AppearanceFormValues> = {
|
const defaultValues: Partial<AppearanceFormValues> = {
|
||||||
theme: "system",
|
theme: "system",
|
||||||
language: "en",
|
language: Languages.English,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppearanceForm() {
|
export function AppearanceForm() {
|
||||||
@@ -172,15 +173,15 @@ export function AppearanceForm() {
|
|||||||
<SelectValue placeholder="No preset selected" />
|
<SelectValue placeholder="No preset selected" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{[
|
{Object.keys(Languages).map((preset) => {
|
||||||
{ label: "English", value: "en" },
|
const value =
|
||||||
{ label: "Polski", value: "pl" },
|
Languages[preset as keyof typeof Languages];
|
||||||
{ label: "简体中文", value: "zh-Hans" },
|
return (
|
||||||
].map((preset) => (
|
<SelectItem key={value} value={value}>
|
||||||
<SelectItem key={preset.label} value={preset.value}>
|
{preset}
|
||||||
{preset.label}
|
</SelectItem>
|
||||||
</SelectItem>
|
);
|
||||||
))}
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -89,18 +89,14 @@ export const ShowBilling = () => {
|
|||||||
<div className="pb-5">
|
<div className="pb-5">
|
||||||
<Progress value={safePercentage} className="max-w-lg" />
|
<Progress value={safePercentage} className="max-w-lg" />
|
||||||
</div>
|
</div>
|
||||||
{admin && (
|
{admin && admin.serversQuantity! <= servers?.length! && (
|
||||||
<>
|
<div className="flex flex-row gap-4 p-2 bg-yellow-50 dark:bg-yellow-950 rounded-lg items-center">
|
||||||
{admin.serversQuantity! <= servers?.length! && (
|
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
|
||||||
<div className="flex flex-row gap-4 p-2 bg-yellow-50 dark:bg-yellow-950 rounded-lg items-center">
|
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||||
<AlertTriangle className="text-yellow-600 dark:text-yellow-400" />
|
You have reached the maximum number of servers you can create,
|
||||||
<span className="text-sm text-yellow-600 dark:text-yellow-400">
|
please upgrade your plan to add more servers.
|
||||||
You have reached the maximum number of servers you can
|
</span>
|
||||||
create, please upgrade your plan to add more servers.
|
</div>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -188,7 +184,6 @@ export const ShowBilling = () => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul
|
<ul
|
||||||
role="list"
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
" mt-4 flex flex-col gap-y-2 text-sm",
|
" mt-4 flex flex-col gap-y-2 text-sm",
|
||||||
featured ? "text-white" : "text-slate-200",
|
featured ? "text-white" : "text-slate-200",
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ import { z } from "zod";
|
|||||||
const certificateDataHolder =
|
const certificateDataHolder =
|
||||||
"-----BEGIN CERTIFICATE-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n------END CERTIFICATE-----";
|
"-----BEGIN CERTIFICATE-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n------END CERTIFICATE-----";
|
||||||
|
|
||||||
|
const privateKeyDataHolder =
|
||||||
|
"-----BEGIN PRIVATE KEY-----\nMIIFRDCCAyygAwIBAgIUEPOR47ys6VDwMVB9tYoeEka83uQwDQYJKoZIhvcNAQELBQAwGTEXMBUGA1UEAwwObWktZG9taW5pby5jb20wHhcNMjQwMzExMDQyNzU3WhcN\n-----END PRIVATE KEY-----";
|
||||||
|
|
||||||
const addCertificate = z.object({
|
const addCertificate = z.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
certificateData: z.string().min(1, "Certificate data is required"),
|
certificateData: z.string().min(1, "Certificate data is required"),
|
||||||
@@ -154,7 +157,7 @@ export const AddCertificate = () => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
className="h-32"
|
className="h-32"
|
||||||
placeholder={certificateDataHolder}
|
placeholder={privateKeyDataHolder}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -6,13 +6,144 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ShieldCheck } from "lucide-react";
|
import { AlertCircle, Link, ShieldCheck } from "lucide-react";
|
||||||
import { AddCertificate } from "./add-certificate";
|
import { AddCertificate } from "./add-certificate";
|
||||||
import { DeleteCertificate } from "./delete-certificate";
|
import { DeleteCertificate } from "./delete-certificate";
|
||||||
|
|
||||||
export const ShowCertificates = () => {
|
export const ShowCertificates = () => {
|
||||||
const { data } = api.certificates.all.useQuery();
|
const { data } = api.certificates.all.useQuery();
|
||||||
|
|
||||||
|
const extractExpirationDate = (certData: string): Date | null => {
|
||||||
|
try {
|
||||||
|
const match = certData.match(
|
||||||
|
/-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/,
|
||||||
|
);
|
||||||
|
if (!match?.[1]) return null;
|
||||||
|
|
||||||
|
const base64Cert = match[1].replace(/\s/g, "");
|
||||||
|
const binaryStr = window.atob(base64Cert);
|
||||||
|
const bytes = new Uint8Array(binaryStr.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < binaryStr.length; i++) {
|
||||||
|
bytes[i] = binaryStr.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dateFound = 0;
|
||||||
|
for (let i = 0; i < bytes.length - 2; i++) {
|
||||||
|
if (bytes[i] === 0x17 || bytes[i] === 0x18) {
|
||||||
|
const dateType = bytes[i];
|
||||||
|
const dateLength = bytes[i + 1];
|
||||||
|
if (typeof dateLength === "undefined") continue;
|
||||||
|
|
||||||
|
if (dateFound === 0) {
|
||||||
|
dateFound++;
|
||||||
|
i += dateLength + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dateStr = "";
|
||||||
|
for (let j = 0; j < dateLength; j++) {
|
||||||
|
const charCode = bytes[i + 2 + j];
|
||||||
|
if (typeof charCode === "undefined") continue;
|
||||||
|
dateStr += String.fromCharCode(charCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateType === 0x17) {
|
||||||
|
// UTCTime (YYMMDDhhmmssZ)
|
||||||
|
const year = Number.parseInt(dateStr.slice(0, 2));
|
||||||
|
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
|
||||||
|
return new Date(
|
||||||
|
Date.UTC(
|
||||||
|
fullYear,
|
||||||
|
Number.parseInt(dateStr.slice(2, 4)) - 1,
|
||||||
|
Number.parseInt(dateStr.slice(4, 6)),
|
||||||
|
Number.parseInt(dateStr.slice(6, 8)),
|
||||||
|
Number.parseInt(dateStr.slice(8, 10)),
|
||||||
|
Number.parseInt(dateStr.slice(10, 12)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneralizedTime (YYYYMMDDhhmmssZ)
|
||||||
|
return new Date(
|
||||||
|
Date.UTC(
|
||||||
|
Number.parseInt(dateStr.slice(0, 4)),
|
||||||
|
Number.parseInt(dateStr.slice(4, 6)) - 1,
|
||||||
|
Number.parseInt(dateStr.slice(6, 8)),
|
||||||
|
Number.parseInt(dateStr.slice(8, 10)),
|
||||||
|
Number.parseInt(dateStr.slice(10, 12)),
|
||||||
|
Number.parseInt(dateStr.slice(12, 14)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing certificate:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExpirationStatus = (certData: string) => {
|
||||||
|
const expirationDate = extractExpirationDate(certData);
|
||||||
|
|
||||||
|
if (!expirationDate)
|
||||||
|
return {
|
||||||
|
status: "unknown" as const,
|
||||||
|
className: "text-muted-foreground",
|
||||||
|
message: "Could not determine expiration",
|
||||||
|
};
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const daysUntilExpiration = Math.ceil(
|
||||||
|
(expirationDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (daysUntilExpiration < 0) {
|
||||||
|
return {
|
||||||
|
status: "expired" as const,
|
||||||
|
className: "text-red-500",
|
||||||
|
message: `Expired on ${expirationDate.toLocaleDateString([], {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysUntilExpiration <= 30) {
|
||||||
|
return {
|
||||||
|
status: "warning" as const,
|
||||||
|
className: "text-yellow-500",
|
||||||
|
message: `Expires in ${daysUntilExpiration} days`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "valid" as const,
|
||||||
|
className: "text-muted-foreground",
|
||||||
|
message: `Expires ${expirationDate.toLocaleDateString([], {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCertificateChainInfo = (certData: string) => {
|
||||||
|
const certCount = (certData.match(/-----BEGIN CERTIFICATE-----/g) || [])
|
||||||
|
.length;
|
||||||
|
return certCount > 1
|
||||||
|
? {
|
||||||
|
isChain: true,
|
||||||
|
count: certCount,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
isChain: false,
|
||||||
|
count: 1,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
<Card className="bg-transparent h-full">
|
<Card className="bg-transparent h-full">
|
||||||
@@ -23,7 +154,7 @@ export const ShowCertificates = () => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 pt-4 h-full">
|
<CardContent className="space-y-2 pt-4 h-full">
|
||||||
{data?.length === 0 ? (
|
{!data?.length ? (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<ShieldCheck className="size-8 self-center text-muted-foreground" />
|
<ShieldCheck className="size-8 self-center text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
@@ -35,21 +166,53 @@ export const ShowCertificates = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
{data?.map((destination, index) => (
|
{data.map((certificate, index) => {
|
||||||
<div
|
const expiration = getExpirationStatus(
|
||||||
key={destination.certificateId}
|
certificate.certificateData,
|
||||||
className="flex items-center justify-between border p-4 rounded-lg"
|
);
|
||||||
>
|
const chainInfo = getCertificateChainInfo(
|
||||||
<span className="text-sm text-muted-foreground">
|
certificate.certificateData,
|
||||||
{index + 1}. {destination.name}
|
);
|
||||||
</span>
|
return (
|
||||||
<div className="flex flex-row gap-3">
|
<div
|
||||||
<DeleteCertificate
|
key={certificate.certificateId}
|
||||||
certificateId={destination.certificateId}
|
className="flex flex-col border p-4 rounded-lg space-y-2"
|
||||||
/>
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{index + 1}. {certificate.name}
|
||||||
|
</span>
|
||||||
|
{chainInfo.isChain && (
|
||||||
|
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-muted/50">
|
||||||
|
<Link className="size-3 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Chain ({chainInfo.count})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DeleteCertificate
|
||||||
|
certificateId={certificate.certificateId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-xs flex items-center gap-1.5 ${expiration.className}`}
|
||||||
|
>
|
||||||
|
{expiration.status !== "valid" && (
|
||||||
|
<AlertCircle className="size-3" />
|
||||||
|
)}
|
||||||
|
{expiration.message}
|
||||||
|
{certificate.autoRenew &&
|
||||||
|
expiration.status !== "valid" && (
|
||||||
|
<span className="text-xs text-emerald-500 ml-1">
|
||||||
|
(Auto-renewal enabled)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<AddCertificate />
|
<AddCertificate />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -33,7 +34,13 @@ export const ShowNodeData = ({ data }: Props) => {
|
|||||||
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem] bg-card">
|
<div className="text-wrap rounded-lg border p-4 text-sm sm:max-w-[59rem] bg-card">
|
||||||
<code>
|
<code>
|
||||||
<pre className="whitespace-pre-wrap break-words">
|
<pre className="whitespace-pre-wrap break-words">
|
||||||
{JSON.stringify(data, null, 2)}
|
<CodeEditor
|
||||||
|
language="json"
|
||||||
|
lineWrapping
|
||||||
|
lineNumbers={false}
|
||||||
|
readOnly
|
||||||
|
value={JSON.stringify(data, null, 2)}
|
||||||
|
/>
|
||||||
</pre>
|
</pre>
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -159,7 +159,11 @@ export const AddRegistry = () => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Username</FormLabel>
|
<FormLabel>Username</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Username" {...field} />
|
<Input
|
||||||
|
placeholder="Username"
|
||||||
|
autoComplete="off"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -177,6 +181,7 @@ export const AddRegistry = () => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
|
autoComplete="off"
|
||||||
{...field}
|
{...field}
|
||||||
type="password"
|
type="password"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -28,11 +28,7 @@ export const ShowRegistry = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
{data && data?.length > 0 && (
|
{data && data?.length > 0 && <AddRegistry />}
|
||||||
<>
|
|
||||||
<AddRegistry />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 pt-4 h-full">
|
<CardContent className="space-y-2 pt-4 h-full">
|
||||||
|
|||||||
@@ -34,9 +34,11 @@ import { useEffect } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { S3_PROVIDERS } from "./constants";
|
||||||
|
|
||||||
const addDestination = z.object({
|
const addDestination = z.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
|
provider: z.string().optional(),
|
||||||
accessKeyId: z.string(),
|
accessKeyId: z.string(),
|
||||||
secretAccessKey: z.string(),
|
secretAccessKey: z.string(),
|
||||||
bucket: z.string(),
|
bucket: z.string(),
|
||||||
@@ -58,6 +60,7 @@ export const AddDestination = () => {
|
|||||||
api.destination.testConnection.useMutation();
|
api.destination.testConnection.useMutation();
|
||||||
const form = useForm<AddDestination>({
|
const form = useForm<AddDestination>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
provider: "",
|
||||||
accessKeyId: "",
|
accessKeyId: "",
|
||||||
bucket: "",
|
bucket: "",
|
||||||
name: "",
|
name: "",
|
||||||
@@ -73,6 +76,7 @@ export const AddDestination = () => {
|
|||||||
|
|
||||||
const onSubmit = async (data: AddDestination) => {
|
const onSubmit = async (data: AddDestination) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
|
provider: data.provider || "",
|
||||||
accessKey: data.accessKeyId,
|
accessKey: data.accessKeyId,
|
||||||
bucket: data.bucket,
|
bucket: data.bucket,
|
||||||
endpoint: data.endpoint,
|
endpoint: data.endpoint,
|
||||||
@@ -123,6 +127,40 @@ export const AddDestination = () => {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="provider"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Provider</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a S3 Provider" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{S3_PROVIDERS.map((s3Provider) => (
|
||||||
|
<SelectItem
|
||||||
|
key={s3Provider.key}
|
||||||
|
value={s3Provider.key}
|
||||||
|
>
|
||||||
|
{s3Provider.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -255,6 +293,7 @@ export const AddDestination = () => {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await testConnection({
|
await testConnection({
|
||||||
|
provider: form.getValues("provider") || "",
|
||||||
accessKey: form.getValues("accessKeyId"),
|
accessKey: form.getValues("accessKeyId"),
|
||||||
bucket: form.getValues("bucket"),
|
bucket: form.getValues("bucket"),
|
||||||
endpoint: form.getValues("endpoint"),
|
endpoint: form.getValues("endpoint"),
|
||||||
@@ -283,6 +322,7 @@ export const AddDestination = () => {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await testConnection({
|
await testConnection({
|
||||||
|
provider: form.getValues("provider") || "",
|
||||||
accessKey: form.getValues("accessKeyId"),
|
accessKey: form.getValues("accessKeyId"),
|
||||||
bucket: form.getValues("bucket"),
|
bucket: form.getValues("bucket"),
|
||||||
endpoint: form.getValues("endpoint"),
|
endpoint: form.getValues("endpoint"),
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
export const S3_PROVIDERS: Array<{
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
key: "AWS",
|
||||||
|
name: "Amazon Web Services (AWS) S3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Alibaba",
|
||||||
|
name: "Alibaba Cloud Object Storage System (OSS) formerly Aliyun",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ArvanCloud",
|
||||||
|
name: "Arvan Cloud Object Storage (AOS)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Ceph",
|
||||||
|
name: "Ceph Object Storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "ChinaMobile",
|
||||||
|
name: "China Mobile Ecloud Elastic Object Storage (EOS)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Cloudflare",
|
||||||
|
name: "Cloudflare R2 Storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "DigitalOcean",
|
||||||
|
name: "DigitalOcean Spaces",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Dreamhost",
|
||||||
|
name: "Dreamhost DreamObjects",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "GCS",
|
||||||
|
name: "Google Cloud Storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "HuaweiOBS",
|
||||||
|
name: "Huawei Object Storage Service",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "IBMCOS",
|
||||||
|
name: "IBM COS S3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "IDrive",
|
||||||
|
name: "IDrive e2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "IONOS",
|
||||||
|
name: "IONOS Cloud",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "LyveCloud",
|
||||||
|
name: "Seagate Lyve Cloud",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Leviia",
|
||||||
|
name: "Leviia Object Storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Liara",
|
||||||
|
name: "Liara Object Storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Linode",
|
||||||
|
name: "Linode Object Storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Magalu",
|
||||||
|
name: "Magalu Object Storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Minio",
|
||||||
|
name: "Minio Object Storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Netease",
|
||||||
|
name: "Netease Object Storage (NOS)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Petabox",
|
||||||
|
name: "Petabox Object Storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "RackCorp",
|
||||||
|
name: "RackCorp Object Storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Rclone",
|
||||||
|
name: "Rclone S3 Server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Scaleway",
|
||||||
|
name: "Scaleway Object Storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "SeaweedFS",
|
||||||
|
name: "SeaweedFS S3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "StackPath",
|
||||||
|
name: "StackPath Object Storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Storj",
|
||||||
|
name: "Storj (S3 Compatible Gateway)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Synology",
|
||||||
|
name: "Synology C2 Object Storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "TencentCOS",
|
||||||
|
name: "Tencent Cloud Object Storage (COS)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Wasabi",
|
||||||
|
name: "Wasabi Object Storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Qiniu",
|
||||||
|
name: "Qiniu Object Storage (Kodo)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Other",
|
||||||
|
name: "Any other S3 compatible provider",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -35,9 +35,11 @@ import { useEffect, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { S3_PROVIDERS } from "./constants";
|
||||||
|
|
||||||
const updateDestination = z.object({
|
const updateDestination = z.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
|
provider: z.string().optional(),
|
||||||
accessKeyId: z.string(),
|
accessKeyId: z.string(),
|
||||||
secretAccessKey: z.string(),
|
secretAccessKey: z.string(),
|
||||||
bucket: z.string(),
|
bucket: z.string(),
|
||||||
@@ -70,6 +72,7 @@ export const UpdateDestination = ({ destinationId }: Props) => {
|
|||||||
api.destination.testConnection.useMutation();
|
api.destination.testConnection.useMutation();
|
||||||
const form = useForm<UpdateDestination>({
|
const form = useForm<UpdateDestination>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
provider: "",
|
||||||
accessKeyId: "",
|
accessKeyId: "",
|
||||||
bucket: "",
|
bucket: "",
|
||||||
name: "",
|
name: "",
|
||||||
@@ -152,6 +155,40 @@ export const UpdateDestination = ({ destinationId }: Props) => {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="provider"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Provider</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a S3 Provider" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{S3_PROVIDERS.map((s3Provider) => (
|
||||||
|
<SelectItem
|
||||||
|
key={s3Provider.key}
|
||||||
|
value={s3Provider.key}
|
||||||
|
>
|
||||||
|
{s3Provider.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -285,6 +322,7 @@ export const UpdateDestination = ({ destinationId }: Props) => {
|
|||||||
variant={"secondary"}
|
variant={"secondary"}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await testConnection({
|
await testConnection({
|
||||||
|
provider: form.getValues("provider") || "",
|
||||||
accessKey: form.getValues("accessKeyId"),
|
accessKey: form.getValues("accessKeyId"),
|
||||||
bucket: form.getValues("bucket"),
|
bucket: form.getValues("bucket"),
|
||||||
endpoint: form.getValues("endpoint"),
|
endpoint: form.getValues("endpoint"),
|
||||||
@@ -311,6 +349,7 @@ export const UpdateDestination = ({ destinationId }: Props) => {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await testConnection({
|
await testConnection({
|
||||||
|
provider: form.getValues("provider") || "",
|
||||||
accessKey: form.getValues("accessKeyId"),
|
accessKey: form.getValues("accessKeyId"),
|
||||||
bucket: form.getValues("bucket"),
|
bucket: form.getValues("bucket"),
|
||||||
endpoint: form.getValues("endpoint"),
|
endpoint: form.getValues("endpoint"),
|
||||||
|
|||||||
@@ -107,7 +107,24 @@ export const AddGithubProvider = () => {
|
|||||||
/>
|
/>
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<a
|
||||||
|
href={
|
||||||
|
isOrganization && organizationName
|
||||||
|
? `https://github.com/organizations/${organizationName}/settings/installations`
|
||||||
|
: "https://github.com/settings/installations"
|
||||||
|
}
|
||||||
|
className={`text-muted-foreground text-sm hover:underline duration-300
|
||||||
|
${
|
||||||
|
isOrganization && !organizationName
|
||||||
|
? "pointer-events-none opacity-50"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Unsure if you already have an app?
|
||||||
|
</a>
|
||||||
<Button
|
<Button
|
||||||
disabled={isOrganization && organizationName.length < 1}
|
disabled={isOrganization && organizationName.length < 1}
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ const Schema = z.object({
|
|||||||
name: z.string().min(1, {
|
name: z.string().min(1, {
|
||||||
message: "Name is required",
|
message: "Name is required",
|
||||||
}),
|
}),
|
||||||
|
gitlabUrl: z.string().min(1, {
|
||||||
|
message: "GitLab URL is required",
|
||||||
|
}),
|
||||||
applicationId: z.string().min(1, {
|
applicationId: z.string().min(1, {
|
||||||
message: "Application ID is required",
|
message: "Application ID is required",
|
||||||
}),
|
}),
|
||||||
@@ -62,16 +65,22 @@ export const AddGitlabProvider = () => {
|
|||||||
applicationSecret: "",
|
applicationSecret: "",
|
||||||
groupName: "",
|
groupName: "",
|
||||||
redirectUri: webhookUrl,
|
redirectUri: webhookUrl,
|
||||||
|
name: "",
|
||||||
|
gitlabUrl: "https://gitlab.com",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(Schema),
|
resolver: zodResolver(Schema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const gitlabUrl = form.watch("gitlabUrl");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset({
|
form.reset({
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
applicationSecret: "",
|
applicationSecret: "",
|
||||||
groupName: "",
|
groupName: "",
|
||||||
redirectUri: webhookUrl,
|
redirectUri: webhookUrl,
|
||||||
|
name: "",
|
||||||
|
gitlabUrl: "https://gitlab.com",
|
||||||
});
|
});
|
||||||
}, [form, isOpen]);
|
}, [form, isOpen]);
|
||||||
|
|
||||||
@@ -83,6 +92,7 @@ export const AddGitlabProvider = () => {
|
|||||||
authId: auth?.id || "",
|
authId: auth?.id || "",
|
||||||
name: data.name || "",
|
name: data.name || "",
|
||||||
redirectUri: data.redirectUri || "",
|
redirectUri: data.redirectUri || "",
|
||||||
|
gitlabUrl: data.gitlabUrl || "https://gitlab.com",
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await utils.gitProvider.getAll.invalidate();
|
await utils.gitProvider.getAll.invalidate();
|
||||||
@@ -129,7 +139,7 @@ export const AddGitlabProvider = () => {
|
|||||||
<li className="flex flex-row gap-2 items-center">
|
<li className="flex flex-row gap-2 items-center">
|
||||||
Go to your GitLab profile settings{" "}
|
Go to your GitLab profile settings{" "}
|
||||||
<Link
|
<Link
|
||||||
href="https://gitlab.com/-/profile/applications"
|
href={`${gitlabUrl}/-/profile/applications`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-fit text-primary size-4" />
|
<ExternalLink className="w-fit text-primary size-4" />
|
||||||
@@ -169,6 +179,20 @@ export const AddGitlabProvider = () => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="gitlabUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Gitlab URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="https://gitlab.com/" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="redirectUri"
|
name="redirectUri"
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ const Schema = z.object({
|
|||||||
name: z.string().min(1, {
|
name: z.string().min(1, {
|
||||||
message: "Name is required",
|
message: "Name is required",
|
||||||
}),
|
}),
|
||||||
|
gitlabUrl: z.string().url({
|
||||||
|
message: "Invalid Gitlab URL",
|
||||||
|
}),
|
||||||
groupName: z.string().optional(),
|
groupName: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,7 +43,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||||
const { data: gitlab } = api.gitlab.one.useQuery(
|
const { data: gitlab, refetch } = api.gitlab.one.useQuery(
|
||||||
{
|
{
|
||||||
gitlabId,
|
gitlabId,
|
||||||
},
|
},
|
||||||
@@ -57,6 +60,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
groupName: "",
|
groupName: "",
|
||||||
name: "",
|
name: "",
|
||||||
|
gitlabUrl: "https://gitlab.com",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(Schema),
|
resolver: zodResolver(Schema),
|
||||||
});
|
});
|
||||||
@@ -67,6 +71,7 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
|||||||
form.reset({
|
form.reset({
|
||||||
groupName: gitlab?.groupName || "",
|
groupName: gitlab?.groupName || "",
|
||||||
name: gitlab?.gitProvider.name || "",
|
name: gitlab?.gitProvider.name || "",
|
||||||
|
gitlabUrl: gitlab?.gitlabUrl || "",
|
||||||
});
|
});
|
||||||
}, [form, isOpen]);
|
}, [form, isOpen]);
|
||||||
|
|
||||||
@@ -76,11 +81,13 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
|||||||
gitProviderId: gitlab?.gitProviderId || "",
|
gitProviderId: gitlab?.gitProviderId || "",
|
||||||
groupName: data.groupName || "",
|
groupName: data.groupName || "",
|
||||||
name: data.name || "",
|
name: data.name || "",
|
||||||
|
gitlabUrl: data.gitlabUrl || "",
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await utils.gitProvider.getAll.invalidate();
|
await utils.gitProvider.getAll.invalidate();
|
||||||
toast.success("Gitlab updated successfully");
|
toast.success("Gitlab updated successfully");
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error to update Gitlab");
|
toast.error("Error to update Gitlab");
|
||||||
@@ -126,6 +133,19 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="gitlabUrl"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Gitlab Url</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="https://gitlab.com" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -23,12 +23,16 @@ export const ShowGitProviders = () => {
|
|||||||
|
|
||||||
const url = useUrl();
|
const url = useUrl();
|
||||||
|
|
||||||
const getGitlabUrl = (clientId: string, gitlabId: string) => {
|
const getGitlabUrl = (
|
||||||
|
clientId: string,
|
||||||
|
gitlabId: string,
|
||||||
|
gitlabUrl: string,
|
||||||
|
) => {
|
||||||
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
|
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
|
||||||
|
|
||||||
const scope = "api read_user read_repository";
|
const scope = "api read_user read_repository";
|
||||||
|
|
||||||
const authUrl = `https://gitlab.com/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
|
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
|
||||||
|
|
||||||
return authUrl;
|
return authUrl;
|
||||||
};
|
};
|
||||||
@@ -142,6 +146,7 @@ export const ShowGitProviders = () => {
|
|||||||
href={getGitlabUrl(
|
href={getGitlabUrl(
|
||||||
gitProvider.gitlab?.applicationId || "",
|
gitProvider.gitlab?.applicationId || "",
|
||||||
gitProvider.gitlab?.gitlabId || "",
|
gitProvider.gitlab?.gitlabId || "",
|
||||||
|
gitProvider.gitlab?.gitlabUrl,
|
||||||
)}
|
)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className={buttonVariants({
|
className={buttonVariants({
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
|||||||
.object({
|
.object({
|
||||||
type: z.literal("discord"),
|
type: z.literal("discord"),
|
||||||
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
|
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
|
||||||
|
decoration: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
.merge(notificationBaseSchema),
|
.merge(notificationBaseSchema),
|
||||||
z
|
z
|
||||||
@@ -195,6 +196,7 @@ export const AddNotification = () => {
|
|||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
|
decoration: data.decoration,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
dockerCleanup: dockerCleanup,
|
dockerCleanup: dockerCleanup,
|
||||||
});
|
});
|
||||||
@@ -415,6 +417,28 @@ export const AddNotification = () => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="decoration"
|
||||||
|
defaultValue={true}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Decoration</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Decorate the notification with emojis.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -669,7 +693,7 @@ export const AddNotification = () => {
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>Dokploy Restart</FormLabel>
|
<FormLabel>Dokploy Restart</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Trigger the action when a dokploy is restarted.
|
Trigger the action when dokploy is restarted.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -710,6 +734,7 @@ export const AddNotification = () => {
|
|||||||
} else if (type === "discord") {
|
} else if (type === "discord") {
|
||||||
await testDiscordConnection({
|
await testDiscordConnection({
|
||||||
webhookUrl: form.getValues("webhookUrl"),
|
webhookUrl: form.getValues("webhookUrl"),
|
||||||
|
decoration: form.getValues("decoration"),
|
||||||
});
|
});
|
||||||
} else if (type === "email") {
|
} else if (type === "email") {
|
||||||
await testEmailConnection({
|
await testEmailConnection({
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { Trash2 } from "lucide-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -24,8 +24,13 @@ export const DeleteNotification = ({ notificationId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="ghost" isLoading={isLoading}>
|
<Button
|
||||||
<TrashIcon className="size-4 text-muted-foreground" />
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 group hover:bg-red-500/10"
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-muted-foreground group-hover:text-red-500" />
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -40,34 +40,45 @@ export const ShowNotifications = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid lg:grid-cols-1 xl:grid-cols-2 gap-4">
|
||||||
{data?.map((notification, index) => (
|
{data?.map((notification, index) => (
|
||||||
<div
|
<div
|
||||||
key={notification.notificationId}
|
key={notification.notificationId}
|
||||||
className="flex items-center justify-between border gap-2 p-3.5 rounded-lg"
|
className="flex items-center justify-between rounded-xl p-4 transition-colors dark:bg-zinc-900/50 bg-gray-200/50 border border-card"
|
||||||
>
|
>
|
||||||
<div className="flex flex-row gap-2 items-center w-full ">
|
<div className="flex items-center gap-4">
|
||||||
{notification.notificationType === "slack" && (
|
{notification.notificationType === "slack" && (
|
||||||
<SlackIcon className="text-muted-foreground size-6 flex-shrink-0" />
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10">
|
||||||
|
<SlackIcon className="h-6 w-6 text-indigo-400" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{notification.notificationType === "telegram" && (
|
{notification.notificationType === "telegram" && (
|
||||||
<TelegramIcon className="text-muted-foreground size-8 flex-shrink-0" />
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-cyan-500/10">
|
||||||
|
<TelegramIcon className="h-6 w-6 text-indigo-400" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{notification.notificationType === "discord" && (
|
{notification.notificationType === "discord" && (
|
||||||
<DiscordIcon className="text-muted-foreground size-7 flex-shrink-0" />
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-500/10">
|
||||||
|
<DiscordIcon className="h-6 w-6 text-indigo-400" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{notification.notificationType === "email" && (
|
{notification.notificationType === "email" && (
|
||||||
<Mail
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-zinc-500/10">
|
||||||
size={29}
|
<Mail className="h-6 w-6 text-indigo-400" />
|
||||||
className="text-muted-foreground size-6 flex-shrink-0"
|
</div>
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<span className="text-sm text-muted-foreground">
|
<div className="flex flex-col">
|
||||||
{notification.name}
|
<span className="text-sm font-medium dark:text-zinc-300 text-zinc-800">
|
||||||
</span>
|
{notification.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
{notification.notificationType?.[0]?.toUpperCase() +
|
||||||
|
notification.notificationType?.slice(1)}{" "}
|
||||||
|
notification
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex flex-row gap-1 w-fit">
|
|
||||||
<UpdateNotification
|
<UpdateNotification
|
||||||
notificationId={notification.notificationId}
|
notificationId={notification.notificationId}
|
||||||
/>
|
/>
|
||||||
@@ -78,6 +89,7 @@ export const ShowNotifications = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 justify-end w-full items-end">
|
<div className="flex flex-col gap-4 justify-end w-full items-end">
|
||||||
<AddNotification />
|
<AddNotification />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user