mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Compare commits
566 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
892f272108 | ||
|
|
fca537ee40 | ||
|
|
ae24aa8be5 | ||
|
|
b74d3995ee | ||
|
|
f7fd77f7e9 | ||
|
|
db8a4e6edf | ||
|
|
fa16cfec2a | ||
|
|
f35d084dd4 | ||
|
|
274daf52c0 | ||
|
|
da52d767eb | ||
|
|
45a178e705 | ||
|
|
ebf9db7cc0 | ||
|
|
ec6c685a28 | ||
|
|
7b14e4c5d2 | ||
|
|
316f592e09 | ||
|
|
bd82199ae0 | ||
|
|
89d573a2f5 | ||
|
|
3d285ca437 | ||
|
|
8c5e34c528 | ||
|
|
98199e65bf | ||
|
|
bf1026af7a | ||
|
|
7c9767d90f | ||
|
|
688f6478f1 | ||
|
|
cad17e0f7f | ||
|
|
d97461d820 | ||
|
|
9686848090 | ||
|
|
a7b644e403 | ||
|
|
96b4c334da | ||
|
|
1b99c3ac23 | ||
|
|
a12b514525 | ||
|
|
ea91b01461 | ||
|
|
149b8f70d8 | ||
|
|
6be4984649 | ||
|
|
7ec68e688b | ||
|
|
b30f8944c4 | ||
|
|
f0d242b9b9 | ||
|
|
b6d86b4732 | ||
|
|
304134cdda | ||
|
|
c84b271511 | ||
|
|
96dd8d37a5 | ||
|
|
be91b53c86 | ||
|
|
98c77d539e | ||
|
|
67f5befa48 | ||
|
|
5b2056101f | ||
|
|
000b4ba49e | ||
|
|
4efa56aae5 | ||
|
|
a788a73fa3 | ||
|
|
319ca6944d | ||
|
|
238736db8d | ||
|
|
556a437251 | ||
|
|
ef5e1d6818 | ||
|
|
1089a8247d | ||
|
|
ef0cef99a1 | ||
|
|
8737dc86c9 | ||
|
|
cf06e5369a | ||
|
|
973de2a610 | ||
|
|
f8baf6fe41 | ||
|
|
3e05be4513 | ||
|
|
b3b009761a | ||
|
|
a659594134 | ||
|
|
9a1f0b467d | ||
|
|
e8b3abb7c9 | ||
|
|
8215d2e79f | ||
|
|
9c19b1efa3 | ||
|
|
4966bbeb73 | ||
|
|
df97dc0179 | ||
|
|
b14b9300c0 | ||
|
|
a7d1fabd81 | ||
|
|
d171e3da91 | ||
|
|
2c77029dad | ||
|
|
030e482fce | ||
|
|
e53c67f0d9 | ||
|
|
0c12d967e2 | ||
|
|
98aabd7bd8 | ||
|
|
88e862544b | ||
|
|
7f9c19bc11 | ||
|
|
9535276fe6 | ||
|
|
56d21aff60 | ||
|
|
8436d364be | ||
|
|
5d5e56d144 | ||
|
|
0627b6fd3a | ||
|
|
39af44daef | ||
|
|
2619cb49d1 | ||
|
|
46d12fa9d8 | ||
|
|
51ee46496c | ||
|
|
a13e24dab0 | ||
|
|
4aac3476b6 | ||
|
|
037343a796 | ||
|
|
274d80ea7c | ||
|
|
629889f1a8 | ||
|
|
3e74ce05a7 | ||
|
|
d05218e848 | ||
|
|
0fbad4f75e | ||
|
|
c3cbaf2a57 | ||
|
|
560d493d56 | ||
|
|
27b2106630 | ||
|
|
609954c366 | ||
|
|
84faa9747e | ||
|
|
4b370ef43e | ||
|
|
b94a6bff92 | ||
|
|
276b754377 | ||
|
|
f3b3798362 | ||
|
|
461acc354e | ||
|
|
dfc75a9116 | ||
|
|
e1580bad23 | ||
|
|
b567ec1d83 | ||
|
|
9c73b8dc36 | ||
|
|
7348526873 | ||
|
|
6fc83f2db3 | ||
|
|
43d22c2bd4 | ||
|
|
38a5313967 | ||
|
|
ba3645933f | ||
|
|
2fa2e76e2e | ||
|
|
17a26353b6 | ||
|
|
e2c163c6d5 | ||
|
|
616e11722c | ||
|
|
91a44706df | ||
|
|
748de47a6d | ||
|
|
cbf9aef0df | ||
|
|
e2befc24a5 | ||
|
|
0f48f2c830 | ||
|
|
5dfa7645f3 | ||
|
|
7fe163dd33 | ||
|
|
19b56771b8 | ||
|
|
cff01ed438 | ||
|
|
10fa3c8cf1 | ||
|
|
6c5497ed21 | ||
|
|
380656efee | ||
|
|
c64d2245ce | ||
|
|
a985998b93 | ||
|
|
4f3ba16dfa | ||
|
|
6c788429f1 | ||
|
|
3176a9d7e3 | ||
|
|
94a6a9587e | ||
|
|
911681f389 | ||
|
|
5992688e85 | ||
|
|
425061e481 | ||
|
|
08c0bf8a21 | ||
|
|
64a2c9e0a1 | ||
|
|
21e46f5382 | ||
|
|
52b2158309 | ||
|
|
178d84d438 | ||
|
|
80016b57a8 | ||
|
|
b4b2d12f6e | ||
|
|
294378d95b | ||
|
|
c52812f9d3 | ||
|
|
82f7c5d5f3 | ||
|
|
3d2ae52259 | ||
|
|
bf115c7895 | ||
|
|
c2c29dbaba | ||
|
|
d4032f34bf | ||
|
|
136570b36c | ||
|
|
7d0075c230 | ||
|
|
19b4edee8d | ||
|
|
7f04eb856e | ||
|
|
5156b45ffc | ||
|
|
80e6f21840 | ||
|
|
5b519151e8 | ||
|
|
2ad8bf355b | ||
|
|
aa475e6123 | ||
|
|
66756c34fe | ||
|
|
946a5739dc | ||
|
|
6c817a9e5d | ||
|
|
6aea937e86 | ||
|
|
19612d4b66 | ||
|
|
47dd003461 | ||
|
|
def99225fc | ||
|
|
32405fc61a | ||
|
|
25e1a9af57 | ||
|
|
1fcb1f2c5e | ||
|
|
fdaba7e752 | ||
|
|
c1640cba29 | ||
|
|
3bd54ff61e | ||
|
|
5853d18bc1 | ||
|
|
f575317906 | ||
|
|
e6028e73ac | ||
|
|
bcbed151e8 | ||
|
|
c708f7ba62 | ||
|
|
95a538f261 | ||
|
|
f854457d69 | ||
|
|
cd998c37f1 | ||
|
|
d46a61098b | ||
|
|
8f14d854a0 | ||
|
|
388399b370 | ||
|
|
a8b4bb9c41 | ||
|
|
ebc8c2f73d | ||
|
|
1227d2b5fc | ||
|
|
314438b84c | ||
|
|
cc5574e08a | ||
|
|
11a8fcc476 | ||
|
|
c50229a33c | ||
|
|
0609d74d2b | ||
|
|
fce8eca894 | ||
|
|
3de0d674ed | ||
|
|
7faab54a65 | ||
|
|
40d9db7ccf | ||
|
|
c7c01f57d4 | ||
|
|
45cf295be0 | ||
|
|
79372527e6 | ||
|
|
edcfc7d670 | ||
|
|
6277ebaaec | ||
|
|
2b081166f9 | ||
|
|
d8f12f1780 | ||
|
|
95d949f112 | ||
|
|
1ec0c8e8b3 | ||
|
|
b9ac25ef42 | ||
|
|
9fe2460b88 | ||
|
|
44af0ec975 | ||
|
|
b84b4549a0 | ||
|
|
c110fae965 | ||
|
|
86b56e2597 | ||
|
|
7e365e1947 | ||
|
|
d458536803 | ||
|
|
9cb5b9a7d0 | ||
|
|
7e9fccfcb0 | ||
|
|
1c73dab719 | ||
|
|
3ec339fc89 | ||
|
|
c13a68dab4 | ||
|
|
eb5ba2f219 | ||
|
|
1eda6513df | ||
|
|
4bb60b9f7e | ||
|
|
9948dd7f19 | ||
|
|
e3ec8f1589 | ||
|
|
9aa56870b0 | ||
|
|
06c9e43143 | ||
|
|
e09447d4b4 | ||
|
|
2fa0c7dfd2 | ||
|
|
27521decbd | ||
|
|
4bec311ad0 | ||
|
|
432f616896 | ||
|
|
4575f16be4 | ||
|
|
d6f5f6e6cb | ||
|
|
96b1df2199 | ||
|
|
614b9d25a8 | ||
|
|
66dd890448 | ||
|
|
557c89ac6d | ||
|
|
b3e2af3b40 | ||
|
|
a8159e5f99 | ||
|
|
89306a7619 | ||
|
|
0690f07262 | ||
|
|
e437903ef8 | ||
|
|
50aeeb2fb8 | ||
|
|
d85fc2e513 | ||
|
|
cfba9a7d79 | ||
|
|
4a8cadc6ee | ||
|
|
b2d938d2fc | ||
|
|
9277427153 | ||
|
|
ccb141339b | ||
|
|
c87af312ca | ||
|
|
a5fb5532fd | ||
|
|
43ab1aa7b8 | ||
|
|
3072795232 | ||
|
|
98d0f1d5bf | ||
|
|
49e55961db | ||
|
|
4ee220c1d8 | ||
|
|
c69992c4f0 | ||
|
|
1f6ba45c12 | ||
|
|
ef02ba22b5 | ||
|
|
d7daa6d8e0 | ||
|
|
fafa14c10a | ||
|
|
2c90103823 | ||
|
|
f2bb01c800 | ||
|
|
442f051457 | ||
|
|
e84ce38994 | ||
|
|
0ea264ea42 | ||
|
|
d4064805eb | ||
|
|
d5c77fded3 | ||
|
|
d853b1d326 | ||
|
|
d57e347fdc | ||
|
|
25f3980492 | ||
|
|
c8e2f4bfdc | ||
|
|
4afc6ac250 | ||
|
|
52a660add3 | ||
|
|
3ad5982f39 | ||
|
|
8ba4ac22cc | ||
|
|
bcebcfdfdf | ||
|
|
77b1ec4733 | ||
|
|
cfae5f7e6c | ||
|
|
0f67e9e222 | ||
|
|
c4045795ee | ||
|
|
24f3be3c00 | ||
|
|
5055994bd3 | ||
|
|
ddcb22dff9 | ||
|
|
77d7dc1f22 | ||
|
|
19bf4f27b6 | ||
|
|
6b9765a26c | ||
|
|
8d91053c3a | ||
|
|
7c2eb63625 | ||
|
|
2ea2605ab1 | ||
|
|
7ae3ff22ee | ||
|
|
fa6baa0c1a | ||
|
|
5b43df92c1 | ||
|
|
f3032bc94f | ||
|
|
eef874ecd4 | ||
|
|
d6daa5677a | ||
|
|
dc03ba73b3 | ||
|
|
5c2159f7b2 | ||
|
|
ffcdbcf046 | ||
|
|
bb5c6bebff | ||
|
|
144d48057c | ||
|
|
55dc08b6ba | ||
|
|
56f525803b | ||
|
|
91bcd1238f | ||
|
|
120646c77b | ||
|
|
c0b35efaca | ||
|
|
22dee88e51 | ||
|
|
1645f7e932 | ||
|
|
b4aeb6577e | ||
|
|
fdd330ca19 | ||
|
|
33de620893 | ||
|
|
6518407c0c | ||
|
|
6f47999a2e | ||
|
|
fe69d5d405 | ||
|
|
a6880fd38c | ||
|
|
5d25de13dd | ||
|
|
5611dcccfd | ||
|
|
e2a1882fe3 | ||
|
|
ceb16ae9f7 | ||
|
|
1911b5b674 | ||
|
|
6b818bbb51 | ||
|
|
79796185d6 | ||
|
|
461d7c530a | ||
|
|
ade4b8dd1b | ||
|
|
f49a67f8df | ||
|
|
c3986d7a08 | ||
|
|
0bf4e5560c | ||
|
|
79d55d8d34 | ||
|
|
d4c6e5b048 | ||
|
|
cd4eed3507 | ||
|
|
a650bd16fb | ||
|
|
4e5b5f219e | ||
|
|
dfda934726 | ||
|
|
e6d0b7b4ee | ||
|
|
d0dbc1837f | ||
|
|
2b5af1897f | ||
|
|
11b9cee73d | ||
|
|
bc17991580 | ||
|
|
459c94929a | ||
|
|
8d28a50a17 | ||
|
|
08bbeceeba | ||
|
|
b7bf09bf21 | ||
|
|
546c6ade82 | ||
|
|
db2e3691a5 | ||
|
|
a6dca144a8 | ||
|
|
43a17e7e75 | ||
|
|
da60c4f3a8 | ||
|
|
e14f2780af | ||
|
|
33ab87f3db | ||
|
|
571d73a5b6 | ||
|
|
a630909612 | ||
|
|
8eaa006f0f | ||
|
|
8e8bc3e71e | ||
|
|
f4cd617107 | ||
|
|
48cfe66a6b | ||
|
|
bdc10cacef | ||
|
|
8fbad8a26e | ||
|
|
0f36bcb04e | ||
|
|
f4054453b4 | ||
|
|
dbd36fc024 | ||
|
|
850d06a32c | ||
|
|
dfd3dc180d | ||
|
|
3d42bfc81b | ||
|
|
764f8ec993 | ||
|
|
d2eaa4b40b | ||
|
|
7d7f2b4b1f | ||
|
|
8c36e48fe7 | ||
|
|
8e97c63faa | ||
|
|
74ec8f4594 | ||
|
|
76c0bff13a | ||
|
|
9b5cd0f5fe | ||
|
|
efee798880 | ||
|
|
1c470b8ba7 | ||
|
|
692864ced1 | ||
|
|
9ca61476d2 | ||
|
|
773a610be1 | ||
|
|
37f9e073f0 | ||
|
|
d335a9515d | ||
|
|
7a5a3de43d | ||
|
|
ef7918a33a | ||
|
|
ee6ad07c0a | ||
|
|
48fe26b204 | ||
|
|
3ede89fe8a | ||
|
|
fa698d173e | ||
|
|
1279fac137 | ||
|
|
0e1f0b42ee | ||
|
|
05f43ad06b | ||
|
|
af4511040f | ||
|
|
8f0697b0e9 | ||
|
|
4b15177260 | ||
|
|
61a20f13e2 | ||
|
|
f5cffca37c | ||
|
|
148b1ff2db | ||
|
|
1beceb7ee7 | ||
|
|
bea0316bbd | ||
|
|
b2a8572d10 | ||
|
|
2352939e87 | ||
|
|
e5ee06b67e | ||
|
|
48ec0a74ad | ||
|
|
bca6af77fd | ||
|
|
b3bd9ba1ce | ||
|
|
5a9c763c4f | ||
|
|
4b51744d0d | ||
|
|
e5a3e56e13 | ||
|
|
42fa4008ab | ||
|
|
1605aedd6e | ||
|
|
14bc26e065 | ||
|
|
6c8eb3b711 | ||
|
|
cb20950dd9 | ||
|
|
350bed217c | ||
|
|
7ac7481343 | ||
|
|
d9c34c4524 | ||
|
|
e83efa3379 | ||
|
|
5863e45c13 | ||
|
|
2c09b63bf9 | ||
|
|
eff2657e70 | ||
|
|
36172491a4 | ||
|
|
d43b098a7a | ||
|
|
8479f20205 | ||
|
|
6cb4159d54 | ||
|
|
1bbbdfba60 | ||
|
|
031d0ce315 | ||
|
|
131a1acbbe | ||
|
|
9a839de022 | ||
|
|
b9de05015f | ||
|
|
e176def5b6 | ||
|
|
94c947e288 | ||
|
|
fcb8a2bded | ||
|
|
116e33ce37 | ||
|
|
0bdaa81263 | ||
|
|
baf36b6fb6 | ||
|
|
d632e83799 | ||
|
|
6f52edd845 | ||
|
|
e9b92d2641 | ||
|
|
9d0f5bc8cd | ||
|
|
3dc558c260 | ||
|
|
180aa34140 | ||
|
|
96e9799afb | ||
|
|
3e07be38df | ||
|
|
ffc85b04a8 | ||
|
|
dbcfc702d4 | ||
|
|
67e85cabcb | ||
|
|
7805efc738 | ||
|
|
3910e22412 | ||
|
|
2f16034cb0 | ||
|
|
d4925dd2b7 | ||
|
|
5aba6c79a0 | ||
|
|
84f5627471 | ||
|
|
4eaf8fee0f | ||
|
|
adee87b6da | ||
|
|
e5e987fcf9 | ||
|
|
d0a6373dcc | ||
|
|
8ed44066ad | ||
|
|
befe2193a7 | ||
|
|
f20c73cdee | ||
|
|
64a77decfd | ||
|
|
16bfc09202 | ||
|
|
d54a61b2a4 | ||
|
|
60c09a6434 | ||
|
|
5361e9074f | ||
|
|
13d4dea504 | ||
|
|
ffc2d593e4 | ||
|
|
297439a348 | ||
|
|
ff3e067866 | ||
|
|
f008a45bf2 | ||
|
|
50c8503cf9 | ||
|
|
930a03de60 | ||
|
|
2d3d86e823 | ||
|
|
7bab166e1b | ||
|
|
7a6e1dbc1b | ||
|
|
17a859d26d | ||
|
|
d793c6a2ec | ||
|
|
3adb9d54f4 | ||
|
|
7144adbf0c | ||
|
|
55328468d1 | ||
|
|
6968cb6930 | ||
|
|
a431e4c58e | ||
|
|
fe967239b4 | ||
|
|
c5b4b85470 | ||
|
|
b1ef9d25b1 | ||
|
|
74f7c51530 | ||
|
|
4ba2b9fe8d | ||
|
|
413eda50f4 | ||
|
|
9f09681708 | ||
|
|
8eb174812d | ||
|
|
be77f114eb | ||
|
|
ca42708035 | ||
|
|
8b03454a87 | ||
|
|
fa7f749f84 | ||
|
|
3daecd7d71 | ||
|
|
0666b5b292 | ||
|
|
b288ddd826 | ||
|
|
beadcf871a | ||
|
|
ee49dadf0b | ||
|
|
46de83a1de | ||
|
|
fee5024b7d | ||
|
|
1f28a21835 | ||
|
|
0114b371f5 | ||
|
|
66d6cb5710 | ||
|
|
5927c7c3c5 | ||
|
|
84afcf0de5 | ||
|
|
e3527f7d69 | ||
|
|
cc5a3e6873 | ||
|
|
39f4a35cc8 | ||
|
|
e0433e9f7b | ||
|
|
d29ff881fc | ||
|
|
568c3a1d06 | ||
|
|
e9fd280fa2 | ||
|
|
5d5913f39d | ||
|
|
f04c8a36af | ||
|
|
d5137d5d3a | ||
|
|
9535fca28f | ||
|
|
dd62d603e0 | ||
|
|
8d227e2a2c | ||
|
|
048c8ffc11 | ||
|
|
b59597630c | ||
|
|
707463f973 | ||
|
|
4b3e0805a4 | ||
|
|
148c30f604 | ||
|
|
95f79f2afb | ||
|
|
a067abd3e4 | ||
|
|
9359ee7a04 | ||
|
|
fc7eff94b6 | ||
|
|
ff3d444b89 | ||
|
|
530ad31aaa | ||
|
|
68d0a48843 | ||
|
|
a4e4d1c467 | ||
|
|
56d8defebe | ||
|
|
997e755b6f | ||
|
|
852011dde8 | ||
|
|
d7ef201adb | ||
|
|
b1d1763988 | ||
|
|
91183056f0 | ||
|
|
03bd4398d0 | ||
|
|
8c260eff72 | ||
|
|
4eef65f1b7 | ||
|
|
a7535c6862 | ||
|
|
b5d199057d | ||
|
|
6e28196b0e | ||
|
|
18bacae175 | ||
|
|
f2be5a378e | ||
|
|
aef24296b9 | ||
|
|
7123b9b109 | ||
|
|
891dc840f5 | ||
|
|
bc78100613 | ||
|
|
ff22404b3b | ||
|
|
bfb6baf572 | ||
|
|
17330ca71a | ||
|
|
2898a5e575 | ||
|
|
fac8ea7a30 | ||
|
|
9a11d0db97 | ||
|
|
3cdf4c426c | ||
|
|
7cb184dc97 | ||
|
|
fe57333f84 | ||
|
|
04fd77c3a9 | ||
|
|
371c6317aa | ||
|
|
1f81794904 | ||
|
|
d5d3831d54 | ||
|
|
cf28640188 | ||
|
|
856399550a | ||
|
|
ea39b152f4 | ||
|
|
027406547e | ||
|
|
2974a8183e | ||
|
|
86b8b0987b | ||
|
|
ac0922d742 | ||
|
|
0dac1fefe6 | ||
|
|
633ba899e0 |
BIN
.github/sponsors/agentdock.png
vendored
Normal file
BIN
.github/sponsors/agentdock.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
.github/sponsors/american-cloud.png
vendored
Normal file
BIN
.github/sponsors/american-cloud.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
2
.github/workflows/dokploy.yml
vendored
2
.github/workflows/dokploy.yml
vendored
@@ -2,7 +2,7 @@ name: Dokploy Docker Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, canary, "feat/better-auth-2"]
|
branches: [main, canary, "1061-custom-docker-service-hostname"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE_NAME: dokploy/dokploy
|
IMAGE_NAME: dokploy/dokploy
|
||||||
|
|||||||
22
.github/workflows/format.yml
vendored
Normal file
22
.github/workflows/format.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
name: autofix.ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [canary]
|
||||||
|
pull_request:
|
||||||
|
branches: [canary]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
format:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup biomeJs
|
||||||
|
uses: biomejs/setup-biome@v2
|
||||||
|
|
||||||
|
- name: Run Biome formatter
|
||||||
|
run: biome format . --write
|
||||||
|
|
||||||
|
- uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef
|
||||||
6
.github/workflows/pull-request.yml
vendored
6
.github/workflows/pull-request.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.9.0
|
node-version: 20.16.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
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.9.0
|
node-version: 20.16.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
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.9.0
|
node-version: 20.16.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
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ feat: add new feature
|
|||||||
|
|
||||||
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
|
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
|
||||||
|
|
||||||
We use Node v20.9.0
|
We use Node v20.16.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.16.0 && nvm use` in the root directory.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/dokploy/dokploy.git
|
git clone https://github.com/dokploy/dokploy.git
|
||||||
@@ -61,9 +61,9 @@ pnpm install
|
|||||||
cp apps/dokploy/.env.example apps/dokploy/.env
|
cp apps/dokploy/.env.example apps/dokploy/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development
|
## Requirements
|
||||||
|
|
||||||
Is required to have **Docker** installed on your machine.
|
- [Docker](/GUIDES.md#docker)
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
@@ -87,6 +87,8 @@ pnpm run dokploy:dev
|
|||||||
|
|
||||||
Go to http://localhost:3000 to see the development server
|
Go to http://localhost:3000 to see the development server
|
||||||
|
|
||||||
|
Note: this project uses Biome. If your editor is configured to use another formatter such as Prettier, it's recommended to either change it to use Biome or turn it off.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -145,11 +147,9 @@ curl -sSL https://railpack.com/install.sh | sh
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install Buildpacks
|
# Install Buildpacks
|
||||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.32.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Pull Request
|
## Pull Request
|
||||||
|
|
||||||
- The `main` branch is the source of truth and should always reflect the latest stable release.
|
- The `main` branch is the source of truth and should always reflect the latest stable release.
|
||||||
@@ -167,7 +167,6 @@ Thank you for your contribution!
|
|||||||
|
|
||||||
To add a new template, go to `https://github.com/Dokploy/templates` repository and read the README.md file.
|
To add a new template, go to `https://github.com/Dokploy/templates` repository and read the README.md file.
|
||||||
|
|
||||||
|
|
||||||
### Recommendations
|
### Recommendations
|
||||||
|
|
||||||
- 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.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:20.9-slim AS base
|
FROM node:20.9-slim AS base
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
@@ -29,7 +30,7 @@ WORKDIR /app
|
|||||||
# Set production
|
# Set production
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y curl unzip apache2-utils iproute2 && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy only the necessary files
|
# Copy only the necessary files
|
||||||
COPY --from=build /prod/dokploy/.next ./.next
|
COPY --from=build /prod/dokploy/.next ./.next
|
||||||
@@ -49,18 +50,18 @@ 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
|
ARG NIXPACKS_VERSION=1.39.0
|
||||||
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 \
|
||||||
&& pnpm install -g tsx
|
&& pnpm install -g tsx
|
||||||
|
|
||||||
# Install Railpack
|
# Install Railpack
|
||||||
ARG RAILPACK_VERSION=0.0.37
|
ARG RAILPACK_VERSION=0.0.64
|
||||||
RUN curl -sSL https://railpack.com/install.sh | bash
|
RUN curl -sSL https://railpack.com/install.sh | bash
|
||||||
|
|
||||||
# Install buildpacks
|
# Install buildpacks
|
||||||
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
|
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD [ "pnpm", "start" ]
|
CMD [ "pnpm", "start" ]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:20.9-slim AS base
|
FROM node:20.9-slim AS base
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
# Build stage
|
# Build stage
|
||||||
FROM golang:1.21-alpine3.19 AS builder
|
FROM golang:1.21-alpine3.19 AS builder
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:20.9-slim AS base
|
FROM node:20.9-slim AS base
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
FROM node:20.9-slim AS base
|
FROM node:20.9-slim AS base
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
|||||||
49
GUIDES.md
Normal file
49
GUIDES.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Docker
|
||||||
|
|
||||||
|
Here's how to install docker on different operating systems:
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
|
||||||
|
1. Visit [Docker Desktop for Mac](https://www.docker.com/products/docker-desktop)
|
||||||
|
2. Download the Docker Desktop installer
|
||||||
|
3. Double-click the downloaded `.dmg` file
|
||||||
|
4. Drag Docker to your Applications folder
|
||||||
|
5. Open Docker Desktop from Applications
|
||||||
|
6. Follow the onboarding tutorial if desired
|
||||||
|
|
||||||
|
## Linux
|
||||||
|
|
||||||
|
### Ubuntu
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update package index
|
||||||
|
sudo apt-get update
|
||||||
|
|
||||||
|
# Install prerequisites
|
||||||
|
sudo apt-get install \
|
||||||
|
apt-transport-https \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
gnupg \
|
||||||
|
lsb-release
|
||||||
|
|
||||||
|
# Add Docker's official GPG key
|
||||||
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
|
||||||
|
|
||||||
|
# Set up stable repository
|
||||||
|
echo \
|
||||||
|
"deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
|
||||||
|
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||||
|
|
||||||
|
# Install Docker Engine
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install docker-ce docker-ce-cli containerd.io
|
||||||
|
```
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
1. Enable WSL2 if not already enabled
|
||||||
|
2. Visit [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop)
|
||||||
|
3. Download the installer
|
||||||
|
4. Run the installer and follow the prompts
|
||||||
|
5. Start Docker Desktop from the Start menu
|
||||||
@@ -19,8 +19,8 @@ See the License for the specific language governing permissions and limitations
|
|||||||
|
|
||||||
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:
|
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, Preview Deployments 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, Schedules, 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, 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.
|
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Schedules, 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, Preview Deployments 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, Schedules, 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.
|
||||||
|
|||||||
36
README.md
36
README.md
@@ -80,12 +80,30 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
|||||||
|
|
||||||
### Premium Supporters 🥇
|
### Premium Supporters 🥇
|
||||||
|
|
||||||
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
|
<div style="display: flex; align-items: center; gap: 20px;">
|
||||||
<a href="https://supafort.com/?ref=dokploy" target="_blank"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="190"/></a>
|
<a href="https://supafort.com/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 20px;">
|
||||||
|
<img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" height="50"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://agentdock.ai/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 50px;">
|
||||||
|
<img src=".github/sponsors/agentdock.png" alt="agentdock.ai" height="70"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
### Elite Contributors 🥈
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 20px;">
|
||||||
|
|
||||||
|
<a href="https://americancloud.com/?ref=dokploy" target="_blank" style="display: inline-block; padding: 10px; border-radius: 10px;">
|
||||||
|
<img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" height="70"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
<!-- Elite Contributors 🥈 -->
|
<!-- Elite Contributors 🥈 -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Add Elite Contributors here -->
|
<!-- Add Elite Contributors here -->
|
||||||
|
|
||||||
### Supporting Members 🥉
|
### Supporting Members 🥉
|
||||||
@@ -97,6 +115,7 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
|||||||
<a href="https://itsdb-center.com?ref=dokploy "><img src=".github/sponsors/its.png" width="65px" alt="Itsdb-center"/></a>
|
<a href="https://itsdb-center.com?ref=dokploy "><img src=".github/sponsors/its.png" width="65px" alt="Itsdb-center"/></a>
|
||||||
<a href="https://openalternative.co/?ref=dokploy "><img src=".github/sponsors/openalternative.png" width="65px" alt="Openalternative"/></a>
|
<a href="https://openalternative.co/?ref=dokploy "><img src=".github/sponsors/openalternative.png" width="65px" alt="Openalternative"/></a>
|
||||||
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
|
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -129,19 +148,6 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
|||||||
<img src="https://dokploy.com/banner.png" 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
|
|
||||||
|
|
||||||
- Ubuntu 24.04 LTS
|
|
||||||
- Ubuntu 23.10
|
|
||||||
- Ubuntu 22.04 LTS
|
|
||||||
- Ubuntu 20.04 LTS
|
|
||||||
- Ubuntu 18.04 LTS
|
|
||||||
- Debian 12
|
|
||||||
- Debian 11
|
|
||||||
- Fedora 40
|
|
||||||
- Centos 9
|
|
||||||
- Centos 8 -->
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Check out the [Contributing Guide](CONTRIBUTING.md) for more information.
|
Check out the [Contributing Guide](CONTRIBUTING.md) for more information.
|
||||||
|
|||||||
28
SECURITY.md
Normal file
28
SECURITY.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Dokploy Security Policy
|
||||||
|
|
||||||
|
At Dokploy, security is a top priority. We appreciate the help of security researchers and the community in identifying and reporting vulnerabilities.
|
||||||
|
|
||||||
|
## How to Report a Vulnerability
|
||||||
|
|
||||||
|
If you have discovered a security vulnerability in Dokploy, we ask that you report it responsibly by following these guidelines:
|
||||||
|
|
||||||
|
1. **Contact us:** Send an email to [contact@dokploy.com](mailto:contact@dokploy.com).
|
||||||
|
2. **Provide clear details:** Include as much information as possible to help us understand and reproduce the vulnerability. This should include:
|
||||||
|
* A clear description of the vulnerability.
|
||||||
|
* Steps to reproduce the vulnerability.
|
||||||
|
* Any sample code, screenshots, or videos that might be helpful.
|
||||||
|
* The potential impact of the vulnerability.
|
||||||
|
3. **Do not make the vulnerability public:** Please refrain from publicly disclosing the vulnerability until we have had the opportunity to investigate and address it. This is crucial for protecting our users.
|
||||||
|
4. **Allow us time:** We will endeavor to acknowledge receipt of your report as soon as possible and keep you informed of our progress. The time to resolve the vulnerability may vary depending on its complexity and severity.
|
||||||
|
|
||||||
|
## What We Expect From You
|
||||||
|
|
||||||
|
* Do not access user data or systems beyond what is necessary to demonstrate the vulnerability.
|
||||||
|
* Do not perform denial-of-service (DoS) attacks, spamming, or social engineering.
|
||||||
|
* Do not modify or destroy data that does not belong to you.
|
||||||
|
|
||||||
|
## Our Commitment
|
||||||
|
|
||||||
|
We are committed to working with you quickly and responsibly to address any legitimate security vulnerability.
|
||||||
|
|
||||||
|
Thank you for helping us keep Dokploy secure for everyone.
|
||||||
@@ -9,25 +9,25 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pino": "9.4.0",
|
|
||||||
"pino-pretty": "11.2.2",
|
|
||||||
"@hono/zod-validator": "0.3.0",
|
|
||||||
"zod": "^3.23.4",
|
|
||||||
"react": "18.2.0",
|
|
||||||
"react-dom": "18.2.0",
|
|
||||||
"@dokploy/server": "workspace:*",
|
"@dokploy/server": "workspace:*",
|
||||||
"@hono/node-server": "^1.12.1",
|
"@hono/node-server": "^1.12.1",
|
||||||
"hono": "^4.5.8",
|
"@hono/zod-validator": "0.3.0",
|
||||||
|
"@nerimity/mimiqueue": "1.2.3",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
|
"hono": "^4.5.8",
|
||||||
|
"pino": "9.4.0",
|
||||||
|
"pino-pretty": "11.2.2",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
"redis": "4.7.0",
|
"redis": "4.7.0",
|
||||||
"@nerimity/mimiqueue": "1.2.3"
|
"zod": "^3.23.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.4.2",
|
"@types/node": "^20.11.17",
|
||||||
"@types/react": "^18.2.37",
|
"@types/react": "^18.2.37",
|
||||||
"@types/react-dom": "^18.2.15",
|
"@types/react-dom": "^18.2.15",
|
||||||
"@types/node": "^20.11.17",
|
"tsx": "^4.7.1",
|
||||||
"tsx": "^4.7.1"
|
"typescript": "^5.4.2"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.5.0"
|
"packageManager": "pnpm@9.5.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
20.9.0
|
20.16.0
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
FROM node:18-slim AS base
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
FROM base AS build
|
|
||||||
COPY . /usr/src/app
|
|
||||||
WORKDIR /usr/src/app
|
|
||||||
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ git && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# Build only the dokploy app
|
|
||||||
RUN pnpm run dokploy:build
|
|
||||||
|
|
||||||
# Deploy only the dokploy app
|
|
||||||
RUN pnpm deploy --filter=dokploy --prod /prod/dokploy
|
|
||||||
|
|
||||||
FROM base AS dokploy
|
|
||||||
COPY --from=build /prod/dokploy /prod/dokploy
|
|
||||||
WORKDIR /prod/dokploy
|
|
||||||
EXPOSE 3000
|
|
||||||
CMD [ "pnpm", "start" ]
|
|
||||||
@@ -1006,7 +1006,7 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-config-testhash:
|
db-config-testhash:
|
||||||
`) as ComposeSpecification;
|
`);
|
||||||
|
|
||||||
test("Expect to change the suffix in all the possible places (4 Try)", () => {
|
test("Expect to change the suffix in all the possible places (4 Try)", () => {
|
||||||
const composeData = load(composeFileComplex) as ComposeSpecification;
|
const composeData = load(composeFileComplex) as ComposeSpecification;
|
||||||
@@ -1115,3 +1115,60 @@ test("Expect to change the suffix in all the possible places (5 Try)", () => {
|
|||||||
|
|
||||||
expect(updatedComposeData).toEqual(expectedDockerComposeExample1);
|
expect(updatedComposeData).toEqual(expectedDockerComposeExample1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const composeFileBackrest = `
|
||||||
|
services:
|
||||||
|
backrest:
|
||||||
|
image: garethgeorge/backrest:v1.7.3
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 9898
|
||||||
|
environment:
|
||||||
|
- BACKREST_PORT=9898
|
||||||
|
- BACKREST_DATA=/data
|
||||||
|
- BACKREST_CONFIG=/config/config.json
|
||||||
|
- XDG_CACHE_HOME=/cache
|
||||||
|
- TZ=\${TZ}
|
||||||
|
volumes:
|
||||||
|
- backrest/data:/data
|
||||||
|
- backrest/config:/config
|
||||||
|
- backrest/cache:/cache
|
||||||
|
- /:/userdata:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backrest:
|
||||||
|
backrest-cache:
|
||||||
|
`;
|
||||||
|
|
||||||
|
const expectedDockerComposeBackrest = load(`
|
||||||
|
services:
|
||||||
|
backrest:
|
||||||
|
image: garethgeorge/backrest:v1.7.3
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- 9898
|
||||||
|
environment:
|
||||||
|
- BACKREST_PORT=9898
|
||||||
|
- BACKREST_DATA=/data
|
||||||
|
- BACKREST_CONFIG=/config/config.json
|
||||||
|
- XDG_CACHE_HOME=/cache
|
||||||
|
- TZ=\${TZ}
|
||||||
|
volumes:
|
||||||
|
- backrest-testhash/data:/data
|
||||||
|
- backrest-testhash/config:/config
|
||||||
|
- backrest-testhash/cache:/cache
|
||||||
|
- /:/userdata:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
backrest-testhash:
|
||||||
|
backrest-cache-testhash:
|
||||||
|
`) as ComposeSpecification;
|
||||||
|
|
||||||
|
test("Should handle volume paths with subdirectories correctly", () => {
|
||||||
|
const composeData = load(composeFileBackrest) as ComposeSpecification;
|
||||||
|
const suffix = "testhash";
|
||||||
|
|
||||||
|
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||||
|
|
||||||
|
expect(updatedComposeData).toEqual(expectedDockerComposeBackrest);
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,9 +27,16 @@ if (typeof window === "undefined") {
|
|||||||
const baseApp: ApplicationNested = {
|
const baseApp: ApplicationNested = {
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
herokuVersion: "",
|
herokuVersion: "",
|
||||||
|
giteaBranch: "",
|
||||||
|
giteaBuildPath: "",
|
||||||
|
giteaId: "",
|
||||||
|
giteaOwner: "",
|
||||||
|
giteaRepository: "",
|
||||||
cleanCache: false,
|
cleanCache: false,
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
enableSubmodules: false,
|
||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
|
triggerType: "push",
|
||||||
appName: "",
|
appName: "",
|
||||||
autoDeploy: true,
|
autoDeploy: true,
|
||||||
serverId: "",
|
serverId: "",
|
||||||
@@ -98,6 +105,7 @@ const baseApp: ApplicationNested = {
|
|||||||
ports: [],
|
ports: [],
|
||||||
projectId: "",
|
projectId: "",
|
||||||
publishDirectory: null,
|
publishDirectory: null,
|
||||||
|
isStaticSpa: null,
|
||||||
redirects: [],
|
redirects: [],
|
||||||
refreshToken: "",
|
refreshToken: "",
|
||||||
registry: null,
|
registry: null,
|
||||||
@@ -113,6 +121,7 @@ const baseApp: ApplicationNested = {
|
|||||||
updateConfigSwarm: null,
|
updateConfigSwarm: null,
|
||||||
username: null,
|
username: null,
|
||||||
dockerContextPath: null,
|
dockerContextPath: null,
|
||||||
|
rollbackActive: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("unzipDrop using real zip files", () => {
|
describe("unzipDrop using real zip files", () => {
|
||||||
@@ -142,67 +151,68 @@ describe("unzipDrop using real zip files", () => {
|
|||||||
} finally {
|
} finally {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should correctly extract a zip with a single root folder and a subfolder", async () => {
|
|
||||||
baseApp.appName = "folderwithfile";
|
|
||||||
// const appName = "folderwithfile";
|
|
||||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
|
||||||
const zip = new AdmZip("./__test__/drop/zips/folder-with-file.zip");
|
|
||||||
|
|
||||||
const zipBuffer = zip.toBuffer();
|
|
||||||
const file = new File([zipBuffer], "single.zip");
|
|
||||||
await unzipDrop(file, baseApp);
|
|
||||||
|
|
||||||
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
|
||||||
expect(files.some((f) => f.name === "folder1.txt")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should correctly extract a zip with multiple root folders", async () => {
|
|
||||||
baseApp.appName = "two-folders";
|
|
||||||
// const appName = "two-folders";
|
|
||||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
|
||||||
const zip = new AdmZip("./__test__/drop/zips/two-folders.zip");
|
|
||||||
|
|
||||||
const zipBuffer = zip.toBuffer();
|
|
||||||
const file = new File([zipBuffer], "single.zip");
|
|
||||||
await unzipDrop(file, baseApp);
|
|
||||||
|
|
||||||
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
|
||||||
|
|
||||||
expect(files.some((f) => f.name === "folder1")).toBe(true);
|
|
||||||
expect(files.some((f) => f.name === "folder2")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should correctly extract a zip with a single root with a file", async () => {
|
|
||||||
baseApp.appName = "nested";
|
|
||||||
// const appName = "nested";
|
|
||||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
|
||||||
const zip = new AdmZip("./__test__/drop/zips/nested.zip");
|
|
||||||
|
|
||||||
const zipBuffer = zip.toBuffer();
|
|
||||||
const file = new File([zipBuffer], "single.zip");
|
|
||||||
await unzipDrop(file, baseApp);
|
|
||||||
|
|
||||||
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
|
||||||
|
|
||||||
expect(files.some((f) => f.name === "folder1")).toBe(true);
|
|
||||||
expect(files.some((f) => f.name === "folder2")).toBe(true);
|
|
||||||
expect(files.some((f) => f.name === "folder3")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should correctly extract a zip with a single root with a folder", async () => {
|
|
||||||
baseApp.appName = "folder-with-sibling-file";
|
|
||||||
// const appName = "folder-with-sibling-file";
|
|
||||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
|
||||||
const zip = new AdmZip("./__test__/drop/zips/folder-with-sibling-file.zip");
|
|
||||||
|
|
||||||
const zipBuffer = zip.toBuffer();
|
|
||||||
const file = new File([zipBuffer], "single.zip");
|
|
||||||
await unzipDrop(file, baseApp);
|
|
||||||
|
|
||||||
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
|
||||||
|
|
||||||
expect(files.some((f) => f.name === "folder1")).toBe(true);
|
|
||||||
expect(files.some((f) => f.name === "test.txt")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// it("should correctly extract a zip with a single root folder and a subfolder", async () => {
|
||||||
|
// baseApp.appName = "folderwithfile";
|
||||||
|
// // const appName = "folderwithfile";
|
||||||
|
// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||||
|
// const zip = new AdmZip("./__test__/drop/zips/folder-with-file.zip");
|
||||||
|
|
||||||
|
// const zipBuffer = zip.toBuffer();
|
||||||
|
// const file = new File([zipBuffer], "single.zip");
|
||||||
|
// await unzipDrop(file, baseApp);
|
||||||
|
|
||||||
|
// const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||||
|
// expect(files.some((f) => f.name === "folder1.txt")).toBe(true);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// it("should correctly extract a zip with multiple root folders", async () => {
|
||||||
|
// baseApp.appName = "two-folders";
|
||||||
|
// // const appName = "two-folders";
|
||||||
|
// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||||
|
// const zip = new AdmZip("./__test__/drop/zips/two-folders.zip");
|
||||||
|
|
||||||
|
// const zipBuffer = zip.toBuffer();
|
||||||
|
// const file = new File([zipBuffer], "single.zip");
|
||||||
|
// await unzipDrop(file, baseApp);
|
||||||
|
|
||||||
|
// const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
// expect(files.some((f) => f.name === "folder1")).toBe(true);
|
||||||
|
// expect(files.some((f) => f.name === "folder2")).toBe(true);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// it("should correctly extract a zip with a single root with a file", async () => {
|
||||||
|
// baseApp.appName = "nested";
|
||||||
|
// // const appName = "nested";
|
||||||
|
// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||||
|
// const zip = new AdmZip("./__test__/drop/zips/nested.zip");
|
||||||
|
|
||||||
|
// const zipBuffer = zip.toBuffer();
|
||||||
|
// const file = new File([zipBuffer], "single.zip");
|
||||||
|
// await unzipDrop(file, baseApp);
|
||||||
|
|
||||||
|
// const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
// expect(files.some((f) => f.name === "folder1")).toBe(true);
|
||||||
|
// expect(files.some((f) => f.name === "folder2")).toBe(true);
|
||||||
|
// expect(files.some((f) => f.name === "folder3")).toBe(true);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// it("should correctly extract a zip with a single root with a folder", async () => {
|
||||||
|
// baseApp.appName = "folder-with-sibling-file";
|
||||||
|
// // const appName = "folder-with-sibling-file";
|
||||||
|
// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||||
|
// const zip = new AdmZip("./__test__/drop/zips/folder-with-sibling-file.zip");
|
||||||
|
|
||||||
|
// const zipBuffer = zip.toBuffer();
|
||||||
|
// const file = new File([zipBuffer], "single.zip");
|
||||||
|
// await unzipDrop(file, baseApp);
|
||||||
|
|
||||||
|
// const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
// expect(files.some((f) => f.name === "folder1")).toBe(true);
|
||||||
|
// expect(files.some((f) => f.name === "test.txt")).toBe(true);
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import type { Schema } from "@dokploy/server/templates";
|
||||||
import type { CompleteTemplate } from "@dokploy/server/templates/processors";
|
import type { CompleteTemplate } from "@dokploy/server/templates/processors";
|
||||||
import { processTemplate } from "@dokploy/server/templates/processors";
|
import { processTemplate } from "@dokploy/server/templates/processors";
|
||||||
import type { Schema } from "@dokploy/server/templates";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
describe("processTemplate", () => {
|
describe("processTemplate", () => {
|
||||||
// Mock schema for testing
|
// Mock schema for testing
|
||||||
@@ -51,6 +51,35 @@ describe("processTemplate", () => {
|
|||||||
expect(result.domains).toHaveLength(0);
|
expect(result.domains).toHaveLength(0);
|
||||||
expect(result.mounts).toHaveLength(0);
|
expect(result.mounts).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should allow creation of real jwt secret", () => {
|
||||||
|
const template: CompleteTemplate = {
|
||||||
|
metadata: {} as any,
|
||||||
|
variables: {
|
||||||
|
jwt_secret: "cQsdycq1hDLopQonF6jUTqgQc5WEZTwWLL02J6XJ",
|
||||||
|
anon_payload: JSON.stringify({
|
||||||
|
role: "tester",
|
||||||
|
iss: "dockploy",
|
||||||
|
iat: "${timestamps:2025-01-01T00:00:00Z}",
|
||||||
|
exp: "${timestamps:2030-01-01T00:00:00Z}",
|
||||||
|
}),
|
||||||
|
anon_key: "${jwt:jwt_secret:anon_payload}",
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
domains: [],
|
||||||
|
env: {
|
||||||
|
ANON_KEY: "${anon_key}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = processTemplate(template, mockSchema);
|
||||||
|
expect(result.envs).toHaveLength(1);
|
||||||
|
expect(result.envs).toContain(
|
||||||
|
"ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIxNzM1Njg5NjAwIiwiZXhwIjoiMTg5MzQ1NjAwMCIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJkb2NrcGxveSJ9.BG5JoxL2_NaTFbPgyZdm3kRWenf_O3su_HIRKGCJ_kY",
|
||||||
|
);
|
||||||
|
expect(result.mounts).toHaveLength(0);
|
||||||
|
expect(result.domains).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("domains processing", () => {
|
describe("domains processing", () => {
|
||||||
@@ -233,6 +262,49 @@ describe("processTemplate", () => {
|
|||||||
expect(base64Value.length).toBeGreaterThanOrEqual(42);
|
expect(base64Value.length).toBeGreaterThanOrEqual(42);
|
||||||
expect(base64Value.length).toBeLessThanOrEqual(44);
|
expect(base64Value.length).toBeLessThanOrEqual(44);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should handle boolean values in env vars when provided as an array", () => {
|
||||||
|
const template: CompleteTemplate = {
|
||||||
|
metadata: {} as any,
|
||||||
|
variables: {},
|
||||||
|
config: {
|
||||||
|
domains: [],
|
||||||
|
env: [
|
||||||
|
"ENABLE_USER_SIGN_UP=false",
|
||||||
|
"DEBUG_MODE=true",
|
||||||
|
"SOME_NUMBER=42",
|
||||||
|
],
|
||||||
|
mounts: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = processTemplate(template, mockSchema);
|
||||||
|
expect(result.envs).toHaveLength(3);
|
||||||
|
expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false");
|
||||||
|
expect(result.envs).toContain("DEBUG_MODE=true");
|
||||||
|
expect(result.envs).toContain("SOME_NUMBER=42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle boolean values in env vars when provided as an object", () => {
|
||||||
|
const template: CompleteTemplate = {
|
||||||
|
metadata: {} as any,
|
||||||
|
variables: {},
|
||||||
|
config: {
|
||||||
|
domains: [],
|
||||||
|
env: {
|
||||||
|
ENABLE_USER_SIGN_UP: false,
|
||||||
|
DEBUG_MODE: true,
|
||||||
|
SOME_NUMBER: 42,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = processTemplate(template, mockSchema);
|
||||||
|
expect(result.envs).toHaveLength(3);
|
||||||
|
expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false");
|
||||||
|
expect(result.envs).toContain("DEBUG_MODE=true");
|
||||||
|
expect(result.envs).toContain("SOME_NUMBER=42");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("mounts processing", () => {
|
describe("mounts processing", () => {
|
||||||
|
|||||||
232
apps/dokploy/__test__/templates/helpers.template.test.ts
Normal file
232
apps/dokploy/__test__/templates/helpers.template.test.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import type { Schema } from "@dokploy/server/templates";
|
||||||
|
import { processValue } from "@dokploy/server/templates/processors";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("helpers functions", () => {
|
||||||
|
// Mock schema for testing
|
||||||
|
const mockSchema: Schema = {
|
||||||
|
projectName: "test",
|
||||||
|
serverIp: "127.0.0.1",
|
||||||
|
};
|
||||||
|
// some helpers to test jwt
|
||||||
|
type JWTParts = [string, string, string];
|
||||||
|
const jwtMatchExp = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/;
|
||||||
|
const jwtBase64Decode = (str: string) => {
|
||||||
|
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
||||||
|
const decoded = Buffer.from(base64 + padding, "base64").toString("utf-8");
|
||||||
|
return JSON.parse(decoded);
|
||||||
|
};
|
||||||
|
const jwtCheckHeader = (jwtHeader: string) => {
|
||||||
|
const decodedHeader = jwtBase64Decode(jwtHeader);
|
||||||
|
expect(decodedHeader).toHaveProperty("alg");
|
||||||
|
expect(decodedHeader).toHaveProperty("typ");
|
||||||
|
expect(decodedHeader.alg).toEqual("HS256");
|
||||||
|
expect(decodedHeader.typ).toEqual("JWT");
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("${domain}", () => {
|
||||||
|
it("should generate a random domain", () => {
|
||||||
|
const domain = processValue("${domain}", {}, mockSchema);
|
||||||
|
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
domain.endsWith(
|
||||||
|
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${base64}", () => {
|
||||||
|
it("should generate a base64 string", () => {
|
||||||
|
const base64 = processValue("${base64}", {}, mockSchema);
|
||||||
|
expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/);
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
[4, 8],
|
||||||
|
[8, 12],
|
||||||
|
[16, 24],
|
||||||
|
[32, 44],
|
||||||
|
[64, 88],
|
||||||
|
[128, 172],
|
||||||
|
])(
|
||||||
|
"should generate a base64 string from parameter %d bytes length",
|
||||||
|
(length, finalLength) => {
|
||||||
|
const base64 = processValue(`\${base64:${length}}`, {}, mockSchema);
|
||||||
|
expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/);
|
||||||
|
expect(base64.length).toBe(finalLength);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${password}", () => {
|
||||||
|
it("should generate a password string", () => {
|
||||||
|
const password = processValue("${password}", {}, mockSchema);
|
||||||
|
expect(password).toMatch(/^[A-Za-z0-9]+$/);
|
||||||
|
});
|
||||||
|
it.each([6, 8, 12, 16, 32])(
|
||||||
|
"should generate a password string respecting parameter %d length",
|
||||||
|
(length) => {
|
||||||
|
const password = processValue(`\${password:${length}}`, {}, mockSchema);
|
||||||
|
expect(password).toMatch(/^[A-Za-z0-9]+$/);
|
||||||
|
expect(password.length).toBe(length);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${hash}", () => {
|
||||||
|
it("should generate a hash string", () => {
|
||||||
|
const hash = processValue("${hash}", {}, mockSchema);
|
||||||
|
expect(hash).toMatch(/^[A-Za-z0-9]+$/);
|
||||||
|
});
|
||||||
|
it.each([6, 8, 12, 16, 32])(
|
||||||
|
"should generate a hash string respecting parameter %d length",
|
||||||
|
(length) => {
|
||||||
|
const hash = processValue(`\${hash:${length}}`, {}, mockSchema);
|
||||||
|
expect(hash).toMatch(/^[A-Za-z0-9]+$/);
|
||||||
|
expect(hash.length).toBe(length);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${uuid}", () => {
|
||||||
|
it("should generate a UUID string", () => {
|
||||||
|
const uuid = processValue("${uuid}", {}, mockSchema);
|
||||||
|
expect(uuid).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${timestamp}", () => {
|
||||||
|
it("should generate a timestamp string in milliseconds", () => {
|
||||||
|
const timestamp = processValue("${timestamp}", {}, mockSchema);
|
||||||
|
const nowLength = Math.floor(Date.now()).toString().length;
|
||||||
|
expect(timestamp).toMatch(/^\d+$/);
|
||||||
|
expect(timestamp.length).toBe(nowLength);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("${timestampms}", () => {
|
||||||
|
it("should generate a timestamp string in milliseconds", () => {
|
||||||
|
const timestamp = processValue("${timestampms}", {}, mockSchema);
|
||||||
|
const nowLength = Date.now().toString().length;
|
||||||
|
expect(timestamp).toMatch(/^\d+$/);
|
||||||
|
expect(timestamp.length).toBe(nowLength);
|
||||||
|
});
|
||||||
|
it("should generate a timestamp string in milliseconds from parameter", () => {
|
||||||
|
const timestamp = processValue(
|
||||||
|
"${timestampms:2025-01-01}",
|
||||||
|
{},
|
||||||
|
mockSchema,
|
||||||
|
);
|
||||||
|
expect(timestamp).toEqual("1735689600000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("${timestamps}", () => {
|
||||||
|
it("should generate a timestamp string in seconds", () => {
|
||||||
|
const timestamps = processValue("${timestamps}", {}, mockSchema);
|
||||||
|
const nowLength = Math.floor(Date.now() / 1000).toString().length;
|
||||||
|
expect(timestamps).toMatch(/^\d+$/);
|
||||||
|
expect(timestamps.length).toBe(nowLength);
|
||||||
|
});
|
||||||
|
it("should generate a timestamp string in seconds from parameter", () => {
|
||||||
|
const timestamps = processValue(
|
||||||
|
"${timestamps:2025-01-01}",
|
||||||
|
{},
|
||||||
|
mockSchema,
|
||||||
|
);
|
||||||
|
expect(timestamps).toEqual("1735689600");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${randomPort}", () => {
|
||||||
|
it("should generate a random port string", () => {
|
||||||
|
const randomPort = processValue("${randomPort}", {}, mockSchema);
|
||||||
|
expect(randomPort).toMatch(/^\d+$/);
|
||||||
|
expect(Number(randomPort)).toBeLessThan(65536);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${username}", () => {
|
||||||
|
it("should generate a username string", () => {
|
||||||
|
const username = processValue("${username}", {}, mockSchema);
|
||||||
|
expect(username).toMatch(/^[a-zA-Z0-9._-]{3,}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${email}", () => {
|
||||||
|
it("should generate an email string", () => {
|
||||||
|
const email = processValue("${email}", {}, mockSchema);
|
||||||
|
expect(email).toMatch(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${jwt}", () => {
|
||||||
|
it("should generate a JWT string", () => {
|
||||||
|
const jwt = processValue("${jwt}", {}, mockSchema);
|
||||||
|
expect(jwt).toMatch(jwtMatchExp);
|
||||||
|
const parts = jwt.split(".") as JWTParts;
|
||||||
|
const decodedPayload = jwtBase64Decode(parts[1]);
|
||||||
|
jwtCheckHeader(parts[0]);
|
||||||
|
expect(decodedPayload).toHaveProperty("iat");
|
||||||
|
expect(decodedPayload).toHaveProperty("iss");
|
||||||
|
expect(decodedPayload).toHaveProperty("exp");
|
||||||
|
expect(decodedPayload.iss).toEqual("dokploy");
|
||||||
|
});
|
||||||
|
it.each([6, 8, 12, 16, 32])(
|
||||||
|
"should generate a random hex string from parameter %d byte length",
|
||||||
|
(length) => {
|
||||||
|
const jwt = processValue(`\${jwt:${length}}`, {}, mockSchema);
|
||||||
|
expect(jwt).toMatch(/^[A-Za-z0-9-_.]+$/);
|
||||||
|
expect(jwt.length).toBeGreaterThanOrEqual(length); // bytes translated to hex can take up to 2x the length
|
||||||
|
expect(jwt.length).toBeLessThanOrEqual(length * 2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
describe("${jwt:secret}", () => {
|
||||||
|
it("should generate a JWT string respecting parameter secret from variable", () => {
|
||||||
|
const jwt = processValue(
|
||||||
|
"${jwt:secret}",
|
||||||
|
{ secret: "mysecret" },
|
||||||
|
mockSchema,
|
||||||
|
);
|
||||||
|
expect(jwt).toMatch(jwtMatchExp);
|
||||||
|
const parts = jwt.split(".") as JWTParts;
|
||||||
|
const decodedPayload = jwtBase64Decode(parts[1]);
|
||||||
|
jwtCheckHeader(parts[0]);
|
||||||
|
expect(decodedPayload).toHaveProperty("iat");
|
||||||
|
expect(decodedPayload).toHaveProperty("iss");
|
||||||
|
expect(decodedPayload).toHaveProperty("exp");
|
||||||
|
expect(decodedPayload.iss).toEqual("dokploy");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("${jwt:secret:payload}", () => {
|
||||||
|
it("should generate a JWT string respecting parameters secret and payload from variables", () => {
|
||||||
|
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
|
||||||
|
const expiry = iat + 3600;
|
||||||
|
const jwt = processValue(
|
||||||
|
"${jwt:secret:payload}",
|
||||||
|
{
|
||||||
|
secret: "mysecret",
|
||||||
|
payload: `{"iss": "test-issuer", "iat": ${iat}, "exp": ${expiry}, "customprop": "customvalue"}`,
|
||||||
|
},
|
||||||
|
mockSchema,
|
||||||
|
);
|
||||||
|
expect(jwt).toMatch(jwtMatchExp);
|
||||||
|
const parts = jwt.split(".") as JWTParts;
|
||||||
|
jwtCheckHeader(parts[0]);
|
||||||
|
const decodedPayload = jwtBase64Decode(parts[1]);
|
||||||
|
expect(decodedPayload).toHaveProperty("iat");
|
||||||
|
expect(decodedPayload.iat).toEqual(iat);
|
||||||
|
expect(decodedPayload).toHaveProperty("iss");
|
||||||
|
expect(decodedPayload.iss).toEqual("test-issuer");
|
||||||
|
expect(decodedPayload).toHaveProperty("exp");
|
||||||
|
expect(decodedPayload.exp).toEqual(expiry);
|
||||||
|
expect(decodedPayload).toHaveProperty("customprop");
|
||||||
|
expect(decodedPayload.customprop).toEqual("customvalue");
|
||||||
|
expect(jwt).toEqual(
|
||||||
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,7 +14,10 @@ import {
|
|||||||
import { beforeEach, expect, test, vi } from "vitest";
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
const baseAdmin: User = {
|
const baseAdmin: User = {
|
||||||
|
https: false,
|
||||||
enablePaidFeatures: false,
|
enablePaidFeatures: false,
|
||||||
|
allowImpersonation: false,
|
||||||
|
role: "user",
|
||||||
metricsConfig: {
|
metricsConfig: {
|
||||||
containers: {
|
containers: {
|
||||||
refreshRate: 20,
|
refreshRate: 20,
|
||||||
@@ -73,7 +76,6 @@ beforeEach(() => {
|
|||||||
|
|
||||||
test("Should read the configuration file", () => {
|
test("Should read the configuration file", () => {
|
||||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe(
|
expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe(
|
||||||
"dokploy-service-app",
|
"dokploy-service-app",
|
||||||
);
|
);
|
||||||
@@ -83,6 +85,7 @@ test("Should apply redirect-to-https", () => {
|
|||||||
updateServerTraefik(
|
updateServerTraefik(
|
||||||
{
|
{
|
||||||
...baseAdmin,
|
...baseAdmin,
|
||||||
|
https: true,
|
||||||
certificateType: "letsencrypt",
|
certificateType: "letsencrypt",
|
||||||
},
|
},
|
||||||
"example.com",
|
"example.com",
|
||||||
|
|||||||
@@ -5,12 +5,19 @@ import { createRouterConfig } from "@dokploy/server";
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
const baseApp: ApplicationNested = {
|
const baseApp: ApplicationNested = {
|
||||||
|
rollbackActive: false,
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
herokuVersion: "",
|
herokuVersion: "",
|
||||||
|
giteaRepository: "",
|
||||||
|
giteaOwner: "",
|
||||||
|
giteaBranch: "",
|
||||||
|
giteaBuildPath: "",
|
||||||
|
giteaId: "",
|
||||||
cleanCache: false,
|
cleanCache: false,
|
||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
appName: "",
|
appName: "",
|
||||||
autoDeploy: true,
|
autoDeploy: true,
|
||||||
|
enableSubmodules: false,
|
||||||
serverId: "",
|
serverId: "",
|
||||||
branch: null,
|
branch: null,
|
||||||
dockerBuildStage: "",
|
dockerBuildStage: "",
|
||||||
@@ -19,6 +26,7 @@ const baseApp: ApplicationNested = {
|
|||||||
buildArgs: null,
|
buildArgs: null,
|
||||||
isPreviewDeploymentsActive: false,
|
isPreviewDeploymentsActive: false,
|
||||||
previewBuildArgs: null,
|
previewBuildArgs: null,
|
||||||
|
triggerType: "push",
|
||||||
previewCertificateType: "none",
|
previewCertificateType: "none",
|
||||||
previewEnv: null,
|
previewEnv: null,
|
||||||
previewHttps: false,
|
previewHttps: false,
|
||||||
@@ -78,6 +86,7 @@ const baseApp: ApplicationNested = {
|
|||||||
ports: [],
|
ports: [],
|
||||||
projectId: "",
|
projectId: "",
|
||||||
publishDirectory: null,
|
publishDirectory: null,
|
||||||
|
isStaticSpa: null,
|
||||||
redirects: [],
|
redirects: [],
|
||||||
refreshToken: "",
|
refreshToken: "",
|
||||||
registry: null,
|
registry: null,
|
||||||
|
|||||||
61
apps/dokploy/__test__/utils/backups.test.ts
Normal file
61
apps/dokploy/__test__/utils/backups.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
describe("normalizeS3Path", () => {
|
||||||
|
test("should handle empty and whitespace-only prefix", () => {
|
||||||
|
expect(normalizeS3Path("")).toBe("");
|
||||||
|
expect(normalizeS3Path("/")).toBe("");
|
||||||
|
expect(normalizeS3Path(" ")).toBe("");
|
||||||
|
expect(normalizeS3Path("\t")).toBe("");
|
||||||
|
expect(normalizeS3Path("\n")).toBe("");
|
||||||
|
expect(normalizeS3Path(" \n \t ")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should trim whitespace from prefix", () => {
|
||||||
|
expect(normalizeS3Path(" prefix")).toBe("prefix/");
|
||||||
|
expect(normalizeS3Path("prefix ")).toBe("prefix/");
|
||||||
|
expect(normalizeS3Path(" prefix ")).toBe("prefix/");
|
||||||
|
expect(normalizeS3Path("\tprefix\t")).toBe("prefix/");
|
||||||
|
expect(normalizeS3Path(" prefix/nested ")).toBe("prefix/nested/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should remove leading slashes", () => {
|
||||||
|
expect(normalizeS3Path("/prefix")).toBe("prefix/");
|
||||||
|
expect(normalizeS3Path("///prefix")).toBe("prefix/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should remove trailing slashes", () => {
|
||||||
|
expect(normalizeS3Path("prefix/")).toBe("prefix/");
|
||||||
|
expect(normalizeS3Path("prefix///")).toBe("prefix/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should remove both leading and trailing slashes", () => {
|
||||||
|
expect(normalizeS3Path("/prefix/")).toBe("prefix/");
|
||||||
|
expect(normalizeS3Path("///prefix///")).toBe("prefix/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle nested paths", () => {
|
||||||
|
expect(normalizeS3Path("prefix/nested")).toBe("prefix/nested/");
|
||||||
|
expect(normalizeS3Path("/prefix/nested/")).toBe("prefix/nested/");
|
||||||
|
expect(normalizeS3Path("///prefix/nested///")).toBe("prefix/nested/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should preserve middle slashes", () => {
|
||||||
|
expect(normalizeS3Path("prefix/nested/deep")).toBe("prefix/nested/deep/");
|
||||||
|
expect(normalizeS3Path("/prefix/nested/deep/")).toBe("prefix/nested/deep/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle special characters", () => {
|
||||||
|
expect(normalizeS3Path("prefix-with-dashes")).toBe("prefix-with-dashes/");
|
||||||
|
expect(normalizeS3Path("prefix_with_underscores")).toBe(
|
||||||
|
"prefix_with_underscores/",
|
||||||
|
);
|
||||||
|
expect(normalizeS3Path("prefix.with.dots")).toBe("prefix.with.dots/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle the cases from the bug report", () => {
|
||||||
|
expect(normalizeS3Path("instance-backups/")).toBe("instance-backups/");
|
||||||
|
expect(normalizeS3Path("/instance-backups/")).toBe("instance-backups/");
|
||||||
|
expect(normalizeS3Path("instance-backups")).toBe("instance-backups/");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,7 +40,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AddRedirectchema = z.object({
|
const AddRedirectchema = z.object({
|
||||||
replicas: z.number(),
|
replicas: z.number().min(1, "Replicas must be at least 1"),
|
||||||
registryId: z.string(),
|
registryId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,9 +130,11 @@ export const ShowClusterSettings = ({ applicationId }: Props) => {
|
|||||||
placeholder="1"
|
placeholder="1"
|
||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(Number(e.target.value));
|
const value = e.target.value;
|
||||||
|
field.onChange(value === "" ? 0 : Number(value));
|
||||||
}}
|
}}
|
||||||
type="number"
|
type="number"
|
||||||
|
value={field.value || ""}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +33,6 @@ 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 { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
|
|
||||||
const ImportSchema = z.object({
|
const ImportSchema = z.object({
|
||||||
base64: z.string(),
|
base64: z.string(),
|
||||||
@@ -263,7 +263,7 @@ export const ShowImport = ({ composeId }: Props) => {
|
|||||||
{templateInfo.template.envs.map((env, index) => (
|
{templateInfo.template.envs.map((env, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="rounded-lg border bg-card p-2 font-mono text-sm"
|
className="rounded-lg truncate border bg-card p-2 font-mono text-sm"
|
||||||
>
|
>
|
||||||
{env}
|
{env}
|
||||||
</div>
|
</div>
|
||||||
@@ -328,7 +328,7 @@ export const ShowImport = ({ composeId }: Props) => {
|
|||||||
<DialogDescription>Mount File Content</DialogDescription>
|
<DialogDescription>Mount File Content</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<ScrollArea className="h-[25vh] pr-4">
|
<ScrollArea className="h-[45vh] pr-4">
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
language="yaml"
|
language="yaml"
|
||||||
value={selectedMount?.content || ""}
|
value={selectedMount?.content || ""}
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export const UpdateVolume = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="content"
|
name="content"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem className="max-w-full max-w-[45rem]">
|
||||||
<FormLabel>Content</FormLabel>
|
<FormLabel>Content</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -256,7 +256,7 @@ export const UpdateVolume = ({
|
|||||||
placeholder={`NODE_ENV=production
|
placeholder={`NODE_ENV=production
|
||||||
PORT=3000
|
PORT=3000
|
||||||
`}
|
`}
|
||||||
className="h-96 font-mono"
|
className="h-96 font-mono w-full"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
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 { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -20,7 +22,7 @@ import { useForm } from "react-hook-form";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
enum BuildType {
|
export enum BuildType {
|
||||||
dockerfile = "dockerfile",
|
dockerfile = "dockerfile",
|
||||||
heroku_buildpacks = "heroku_buildpacks",
|
heroku_buildpacks = "heroku_buildpacks",
|
||||||
paketo_buildpacks = "paketo_buildpacks",
|
paketo_buildpacks = "paketo_buildpacks",
|
||||||
@@ -29,9 +31,18 @@ enum BuildType {
|
|||||||
railpack = "railpack",
|
railpack = "railpack",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildTypeDisplayMap: Record<BuildType, string> = {
|
||||||
|
[BuildType.dockerfile]: "Dockerfile",
|
||||||
|
[BuildType.railpack]: "Railpack",
|
||||||
|
[BuildType.nixpacks]: "Nixpacks",
|
||||||
|
[BuildType.heroku_buildpacks]: "Heroku Buildpacks",
|
||||||
|
[BuildType.paketo_buildpacks]: "Paketo Buildpacks",
|
||||||
|
[BuildType.static]: "Static",
|
||||||
|
};
|
||||||
|
|
||||||
const mySchema = z.discriminatedUnion("buildType", [
|
const mySchema = z.discriminatedUnion("buildType", [
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal("dockerfile"),
|
buildType: z.literal(BuildType.dockerfile),
|
||||||
dockerfile: z
|
dockerfile: z
|
||||||
.string({
|
.string({
|
||||||
required_error: "Dockerfile path is required",
|
required_error: "Dockerfile path is required",
|
||||||
@@ -42,39 +53,92 @@ const mySchema = z.discriminatedUnion("buildType", [
|
|||||||
dockerBuildStage: z.string().nullable().default(""),
|
dockerBuildStage: z.string().nullable().default(""),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal("heroku_buildpacks"),
|
buildType: z.literal(BuildType.heroku_buildpacks),
|
||||||
herokuVersion: z.string().nullable().default(""),
|
herokuVersion: z.string().nullable().default(""),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal("paketo_buildpacks"),
|
buildType: z.literal(BuildType.paketo_buildpacks),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal("nixpacks"),
|
buildType: z.literal(BuildType.nixpacks),
|
||||||
publishDirectory: z.string().optional(),
|
publishDirectory: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal("static"),
|
buildType: z.literal(BuildType.railpack),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal("railpack"),
|
buildType: z.literal(BuildType.static),
|
||||||
|
isStaticSpa: z.boolean().default(false),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
type AddTemplate = z.infer<typeof mySchema>;
|
type AddTemplate = z.infer<typeof mySchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApplicationData {
|
||||||
|
buildType: BuildType;
|
||||||
|
dockerfile?: string | null;
|
||||||
|
dockerContextPath?: string | null;
|
||||||
|
dockerBuildStage?: string | null;
|
||||||
|
herokuVersion?: string | null;
|
||||||
|
publishDirectory?: string | null;
|
||||||
|
isStaticSpa?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidBuildType(value: string): value is BuildType {
|
||||||
|
return Object.values(BuildType).includes(value as BuildType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetData = (data: ApplicationData): AddTemplate => {
|
||||||
|
switch (data.buildType) {
|
||||||
|
case BuildType.dockerfile:
|
||||||
|
return {
|
||||||
|
buildType: BuildType.dockerfile,
|
||||||
|
dockerfile: data.dockerfile || "",
|
||||||
|
dockerContextPath: data.dockerContextPath || "",
|
||||||
|
dockerBuildStage: data.dockerBuildStage || "",
|
||||||
|
};
|
||||||
|
case BuildType.heroku_buildpacks:
|
||||||
|
return {
|
||||||
|
buildType: BuildType.heroku_buildpacks,
|
||||||
|
herokuVersion: data.herokuVersion || "",
|
||||||
|
};
|
||||||
|
case BuildType.nixpacks:
|
||||||
|
return {
|
||||||
|
buildType: BuildType.nixpacks,
|
||||||
|
publishDirectory: data.publishDirectory || undefined,
|
||||||
|
};
|
||||||
|
case BuildType.paketo_buildpacks:
|
||||||
|
return {
|
||||||
|
buildType: BuildType.paketo_buildpacks,
|
||||||
|
};
|
||||||
|
case BuildType.static:
|
||||||
|
return {
|
||||||
|
buildType: BuildType.static,
|
||||||
|
isStaticSpa: data.isStaticSpa ?? false,
|
||||||
|
};
|
||||||
|
case BuildType.railpack:
|
||||||
|
return {
|
||||||
|
buildType: BuildType.railpack,
|
||||||
|
};
|
||||||
|
default: {
|
||||||
|
const buildType = data.buildType as BuildType;
|
||||||
|
return {
|
||||||
|
buildType,
|
||||||
|
} as AddTemplate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isLoading } =
|
||||||
api.application.saveBuildType.useMutation();
|
api.application.saveBuildType.useMutation();
|
||||||
const { data, refetch } = api.application.one.useQuery(
|
const { data, refetch } = api.application.one.useQuery(
|
||||||
{
|
{ applicationId },
|
||||||
applicationId,
|
{ enabled: !!applicationId },
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!applicationId,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const form = useForm<AddTemplate>({
|
const form = useForm<AddTemplate>({
|
||||||
@@ -85,46 +149,38 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const buildType = form.watch("buildType");
|
const buildType = form.watch("buildType");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
if (data.buildType === "dockerfile") {
|
const typedData: ApplicationData = {
|
||||||
form.reset({
|
...data,
|
||||||
buildType: data.buildType,
|
buildType: isValidBuildType(data.buildType)
|
||||||
...(data.buildType && {
|
? (data.buildType as BuildType)
|
||||||
dockerfile: data.dockerfile || "",
|
: BuildType.nixpacks, // fallback
|
||||||
dockerContextPath: data.dockerContextPath || "",
|
};
|
||||||
dockerBuildStage: data.dockerBuildStage || "",
|
|
||||||
}),
|
form.reset(resetData(typedData));
|
||||||
});
|
|
||||||
} else if (data.buildType === "heroku_buildpacks") {
|
|
||||||
form.reset({
|
|
||||||
buildType: data.buildType,
|
|
||||||
...(data.buildType && {
|
|
||||||
herokuVersion: data.herokuVersion || "",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
form.reset({
|
|
||||||
buildType: data.buildType,
|
|
||||||
publishDirectory: data.publishDirectory || undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [form.formState.isSubmitSuccessful, form.reset, data, form]);
|
}, [data, form]);
|
||||||
|
|
||||||
const onSubmit = async (data: AddTemplate) => {
|
const onSubmit = async (data: AddTemplate) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
applicationId,
|
applicationId,
|
||||||
buildType: data.buildType,
|
buildType: data.buildType,
|
||||||
publishDirectory:
|
publishDirectory:
|
||||||
data.buildType === "nixpacks" ? data.publishDirectory : null,
|
data.buildType === BuildType.nixpacks ? data.publishDirectory : null,
|
||||||
dockerfile: data.buildType === "dockerfile" ? data.dockerfile : null,
|
dockerfile:
|
||||||
|
data.buildType === BuildType.dockerfile ? data.dockerfile : null,
|
||||||
dockerContextPath:
|
dockerContextPath:
|
||||||
data.buildType === "dockerfile" ? data.dockerContextPath : null,
|
data.buildType === BuildType.dockerfile ? data.dockerContextPath : null,
|
||||||
dockerBuildStage:
|
dockerBuildStage:
|
||||||
data.buildType === "dockerfile" ? data.dockerBuildStage : null,
|
data.buildType === BuildType.dockerfile ? data.dockerBuildStage : null,
|
||||||
herokuVersion:
|
herokuVersion:
|
||||||
data.buildType === "heroku_buildpacks" ? data.herokuVersion : null,
|
data.buildType === BuildType.heroku_buildpacks
|
||||||
|
? data.herokuVersion
|
||||||
|
: null,
|
||||||
|
isStaticSpa:
|
||||||
|
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Build type saved");
|
toast.success("Build type saved");
|
||||||
@@ -152,6 +208,22 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
|
<AlertBlock>
|
||||||
|
Builders can consume significant memory and CPU resources
|
||||||
|
(recommended: 4+ GB RAM and 2+ CPU cores). For production
|
||||||
|
environments, please review our{" "}
|
||||||
|
<a
|
||||||
|
href="https://docs.dokploy.com/docs/core/applications/going-production"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
Production Guide
|
||||||
|
</a>{" "}
|
||||||
|
for best practices and optimization recommendations. Builders are
|
||||||
|
suitable for development and prototyping purposes when you have
|
||||||
|
sufficient resources available.
|
||||||
|
</AlertBlock>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="grid w-full gap-4 p-2"
|
className="grid w-full gap-4 p-2"
|
||||||
@@ -160,193 +232,167 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="buildType"
|
name="buildType"
|
||||||
defaultValue={form.control._defaultValues.buildType}
|
defaultValue={form.control._defaultValues.buildType}
|
||||||
render={({ field }) => {
|
render={({ field }) => (
|
||||||
return (
|
<FormItem className="space-y-3">
|
||||||
<FormItem className="space-y-3">
|
<FormLabel>Build Type</FormLabel>
|
||||||
<FormLabel>Build Type</FormLabel>
|
<FormControl>
|
||||||
<FormControl>
|
<RadioGroup
|
||||||
<RadioGroup
|
onValueChange={field.onChange}
|
||||||
onValueChange={field.onChange}
|
value={field.value}
|
||||||
value={field.value}
|
className="flex flex-col space-y-1"
|
||||||
className="flex flex-col space-y-1"
|
>
|
||||||
>
|
{Object.entries(buildTypeDisplayMap).map(
|
||||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
([value, label]) => (
|
||||||
<FormControl>
|
<FormItem
|
||||||
<RadioGroupItem value="dockerfile" />
|
key={value}
|
||||||
</FormControl>
|
className="flex items-center space-x-3 space-y-0"
|
||||||
<FormLabel className="font-normal">
|
>
|
||||||
Dockerfile
|
<FormControl>
|
||||||
</FormLabel>
|
<RadioGroupItem value={value} />
|
||||||
</FormItem>
|
</FormControl>
|
||||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
<FormLabel className="font-normal">
|
||||||
<FormControl>
|
{label}
|
||||||
<RadioGroupItem value="railpack" />
|
{value === BuildType.railpack && (
|
||||||
</FormControl>
|
<Badge className="ml-2 px-1 text-xs">New</Badge>
|
||||||
<FormLabel className="font-normal">
|
)}
|
||||||
Railpack{" "}
|
</FormLabel>
|
||||||
<Badge className="ml-1 text-xs px-1">New</Badge>
|
</FormItem>
|
||||||
</FormLabel>
|
),
|
||||||
</FormItem>
|
)}
|
||||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
</RadioGroup>
|
||||||
<FormControl>
|
</FormControl>
|
||||||
<RadioGroupItem value="nixpacks" />
|
<FormMessage />
|
||||||
</FormControl>
|
</FormItem>
|
||||||
<FormLabel className="font-normal">
|
)}
|
||||||
Nixpacks
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="heroku_buildpacks" />
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">
|
|
||||||
Heroku Buildpacks
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="paketo_buildpacks" />
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">
|
|
||||||
Paketo Buildpacks
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
|
||||||
<FormControl>
|
|
||||||
<RadioGroupItem value="static" />
|
|
||||||
</FormControl>
|
|
||||||
<FormLabel className="font-normal">Static</FormLabel>
|
|
||||||
</FormItem>
|
|
||||||
</RadioGroup>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{buildType === "heroku_buildpacks" && (
|
{buildType === BuildType.heroku_buildpacks && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="herokuVersion"
|
name="herokuVersion"
|
||||||
render={({ field }) => {
|
render={({ field }) => (
|
||||||
return (
|
<FormItem>
|
||||||
<FormItem>
|
<FormLabel>Heroku Version (Optional)</FormLabel>
|
||||||
<FormLabel>Heroku Version (Optional)</FormLabel>
|
<FormControl>
|
||||||
<FormControl>
|
<Input
|
||||||
<Input
|
placeholder="Heroku Version (Default: 24)"
|
||||||
placeholder={"Heroku Version (Default: 24)"}
|
{...field}
|
||||||
{...field}
|
value={field.value ?? ""}
|
||||||
value={field.value ?? ""}
|
/>
|
||||||
/>
|
</FormControl>
|
||||||
</FormControl>
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
<FormMessage />
|
)}
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{buildType === "dockerfile" && (
|
{buildType === BuildType.dockerfile && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="dockerfile"
|
name="dockerfile"
|
||||||
render={({ field }) => {
|
render={({ field }) => (
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Docker File</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder={"Path of your docker file"}
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="dockerContextPath"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Docker Context Path</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder={
|
|
||||||
"Path of your docker context default: ."
|
|
||||||
}
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="dockerBuildStage"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Docker Build Stage</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Allows you to target a specific stage in a
|
|
||||||
Multi-stage Dockerfile. If empty, Docker defaults to
|
|
||||||
build the last defined stage.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder={"E.g. production"}
|
|
||||||
{...field}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{buildType === "nixpacks" && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="publishDirectory"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div className="space-y-0.5">
|
<FormLabel>Docker File</FormLabel>
|
||||||
<FormLabel>Publish Directory</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Allows you to serve a single directory via NGINX after
|
|
||||||
the build phase. Useful if the final build assets
|
|
||||||
should be served as a static site.
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={"Publish Directory"}
|
placeholder="Path of your docker file"
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value ?? ""}
|
value={field.value ?? ""}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
)}
|
||||||
}}
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="dockerContextPath"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Docker Context Path</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Path of your docker context (default: .)"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="dockerBuildStage"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Docker Build Stage</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Allows you to target a specific stage in a Multi-stage
|
||||||
|
Dockerfile. If empty, Docker defaults to build the
|
||||||
|
last defined stage.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="E.g. production"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{buildType === BuildType.nixpacks && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="publishDirectory"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Publish Directory</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Allows you to serve a single directory via NGINX after
|
||||||
|
the build phase. Useful if the final build assets should
|
||||||
|
be served as a static site.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Publish Directory"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{buildType === BuildType.static && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="isStaticSpa"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex items-center gap-x-2 p-2">
|
||||||
|
<Checkbox
|
||||||
|
id="checkboxIsStaticSpa"
|
||||||
|
value={String(field.value)}
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
<FormLabel htmlFor="checkboxIsStaticSpa">
|
||||||
|
Single Page Application (SPA)
|
||||||
|
</FormLabel>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
|
|||||||
@@ -15,11 +15,15 @@ import { Paintbrush } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
id: string;
|
||||||
|
type: "application" | "compose";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CancelQueues = ({ applicationId }: Props) => {
|
export const CancelQueues = ({ id, type }: Props) => {
|
||||||
const { mutateAsync, isLoading } = api.application.cleanQueues.useMutation();
|
const { mutateAsync, isLoading } =
|
||||||
|
type === "application"
|
||||||
|
? api.application.cleanQueues.useMutation()
|
||||||
|
: api.compose.cleanQueues.useMutation();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
|
|
||||||
if (isCloud) {
|
if (isCloud) {
|
||||||
@@ -48,7 +52,8 @@ export const CancelQueues = ({ applicationId }: Props) => {
|
|||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
applicationId,
|
applicationId: id || "",
|
||||||
|
composeId: id || "",
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Queues are being cleaned");
|
toast.success("Queues are being cleaned");
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ import { RefreshCcw } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
id: string;
|
||||||
|
type: "application" | "compose";
|
||||||
}
|
}
|
||||||
export const RefreshToken = ({ applicationId }: Props) => {
|
export const RefreshToken = ({ id, type }: Props) => {
|
||||||
const { mutateAsync } = api.application.refreshToken.useMutation();
|
const { mutateAsync } =
|
||||||
|
type === "application"
|
||||||
|
? api.application.refreshToken.useMutation()
|
||||||
|
: api.compose.refreshToken.useMutation();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
return (
|
return (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
@@ -37,12 +41,19 @@ export const RefreshToken = ({ applicationId }: Props) => {
|
|||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
applicationId,
|
applicationId: id || "",
|
||||||
|
composeId: id || "",
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
utils.application.one.invalidate({
|
if (type === "application") {
|
||||||
applicationId,
|
utils.application.one.invalidate({
|
||||||
});
|
applicationId: id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
utils.compose.one.invalidate({
|
||||||
|
composeId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
toast.success("Refresh updated");
|
toast.success("Refresh updated");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@/utils/api";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ShowDeployment } from "../deployments/show-deployment";
|
||||||
|
import { ShowDeployments } from "./show-deployments";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
type:
|
||||||
|
| "application"
|
||||||
|
| "compose"
|
||||||
|
| "schedule"
|
||||||
|
| "server"
|
||||||
|
| "backup"
|
||||||
|
| "previewDeployment";
|
||||||
|
serverId?: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatDuration = (seconds: number) => {
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
return `${minutes}m ${remainingSeconds}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShowDeploymentsModal = ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
serverId,
|
||||||
|
refreshToken,
|
||||||
|
children,
|
||||||
|
}: Props) => {
|
||||||
|
const [activeLog, setActiveLog] = useState<
|
||||||
|
RouterOutputs["deployment"]["all"][number] | null
|
||||||
|
>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{children ? (
|
||||||
|
children
|
||||||
|
) : (
|
||||||
|
<Button className="sm:w-auto w-full" size="sm" variant="outline">
|
||||||
|
View Logs
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl p-0">
|
||||||
|
<ShowDeployments
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
serverId={serverId}
|
||||||
|
refreshToken={refreshToken}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<ShowDeployment
|
||||||
|
serverId={serverId || ""}
|
||||||
|
open={Boolean(activeLog && activeLog.logPath !== null)}
|
||||||
|
onClose={() => setActiveLog(null)}
|
||||||
|
logPath={activeLog?.logPath || ""}
|
||||||
|
errorMessage={activeLog?.errorMessage || ""}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -9,28 +10,58 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { type RouterOutputs, api } from "@/utils/api";
|
import { type RouterOutputs, api } from "@/utils/api";
|
||||||
import { RocketIcon } from "lucide-react";
|
import { Clock, Loader2, RocketIcon, Settings, RefreshCcw } from "lucide-react";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { CancelQueues } from "./cancel-queues";
|
import { CancelQueues } from "./cancel-queues";
|
||||||
import { RefreshToken } from "./refresh-token";
|
import { RefreshToken } from "./refresh-token";
|
||||||
import { ShowDeployment } from "./show-deployment";
|
import { ShowDeployment } from "./show-deployment";
|
||||||
|
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
id: string;
|
||||||
|
type:
|
||||||
|
| "application"
|
||||||
|
| "compose"
|
||||||
|
| "schedule"
|
||||||
|
| "server"
|
||||||
|
| "backup"
|
||||||
|
| "previewDeployment";
|
||||||
|
refreshToken?: string;
|
||||||
|
serverId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowDeployments = ({ applicationId }: Props) => {
|
export const formatDuration = (seconds: number) => {
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
return `${minutes}m ${remainingSeconds}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShowDeployments = ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
refreshToken,
|
||||||
|
serverId,
|
||||||
|
}: Props) => {
|
||||||
const [activeLog, setActiveLog] = useState<
|
const [activeLog, setActiveLog] = useState<
|
||||||
RouterOutputs["deployment"]["all"][number] | null
|
RouterOutputs["deployment"]["all"][number] | null
|
||||||
>(null);
|
>(null);
|
||||||
const { data } = api.application.one.useQuery({ applicationId });
|
const { data: deployments, isLoading: isLoadingDeployments } =
|
||||||
const { data: deployments } = api.deployment.all.useQuery(
|
api.deployment.allByType.useQuery(
|
||||||
{ applicationId },
|
{
|
||||||
{
|
id,
|
||||||
enabled: !!applicationId,
|
type,
|
||||||
refetchInterval: 1000,
|
},
|
||||||
},
|
{
|
||||||
);
|
enabled: !!id,
|
||||||
|
refetchInterval: 1000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: rollback, isLoading: isRollingBack } =
|
||||||
|
api.rollback.rollback.useMutation();
|
||||||
|
|
||||||
const [url, setUrl] = React.useState("");
|
const [url, setUrl] = React.useState("");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,34 +69,57 @@ export const ShowDeployments = ({ applicationId }: Props) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-background">
|
<Card className="bg-background border-none">
|
||||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<CardTitle className="text-xl">Deployments</CardTitle>
|
<CardTitle className="text-xl">Deployments</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
See all the 10 last deployments for this application
|
See all the 10 last deployments for this {type}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<CancelQueues applicationId={applicationId} />
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
{(type === "application" || type === "compose") && (
|
||||||
|
<CancelQueues id={id} type={type} />
|
||||||
|
)}
|
||||||
|
{type === "application" && (
|
||||||
|
<ShowRollbackSettings applicationId={id}>
|
||||||
|
<Button variant="outline">
|
||||||
|
Configure Rollbacks <Settings className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</ShowRollbackSettings>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2 text-sm">
|
{refreshToken && (
|
||||||
<span>
|
<div className="flex flex-col gap-2 text-sm">
|
||||||
If you want to re-deploy this application use this URL in the config
|
<span>
|
||||||
of your git provider or docker
|
If you want to re-deploy this application use this URL in the
|
||||||
</span>
|
config of your git provider or docker
|
||||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
</span>
|
||||||
<span>Webhook URL: </span>
|
<div className="flex flex-row items-center gap-2 flex-wrap">
|
||||||
<div className="flex flex-row items-center gap-2">
|
<span>Webhook URL: </span>
|
||||||
<span className="break-all text-muted-foreground">
|
<div className="flex flex-row items-center gap-2">
|
||||||
{`${url}/api/deploy/${data?.refreshToken}`}
|
<span className="break-all text-muted-foreground">
|
||||||
</span>
|
{`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`}
|
||||||
<RefreshToken applicationId={applicationId} />
|
</span>
|
||||||
|
{(type === "application" || type === "compose") && (
|
||||||
|
<RefreshToken id={id} type={type} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{data?.deployments?.length === 0 ? (
|
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
{isLoadingDeployments ? (
|
||||||
|
<div className="flex w-full flex-row items-center justify-center gap-3 pt-10 min-h-[25vh]">
|
||||||
|
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||||
|
<span className="text-base text-muted-foreground">
|
||||||
|
Loading deployments...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : deployments?.length === 0 ? (
|
||||||
|
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10 min-h-[25vh]">
|
||||||
<RocketIcon className="size-8 text-muted-foreground" />
|
<RocketIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
No deployments found
|
No deployments found
|
||||||
@@ -96,24 +150,73 @@ export const ShowDeployments = ({ applicationId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
<div className="flex flex-col items-end gap-2">
|
||||||
<div className="text-sm capitalize text-muted-foreground">
|
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
||||||
<DateTooltip date={deployment.createdAt} />
|
<DateTooltip date={deployment.createdAt} />
|
||||||
|
{deployment.startedAt && deployment.finishedAt && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-[10px] gap-1 flex items-center"
|
||||||
|
>
|
||||||
|
<Clock className="size-3" />
|
||||||
|
{formatDuration(
|
||||||
|
Math.floor(
|
||||||
|
(new Date(deployment.finishedAt).getTime() -
|
||||||
|
new Date(deployment.startedAt).getTime()) /
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex flex-row items-center gap-2">
|
||||||
onClick={() => {
|
<Button
|
||||||
setActiveLog(deployment);
|
onClick={() => {
|
||||||
}}
|
setActiveLog(deployment);
|
||||||
>
|
}}
|
||||||
View
|
>
|
||||||
</Button>
|
View
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{deployment?.rollback &&
|
||||||
|
deployment.status === "done" &&
|
||||||
|
type === "application" && (
|
||||||
|
<DialogAction
|
||||||
|
title="Rollback to this deployment"
|
||||||
|
description="Are you sure you want to rollback to this deployment?"
|
||||||
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
await rollback({
|
||||||
|
rollbackId: deployment.rollback.rollbackId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
"Rollback initiated successfully",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error initiating rollback");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
isLoading={isRollingBack}
|
||||||
|
>
|
||||||
|
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
Rollback
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ShowDeployment
|
<ShowDeployment
|
||||||
serverId={data?.serverId || ""}
|
serverId={serverId}
|
||||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
open={Boolean(activeLog && activeLog.logPath !== null)}
|
||||||
onClose={() => setActiveLog(null)}
|
onClose={() => setActiveLog(null)}
|
||||||
logPath={activeLog?.logPath || ""}
|
logPath={activeLog?.logPath || ""}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Copy, HelpCircle, Server } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
domain: {
|
||||||
|
host: string;
|
||||||
|
https: boolean;
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
serverIp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DnsHelperModal = ({ domain, serverIp }: Props) => {
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
toast.success("Copied to clipboard!");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger>
|
||||||
|
<Button variant="ghost" size="icon" className="group">
|
||||||
|
<HelpCircle className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Server className="size-5" />
|
||||||
|
DNS Configuration Guide
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Follow these steps to configure your DNS records for {domain.host}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<AlertBlock type="info">
|
||||||
|
To make your domain accessible, you need to configure your DNS
|
||||||
|
records with your domain provider (e.g., Cloudflare, GoDaddy,
|
||||||
|
NameCheap).
|
||||||
|
</AlertBlock>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h3 className="font-medium mb-2">1. Add A Record</h3>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Create an A record that points your domain to the server's IP
|
||||||
|
address:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-2 bg-muted p-3 rounded-md">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Type: A</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Name: @ or {domain.host.split(".")[0]}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Value: {serverIp || "Your server IP"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyToClipboard(serverIp || "")}
|
||||||
|
disabled={!serverIp}
|
||||||
|
>
|
||||||
|
<Copy className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h3 className="font-medium mb-2">2. Verify Configuration</h3>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
After configuring your DNS records:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li>Wait for DNS propagation (usually 15-30 minutes)</li>
|
||||||
|
<li>
|
||||||
|
Test your domain by visiting:{" "}
|
||||||
|
{domain.https ? "https://" : "http://"}
|
||||||
|
{domain.host}
|
||||||
|
{domain.path || "/"}
|
||||||
|
</li>
|
||||||
|
<li>Use a DNS lookup tool to verify your records</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -38,26 +38,67 @@ 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 { domain } from "@/server/db/validations/domain";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Dices } from "lucide-react";
|
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
||||||
import type z from "zod";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
export type CacheType = "fetch" | "cache";
|
||||||
|
|
||||||
|
export const domain = z
|
||||||
|
.object({
|
||||||
|
host: z.string().min(1, { message: "Add a hostname" }),
|
||||||
|
path: z.string().min(1).optional(),
|
||||||
|
port: z
|
||||||
|
.number()
|
||||||
|
.min(1, { message: "Port must be at least 1" })
|
||||||
|
.max(65535, { message: "Port must be 65535 or below" })
|
||||||
|
.optional(),
|
||||||
|
https: z.boolean().optional(),
|
||||||
|
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||||
|
customCertResolver: z.string().optional(),
|
||||||
|
serviceName: z.string().optional(),
|
||||||
|
domainType: z.enum(["application", "compose", "preview"]).optional(),
|
||||||
|
})
|
||||||
|
.superRefine((input, ctx) => {
|
||||||
|
if (input.https && !input.certificateType) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["certificateType"],
|
||||||
|
message: "Required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.certificateType === "custom" && !input.customCertResolver) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["customCertResolver"],
|
||||||
|
message: "Required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.domainType === "compose" && !input.serviceName) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["serviceName"],
|
||||||
|
message: "Required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
type Domain = z.infer<typeof domain>;
|
type Domain = z.infer<typeof domain>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
id: string;
|
||||||
|
type: "application" | "compose";
|
||||||
domainId?: string;
|
domainId?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddDomain = ({
|
export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||||
applicationId,
|
|
||||||
domainId = "",
|
|
||||||
children,
|
|
||||||
}: Props) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { data, refetch } = api.domain.one.useQuery(
|
const { data, refetch } = api.domain.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -68,14 +109,24 @@ export const AddDomain = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: application } = api.application.one.useQuery(
|
const { data: application } =
|
||||||
{
|
type === "application"
|
||||||
applicationId,
|
? api.application.one.useQuery(
|
||||||
},
|
{
|
||||||
{
|
applicationId: id,
|
||||||
enabled: !!applicationId,
|
},
|
||||||
},
|
{
|
||||||
);
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: api.compose.one.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const { mutateAsync, isError, error, isLoading } = domainId
|
const { mutateAsync, isError, error, isLoading } = domainId
|
||||||
? api.domain.update.useMutation()
|
? api.domain.update.useMutation()
|
||||||
@@ -89,7 +140,22 @@ export const AddDomain = ({
|
|||||||
serverId: application?.serverId || "",
|
serverId: application?.serverId || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("canGenerateTraefikMeDomains", canGenerateTraefikMeDomains);
|
const {
|
||||||
|
data: services,
|
||||||
|
isFetching: isLoadingServices,
|
||||||
|
error: errorServices,
|
||||||
|
refetch: refetchServices,
|
||||||
|
} = api.compose.loadServices.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id,
|
||||||
|
type: cacheType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
enabled: type === "compose" && !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const form = useForm<Domain>({
|
const form = useForm<Domain>({
|
||||||
resolver: zodResolver(domain),
|
resolver: zodResolver(domain),
|
||||||
@@ -100,12 +166,15 @@ export const AddDomain = ({
|
|||||||
https: false,
|
https: false,
|
||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
customCertResolver: undefined,
|
customCertResolver: undefined,
|
||||||
|
serviceName: undefined,
|
||||||
|
domainType: type,
|
||||||
},
|
},
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
const certificateType = form.watch("certificateType");
|
const certificateType = form.watch("certificateType");
|
||||||
const https = form.watch("https");
|
const https = form.watch("https");
|
||||||
|
const domainType = form.watch("domainType");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -116,6 +185,8 @@ export const AddDomain = ({
|
|||||||
port: data?.port || undefined,
|
port: data?.port || undefined,
|
||||||
certificateType: data?.certificateType || undefined,
|
certificateType: data?.certificateType || undefined,
|
||||||
customCertResolver: data?.customCertResolver || undefined,
|
customCertResolver: data?.customCertResolver || undefined,
|
||||||
|
serviceName: data?.serviceName || undefined,
|
||||||
|
domainType: data?.domainType || type,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +198,7 @@ export const AddDomain = ({
|
|||||||
https: false,
|
https: false,
|
||||||
certificateType: undefined,
|
certificateType: undefined,
|
||||||
customCertResolver: undefined,
|
customCertResolver: undefined,
|
||||||
|
domainType: type,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, data, isLoading, domainId]);
|
}, [form, data, isLoading, domainId]);
|
||||||
@@ -150,22 +222,37 @@ export const AddDomain = ({
|
|||||||
const onSubmit = async (data: Domain) => {
|
const onSubmit = async (data: Domain) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
domainId,
|
domainId,
|
||||||
applicationId,
|
...(data.domainType === "application" && {
|
||||||
|
applicationId: id,
|
||||||
|
}),
|
||||||
|
...(data.domainType === "compose" && {
|
||||||
|
composeId: id,
|
||||||
|
}),
|
||||||
...data,
|
...data,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success(dictionary.success);
|
toast.success(dictionary.success);
|
||||||
await utils.domain.byApplicationId.invalidate({
|
|
||||||
applicationId,
|
if (data.domainType === "application") {
|
||||||
});
|
await utils.domain.byApplicationId.invalidate({
|
||||||
await utils.application.readTraefikConfig.invalidate({ applicationId });
|
applicationId: id,
|
||||||
|
});
|
||||||
|
await utils.application.readTraefikConfig.invalidate({
|
||||||
|
applicationId: id,
|
||||||
|
});
|
||||||
|
} else if (data.domainType === "compose") {
|
||||||
|
await utils.domain.byComposeId.invalidate({
|
||||||
|
composeId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (domainId) {
|
if (domainId) {
|
||||||
refetch();
|
refetch();
|
||||||
}
|
}
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((e) => {
|
||||||
|
console.log(e);
|
||||||
toast.error(dictionary.error);
|
toast.error(dictionary.error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -189,6 +276,119 @@ export const AddDomain = ({
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-row items-end w-full gap-4">
|
||||||
|
{domainType === "compose" && (
|
||||||
|
<div className="flex flex-col gap-2 w-full">
|
||||||
|
{errorServices && (
|
||||||
|
<AlertBlock
|
||||||
|
type="warning"
|
||||||
|
className="[overflow-wrap:anywhere]"
|
||||||
|
>
|
||||||
|
{errorServices?.message}
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serviceName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Service Name</FormLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value || ""}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a service name" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{services?.map((service, index) => (
|
||||||
|
<SelectItem
|
||||||
|
value={service}
|
||||||
|
key={`${service}-${index}`}
|
||||||
|
>
|
||||||
|
{service}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="none" disabled>
|
||||||
|
Empty
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "fetch") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("fetch");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Fetch: Will clone the repository and load
|
||||||
|
the services
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "cache") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("cache");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Cache: If you previously deployed this
|
||||||
|
compose, it will read the services from
|
||||||
|
the last deployment/fetch from the
|
||||||
|
repository
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="host"
|
name="host"
|
||||||
@@ -276,6 +476,11 @@ export const AddDomain = ({
|
|||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Container Port</FormLabel>
|
<FormLabel>Container Port</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
The port where your application is running inside the
|
||||||
|
container (e.g., 3000 for Node.js, 80 for Nginx, 8080
|
||||||
|
for Java)
|
||||||
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<NumberInput placeholder={"3000"} {...field} />
|
<NumberInput placeholder={"3000"} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -7,29 +8,135 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
ExternalLink,
|
||||||
|
GlobeIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2,
|
||||||
|
PenBoxIcon,
|
||||||
|
RefreshCw,
|
||||||
|
Server,
|
||||||
|
Trash2,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AddDomain } from "./add-domain";
|
import { DnsHelperModal } from "./dns-helper-modal";
|
||||||
|
import { AddDomain } from "./handle-domain";
|
||||||
|
|
||||||
|
export type ValidationState = {
|
||||||
|
isLoading: boolean;
|
||||||
|
isValid?: boolean;
|
||||||
|
error?: string;
|
||||||
|
resolvedIp?: string;
|
||||||
|
message?: string;
|
||||||
|
cdnProvider?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ValidationStates = Record<string, ValidationState>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
id: string;
|
||||||
|
type: "application" | "compose";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowDomains = ({ applicationId }: Props) => {
|
export const ShowDomains = ({ id, type }: Props) => {
|
||||||
const { data, refetch } = api.domain.byApplicationId.useQuery(
|
const { data: application } =
|
||||||
{
|
type === "application"
|
||||||
applicationId,
|
? api.application.one.useQuery(
|
||||||
},
|
{
|
||||||
{
|
applicationId: id,
|
||||||
enabled: !!applicationId,
|
},
|
||||||
},
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: api.compose.one.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
||||||
|
{},
|
||||||
);
|
);
|
||||||
|
const { data: ip } = api.settings.getIp.useQuery();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
refetch,
|
||||||
|
isLoading: isLoadingDomains,
|
||||||
|
} = type === "application"
|
||||||
|
? api.domain.byApplicationId.useQuery(
|
||||||
|
{
|
||||||
|
applicationId: id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: api.domain.byComposeId.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: validateDomain } =
|
||||||
|
api.domain.validateDomain.useMutation();
|
||||||
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
||||||
api.domain.delete.useMutation();
|
api.domain.delete.useMutation();
|
||||||
|
|
||||||
|
const handleValidateDomain = async (host: string) => {
|
||||||
|
setValidationStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[host]: { isLoading: true },
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await validateDomain({
|
||||||
|
domain: host,
|
||||||
|
serverIp:
|
||||||
|
application?.server?.ipAddress?.toString() || ip?.toString() || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
setValidationStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[host]: {
|
||||||
|
isLoading: false,
|
||||||
|
isValid: result.isValid,
|
||||||
|
error: result.error,
|
||||||
|
resolvedIp: result.resolvedIp,
|
||||||
|
cdnProvider: result.cdnProvider,
|
||||||
|
message: result.error && result.isValid ? result.error : undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
setValidationStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[host]: {
|
||||||
|
isLoading: false,
|
||||||
|
isValid: false,
|
||||||
|
error: error.message || "Failed to validate domain",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
@@ -43,7 +150,7 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
|
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
{data && data?.length > 0 && (
|
{data && data?.length > 0 && (
|
||||||
<AddDomain applicationId={applicationId}>
|
<AddDomain id={id} type={type}>
|
||||||
<Button>
|
<Button>
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
</Button>
|
</Button>
|
||||||
@@ -52,15 +159,22 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-row gap-4">
|
<CardContent className="flex w-full flex-row gap-4">
|
||||||
{data?.length === 0 ? (
|
{isLoadingDomains ? (
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-3">
|
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
|
||||||
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-base text-muted-foreground">
|
||||||
|
Loading domains...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : data?.length === 0 ? (
|
||||||
|
<div className="flex w-full flex-col items-center justify-center gap-3 min-h-[40vh]">
|
||||||
<GlobeIcon className="size-8 text-muted-foreground" />
|
<GlobeIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To access the application it is required to set at least 1
|
To access the application it is required to set at least 1
|
||||||
domain
|
domain
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
<AddDomain applicationId={applicationId}>
|
<AddDomain id={id} type={type}>
|
||||||
<Button>
|
<Button>
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
<GlobeIcon className="size-4" /> Add Domain
|
||||||
</Button>
|
</Button>
|
||||||
@@ -68,73 +182,216 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
||||||
{data?.map((item) => {
|
{data?.map((item) => {
|
||||||
|
const validationState = validationStates[item.host];
|
||||||
return (
|
return (
|
||||||
<div
|
<Card
|
||||||
key={item.domainId}
|
key={item.domainId}
|
||||||
className="flex w-full items-center justify-between gap-4 border p-4 md:px-6 rounded-lg flex-wrap"
|
className="relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit"
|
||||||
>
|
>
|
||||||
<Link
|
<CardContent className="p-6">
|
||||||
className="md:basis-1/2 flex gap-2 items-center hover:underline transition-all w-full"
|
<div className="flex flex-col gap-4">
|
||||||
target="_blank"
|
{/* Service & Domain Info */}
|
||||||
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
<div className="flex items-center justify-between flex-wrap gap-y-2">
|
||||||
>
|
{item.serviceName && (
|
||||||
<span className="truncate max-w-full text-sm">
|
<Badge variant="outline" className="w-fit">
|
||||||
{item.host}
|
<Server className="size-3 mr-1" />
|
||||||
</span>
|
{item.serviceName}
|
||||||
<ExternalLink className="size-4 min-w-4" />
|
</Badge>
|
||||||
</Link>
|
)}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
<div className="flex gap-8">
|
{!item.host.includes("traefik.me") && (
|
||||||
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
|
<DnsHelperModal
|
||||||
<span>{item.path}</span>
|
domain={{
|
||||||
<span>{item.port}</span>
|
host: item.host,
|
||||||
<span>{item.https ? "HTTPS" : "HTTP"}</span>
|
https: item.https,
|
||||||
</div>
|
path: item.path || undefined,
|
||||||
|
}}
|
||||||
<div className="flex gap-2">
|
serverIp={
|
||||||
<AddDomain
|
application?.server?.ipAddress?.toString() ||
|
||||||
applicationId={applicationId}
|
ip?.toString()
|
||||||
domainId={item.domainId}
|
}
|
||||||
>
|
/>
|
||||||
<Button
|
)}
|
||||||
variant="ghost"
|
<AddDomain
|
||||||
size="icon"
|
id={id}
|
||||||
className="group hover:bg-blue-500/10 "
|
type={type}
|
||||||
|
domainId={item.domainId}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10"
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
</AddDomain>
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Domain"
|
||||||
|
description="Are you sure you want to delete this domain?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteDomain({
|
||||||
|
domainId: item.domainId,
|
||||||
|
})
|
||||||
|
.then((_data) => {
|
||||||
|
refetch();
|
||||||
|
toast.success(
|
||||||
|
"Domain deleted successfully",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting domain");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full break-all">
|
||||||
|
<Link
|
||||||
|
className="flex items-center gap-2 text-base font-medium hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
||||||
>
|
>
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
{item.host}
|
||||||
</Button>
|
<ExternalLink className="size-4 min-w-4" />
|
||||||
</AddDomain>
|
</Link>
|
||||||
<DialogAction
|
</div>
|
||||||
title="Delete Domain"
|
|
||||||
description="Are you sure you want to delete this domain?"
|
{/* Domain Details */}
|
||||||
type="destructive"
|
<div className="flex flex-wrap gap-3">
|
||||||
onClick={async () => {
|
<TooltipProvider>
|
||||||
await deleteDomain({
|
<Tooltip>
|
||||||
domainId: item.domainId,
|
<TooltipTrigger asChild>
|
||||||
})
|
<Badge variant="secondary">
|
||||||
.then(() => {
|
<InfoIcon className="size-3 mr-1" />
|
||||||
refetch();
|
Path: {item.path || "/"}
|
||||||
toast.success("Domain deleted successfully");
|
</Badge>
|
||||||
})
|
</TooltipTrigger>
|
||||||
.catch(() => {
|
<TooltipContent>
|
||||||
toast.error("Error deleting domain");
|
<p>URL path for this service</p>
|
||||||
});
|
</TooltipContent>
|
||||||
}}
|
</Tooltip>
|
||||||
>
|
</TooltipProvider>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
<TooltipProvider>
|
||||||
size="icon"
|
<Tooltip>
|
||||||
className="group hover:bg-red-500/10"
|
<TooltipTrigger asChild>
|
||||||
isLoading={isRemoving}
|
<Badge variant="secondary">
|
||||||
>
|
<InfoIcon className="size-3 mr-1" />
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
Port: {item.port}
|
||||||
</Button>
|
</Badge>
|
||||||
</DialogAction>
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Container port exposed</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant={item.https ? "outline" : "secondary"}
|
||||||
|
>
|
||||||
|
{item.https ? "HTTPS" : "HTTP"}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{item.https
|
||||||
|
? "Secure HTTPS connection"
|
||||||
|
: "Standard HTTP connection"}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{item.certificateType && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge variant="outline">
|
||||||
|
Cert: {item.certificateType}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>SSL Certificate Provider</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
validationState?.isValid
|
||||||
|
? "bg-green-500/10 text-green-500 cursor-pointer"
|
||||||
|
: validationState?.error
|
||||||
|
? "bg-red-500/10 text-red-500 cursor-pointer"
|
||||||
|
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
handleValidateDomain(item.host)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{validationState?.isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-3 mr-1 animate-spin" />
|
||||||
|
Checking DNS...
|
||||||
|
</>
|
||||||
|
) : validationState?.isValid ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="size-3 mr-1" />
|
||||||
|
{validationState.message &&
|
||||||
|
validationState.cdnProvider
|
||||||
|
? `Behind ${validationState.cdnProvider}`
|
||||||
|
: "DNS Valid"}
|
||||||
|
</>
|
||||||
|
) : validationState?.error ? (
|
||||||
|
<>
|
||||||
|
<XCircle className="size-3 mr-1" />
|
||||||
|
{validationState.error}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="size-3 mr-1" />
|
||||||
|
Validate DNS
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
{validationState?.error ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-medium text-red-500">
|
||||||
|
Error:
|
||||||
|
</p>
|
||||||
|
<p>{validationState.error}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Click to validate DNS configuration"
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { Form } from "@/components/ui/form";
|
|||||||
import { Secrets } from "@/components/ui/secrets";
|
import { Secrets } from "@/components/ui/secrets";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
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 { useEffect } from "react";
|
|
||||||
|
|
||||||
const addEnvironmentSchema = z.object({
|
const addEnvironmentSchema = z.object({
|
||||||
env: z.string(),
|
env: z.string(),
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -29,6 +31,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -39,13 +42,11 @@ 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";
|
||||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
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 { Badge } from "@/components/ui/badge";
|
|
||||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
const BitbucketProviderSchema = z.object({
|
const BitbucketProviderSchema = z.object({
|
||||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||||
@@ -58,6 +59,7 @@ const BitbucketProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
enableSubmodules: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
|
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
|
||||||
@@ -84,6 +86,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
bitbucketId: "",
|
bitbucketId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(BitbucketProviderSchema),
|
resolver: zodResolver(BitbucketProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -130,9 +133,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
buildPath: data.bitbucketBuildPath || "/",
|
buildPath: data.bitbucketBuildPath || "/",
|
||||||
bitbucketId: data.bitbucketId || "",
|
bitbucketId: data.bitbucketId || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules || false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data?.applicationId, form]);
|
||||||
|
|
||||||
const onSubmit = async (data: BitbucketProvider) => {
|
const onSubmit = async (data: BitbucketProvider) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -143,6 +147,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
bitbucketId: data.bitbucketId,
|
bitbucketId: data.bitbucketId,
|
||||||
applicationId,
|
applicationId,
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules || false,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provided Saved");
|
toast.success("Service Provided Saved");
|
||||||
@@ -430,7 +435,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -449,7 +454,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const input = document.querySelector(
|
const input = document.querySelector(
|
||||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
const value = input.value.trim();
|
const value = input.value.trim();
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -467,6 +472,21 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableSubmodules"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
|||||||
registryURL: data.registryUrl || "",
|
registryURL: data.registryUrl || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data?.applicationId, form]);
|
||||||
|
|
||||||
const onSubmit = async (values: DockerProvider) => {
|
const onSubmit = async (values: DockerProvider) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -115,7 +115,11 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Username</FormLabel>
|
<FormLabel>Username</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Username" autoComplete="username" {...field} />
|
<Input
|
||||||
|
placeholder="Username"
|
||||||
|
autoComplete="username"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -130,7 +134,12 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Password</FormLabel>
|
<FormLabel>Password</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Password" autoComplete="one-time-code" {...field} type="password" />
|
<Input
|
||||||
|
placeholder="Password"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -26,15 +27,15 @@ import {
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import { GitIcon } from "@/components/icons/data-tools-icons";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useEffect } from "react";
|
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 { Badge } from "@/components/ui/badge";
|
|
||||||
import { GitIcon } from "@/components/icons/data-tools-icons";
|
|
||||||
|
|
||||||
const GitProviderSchema = z.object({
|
const GitProviderSchema = z.object({
|
||||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||||
@@ -44,6 +45,7 @@ const GitProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch required"),
|
branch: z.string().min(1, "Branch required"),
|
||||||
sshKey: z.string().optional(),
|
sshKey: z.string().optional(),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GitProvider = z.infer<typeof GitProviderSchema>;
|
type GitProvider = z.infer<typeof GitProviderSchema>;
|
||||||
@@ -67,6 +69,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
repositoryURL: "",
|
repositoryURL: "",
|
||||||
sshKey: undefined,
|
sshKey: undefined,
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GitProviderSchema),
|
resolver: zodResolver(GitProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -79,6 +82,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
buildPath: data.customGitBuildPath || "/",
|
buildPath: data.customGitBuildPath || "/",
|
||||||
repositoryURL: data.customGitUrl || "",
|
repositoryURL: data.customGitUrl || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
@@ -91,6 +95,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
|
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
|
||||||
applicationId,
|
applicationId,
|
||||||
watchPaths: values.watchPaths || [],
|
watchPaths: values.watchPaths || [],
|
||||||
|
enableSubmodules: values.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Git Provider Saved");
|
toast.success("Git Provider Saved");
|
||||||
@@ -257,7 +262,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -276,7 +281,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const input = document.querySelector(
|
const input = document.querySelector(
|
||||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
const value = input.value.trim();
|
const value = input.value.trim();
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -294,6 +299,22 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableSubmodules"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row justify-end">
|
<div className="flex flex-row justify-end">
|
||||||
|
|||||||
@@ -0,0 +1,538 @@
|
|||||||
|
import { GiteaIcon } from "@/components/icons/data-tools-icons";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
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 { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
interface GiteaRepository {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
id: number;
|
||||||
|
owner: {
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GiteaBranch {
|
||||||
|
name: string;
|
||||||
|
commit: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const GiteaProviderSchema = z.object({
|
||||||
|
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||||
|
repository: z
|
||||||
|
.object({
|
||||||
|
repo: z.string().min(1, "Repo is required"),
|
||||||
|
owner: z.string().min(1, "Owner is required"),
|
||||||
|
})
|
||||||
|
.required(),
|
||||||
|
branch: z.string().min(1, "Branch is required"),
|
||||||
|
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||||
|
watchPaths: z.array(z.string()).default([]),
|
||||||
|
enableSubmodules: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
applicationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||||
|
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
|
||||||
|
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading: isSavingGiteaProvider } =
|
||||||
|
api.application.saveGiteaProvider.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<GiteaProvider>({
|
||||||
|
defaultValues: {
|
||||||
|
buildPath: "/",
|
||||||
|
repository: {
|
||||||
|
owner: "",
|
||||||
|
repo: "",
|
||||||
|
},
|
||||||
|
giteaId: "",
|
||||||
|
branch: "",
|
||||||
|
watchPaths: [],
|
||||||
|
enableSubmodules: false,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(GiteaProviderSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const repository = form.watch("repository");
|
||||||
|
const giteaId = form.watch("giteaId");
|
||||||
|
|
||||||
|
const { data: giteaUrl } = api.gitea.getGiteaUrl.useQuery(
|
||||||
|
{ giteaId },
|
||||||
|
{
|
||||||
|
enabled: !!giteaId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: repositories,
|
||||||
|
isLoading: isLoadingRepositories,
|
||||||
|
error,
|
||||||
|
} = api.gitea.getGiteaRepositories.useQuery(
|
||||||
|
{
|
||||||
|
giteaId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!giteaId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: branches,
|
||||||
|
fetchStatus,
|
||||||
|
status,
|
||||||
|
} = api.gitea.getGiteaBranches.useQuery(
|
||||||
|
{
|
||||||
|
owner: repository?.owner,
|
||||||
|
repositoryName: repository?.repo,
|
||||||
|
giteaId: giteaId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!repository?.owner && !!repository?.repo && !!giteaId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
form.reset({
|
||||||
|
branch: data.giteaBranch || "",
|
||||||
|
repository: {
|
||||||
|
repo: data.giteaRepository || "",
|
||||||
|
owner: data.giteaOwner || "",
|
||||||
|
},
|
||||||
|
buildPath: data.giteaBuildPath || "/",
|
||||||
|
giteaId: data.giteaId || "",
|
||||||
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules || false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [form.reset, data?.applicationId, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: GiteaProvider) => {
|
||||||
|
await mutateAsync({
|
||||||
|
giteaBranch: data.branch,
|
||||||
|
giteaRepository: data.repository.repo,
|
||||||
|
giteaOwner: data.repository.owner,
|
||||||
|
giteaBuildPath: data.buildPath,
|
||||||
|
giteaId: data.giteaId,
|
||||||
|
applicationId,
|
||||||
|
watchPaths: data.watchPaths,
|
||||||
|
enableSubmodules: data.enableSubmodules || false,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Service Provider Saved");
|
||||||
|
await refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error saving the Gitea provider");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="grid w-full gap-4 py-3"
|
||||||
|
>
|
||||||
|
{error && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="giteaId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2 flex flex-col">
|
||||||
|
<FormLabel>Gitea Account</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
form.setValue("repository", {
|
||||||
|
owner: "",
|
||||||
|
repo: "",
|
||||||
|
});
|
||||||
|
form.setValue("branch", "");
|
||||||
|
}}
|
||||||
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a Gitea Account" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{giteaProviders?.map((giteaProvider) => (
|
||||||
|
<SelectItem
|
||||||
|
key={giteaProvider.giteaId}
|
||||||
|
value={giteaProvider.giteaId}
|
||||||
|
>
|
||||||
|
{giteaProvider.gitProvider.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="repository"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel>Repository</FormLabel>
|
||||||
|
{field.value.owner && field.value.repo && (
|
||||||
|
<Link
|
||||||
|
href={`${giteaUrl}/${field.value.owner}/${field.value.repo}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
|
>
|
||||||
|
<GiteaIcon className="h-4 w-4" />
|
||||||
|
<span>View Repository</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between !bg-input",
|
||||||
|
!field.value && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoadingRepositories
|
||||||
|
? "Loading...."
|
||||||
|
: field.value.owner
|
||||||
|
? repositories?.find(
|
||||||
|
(repo: GiteaRepository) =>
|
||||||
|
repo.name === field.value.repo,
|
||||||
|
)?.name
|
||||||
|
: "Select repository"}
|
||||||
|
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search repository..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
{isLoadingRepositories && (
|
||||||
|
<span className="py-6 text-center text-sm">
|
||||||
|
Loading Repositories....
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
|
<ScrollArea className="h-96">
|
||||||
|
<CommandGroup>
|
||||||
|
{repositories && repositories.length === 0 && (
|
||||||
|
<CommandEmpty>
|
||||||
|
No repositories found.
|
||||||
|
</CommandEmpty>
|
||||||
|
)}
|
||||||
|
{repositories?.map((repo: GiteaRepository) => {
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
value={repo.name}
|
||||||
|
key={repo.url}
|
||||||
|
onSelect={() => {
|
||||||
|
form.setValue("repository", {
|
||||||
|
owner: repo.owner.username as string,
|
||||||
|
repo: repo.name,
|
||||||
|
});
|
||||||
|
form.setValue("branch", "");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>{repo.name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{repo.owner.username}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
repo.name === field.value.repo
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</ScrollArea>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{form.formState.errors.repository && (
|
||||||
|
<p className={cn("text-sm font-medium text-destructive")}>
|
||||||
|
Repository is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="branch"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="block w-full">
|
||||||
|
<FormLabel>Branch</FormLabel>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
" w-full justify-between !bg-input",
|
||||||
|
!field.value && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status === "loading" && fetchStatus === "fetching"
|
||||||
|
? "Loading...."
|
||||||
|
: field.value
|
||||||
|
? branches?.find(
|
||||||
|
(branch: GiteaBranch) =>
|
||||||
|
branch.name === field.value,
|
||||||
|
)?.name
|
||||||
|
: "Select branch"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search branch..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
{status === "loading" && fetchStatus === "fetching" && (
|
||||||
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Loading Branches....
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!repository?.owner && (
|
||||||
|
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Select a repository
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ScrollArea className="h-96">
|
||||||
|
<CommandEmpty>No branch found.</CommandEmpty>
|
||||||
|
|
||||||
|
<CommandGroup>
|
||||||
|
{branches && branches.length === 0 && (
|
||||||
|
<CommandItem>No branches found.</CommandItem>
|
||||||
|
)}
|
||||||
|
{branches?.map((branch: GiteaBranch) => (
|
||||||
|
<CommandItem
|
||||||
|
value={branch.name}
|
||||||
|
key={branch.commit.id}
|
||||||
|
onSelect={() => {
|
||||||
|
form.setValue("branch", branch.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{branch.name}
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
branch.name === field.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</ScrollArea>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</Popover>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="buildPath"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Build Path</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="/" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="watchPaths"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Add paths to watch for changes. When files in these
|
||||||
|
paths change, a new deployment will be triggered.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{field.value?.map((path: string, index: number) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{path}
|
||||||
|
<X
|
||||||
|
className="size-3 cursor-pointer hover:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
const newPaths = [...field.value];
|
||||||
|
newPaths.splice(index, 1);
|
||||||
|
field.onChange(newPaths);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const path = input.value.trim();
|
||||||
|
if (path) {
|
||||||
|
field.onChange([...field.value, path]);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
const input = document.querySelector(
|
||||||
|
'input[placeholder*="Enter a path"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const path = input.value.trim();
|
||||||
|
if (path) {
|
||||||
|
field.onChange([...field.value, path]);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableSubmodules"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-full justify-end">
|
||||||
|
<Button
|
||||||
|
isLoading={isSavingGiteaProvider}
|
||||||
|
type="submit"
|
||||||
|
className="w-fit"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -28,23 +30,22 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
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";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
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 Link from "next/link";
|
|
||||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
|
||||||
|
|
||||||
const GithubProviderSchema = z.object({
|
const GithubProviderSchema = z.object({
|
||||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||||
@@ -57,6 +58,8 @@ const GithubProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
githubId: z.string().min(1, "Github Provider is required"),
|
githubId: z.string().min(1, "Github Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GithubProvider = z.infer<typeof GithubProviderSchema>;
|
type GithubProvider = z.infer<typeof GithubProviderSchema>;
|
||||||
@@ -81,12 +84,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
},
|
},
|
||||||
githubId: "",
|
githubId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
|
triggerType: "push",
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GithubProviderSchema),
|
resolver: zodResolver(GithubProviderSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const repository = form.watch("repository");
|
const repository = form.watch("repository");
|
||||||
const githubId = form.watch("githubId");
|
const githubId = form.watch("githubId");
|
||||||
|
const triggerType = form.watch("triggerType");
|
||||||
|
|
||||||
const { data: repositories, isLoading: isLoadingRepositories } =
|
const { data: repositories, isLoading: isLoadingRepositories } =
|
||||||
api.github.getGithubRepositories.useQuery(
|
api.github.getGithubRepositories.useQuery(
|
||||||
@@ -124,9 +130,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
buildPath: data.buildPath || "/",
|
buildPath: data.buildPath || "/",
|
||||||
githubId: data.githubId || "",
|
githubId: data.githubId || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
triggerType: data.triggerType || "push",
|
||||||
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data?.applicationId, form]);
|
||||||
|
|
||||||
const onSubmit = async (data: GithubProvider) => {
|
const onSubmit = async (data: GithubProvider) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -137,6 +145,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
buildPath: data.buildPath,
|
buildPath: data.buildPath,
|
||||||
githubId: data.githubId,
|
githubId: data.githubId,
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
triggerType: data.triggerType,
|
||||||
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provided Saved");
|
toast.success("Service Provided Saved");
|
||||||
@@ -381,11 +391,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="watchPaths"
|
name="triggerType"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="md:col-span-2">
|
<FormItem className="md:col-span-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 ">
|
||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Trigger Type</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -393,68 +403,127 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
Add paths to watch for changes. When files in these
|
Choose when to trigger deployments: on push to the
|
||||||
paths change, a new deployment will be triggered.
|
selected branch or when a new tag is created.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<Select
|
||||||
{field.value?.map((path, index) => (
|
onValueChange={field.onChange}
|
||||||
<Badge
|
defaultValue={field.value}
|
||||||
key={index}
|
value={field.value}
|
||||||
variant="secondary"
|
>
|
||||||
className="flex items-center gap-1"
|
<FormControl>
|
||||||
>
|
<SelectTrigger>
|
||||||
{path}
|
<SelectValue placeholder="Select a trigger type" />
|
||||||
<X
|
</SelectTrigger>
|
||||||
className="size-3 cursor-pointer hover:text-destructive"
|
</FormControl>
|
||||||
onClick={() => {
|
<SelectContent>
|
||||||
const newPaths = [...(field.value || [])];
|
<SelectItem value="push">On Push</SelectItem>
|
||||||
newPaths.splice(index, 1);
|
<SelectItem value="tag">On Tag</SelectItem>
|
||||||
field.onChange(newPaths);
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{triggerType === "push" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="watchPaths"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Add paths to watch for changes. When files in
|
||||||
|
these paths change, a new deployment will be
|
||||||
|
triggered.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{field.value?.map((path, index) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{path}
|
||||||
|
<X
|
||||||
|
className="size-3 cursor-pointer hover:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
const newPaths = [...(field.value || [])];
|
||||||
|
newPaths.splice(index, 1);
|
||||||
|
field.onChange(newPaths);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const path = input.value.trim();
|
||||||
|
if (path) {
|
||||||
|
field.onChange([...(field.value || []), path]);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Badge>
|
</FormControl>
|
||||||
))}
|
<Button
|
||||||
</div>
|
type="button"
|
||||||
<div className="flex gap-2">
|
variant="outline"
|
||||||
<FormControl>
|
size="icon"
|
||||||
<Input
|
onClick={() => {
|
||||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
const input = document.querySelector(
|
||||||
onKeyDown={(e) => {
|
'input[placeholder*="Enter a path"]',
|
||||||
if (e.key === "Enter") {
|
) as HTMLInputElement;
|
||||||
e.preventDefault();
|
const path = input.value.trim();
|
||||||
const input = e.currentTarget;
|
if (path) {
|
||||||
const path = input.value.trim();
|
field.onChange([...(field.value || []), path]);
|
||||||
if (path) {
|
input.value = "";
|
||||||
field.onChange([...(field.value || []), path]);
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</FormControl>
|
<Plus className="size-4" />
|
||||||
<Button
|
</Button>
|
||||||
type="button"
|
</div>
|
||||||
variant="outline"
|
<FormMessage />
|
||||||
size="icon"
|
</FormItem>
|
||||||
onClick={() => {
|
)}
|
||||||
const input = document.querySelector(
|
/>
|
||||||
'input[placeholder*="Enter a path"]',
|
)}
|
||||||
) as HTMLInputElement;
|
|
||||||
const path = input.value.trim();
|
<FormField
|
||||||
if (path) {
|
control={form.control}
|
||||||
field.onChange([...(field.value || []), path]);
|
name="enableSubmodules"
|
||||||
input.value = "";
|
render={({ field }) => (
|
||||||
}
|
<FormItem className="flex items-center space-x-2">
|
||||||
}}
|
<FormControl>
|
||||||
>
|
<Switch
|
||||||
<Plus className="size-4" />
|
checked={field.value}
|
||||||
</Button>
|
onCheckedChange={field.onChange}
|
||||||
</div>
|
/>
|
||||||
<FormMessage />
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -29,23 +31,22 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
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";
|
||||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
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 Link from "next/link";
|
|
||||||
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
|
||||||
|
|
||||||
const GitlabProviderSchema = z.object({
|
const GitlabProviderSchema = z.object({
|
||||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||||
@@ -60,6 +61,7 @@ const GitlabProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
|
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
|
||||||
@@ -86,6 +88,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
},
|
},
|
||||||
gitlabId: "",
|
gitlabId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GitlabProviderSchema),
|
resolver: zodResolver(GitlabProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -135,9 +138,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
buildPath: data.gitlabBuildPath || "/",
|
buildPath: data.gitlabBuildPath || "/",
|
||||||
gitlabId: data.gitlabId || "",
|
gitlabId: data.gitlabId || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data?.applicationId, form]);
|
||||||
|
|
||||||
const onSubmit = async (data: GitlabProvider) => {
|
const onSubmit = async (data: GitlabProvider) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -150,6 +154,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
gitlabProjectId: data.repository.id,
|
gitlabProjectId: data.repository.id,
|
||||||
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provided Saved");
|
toast.success("Service Provided Saved");
|
||||||
@@ -447,7 +452,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -483,6 +488,21 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableSubmodules"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,37 +1,135 @@
|
|||||||
import { SaveDockerProvider } from "@/components/dashboard/application/general/generic/save-docker-provider";
|
import { SaveDockerProvider } from "@/components/dashboard/application/general/generic/save-docker-provider";
|
||||||
import { SaveGitProvider } from "@/components/dashboard/application/general/generic/save-git-provider";
|
import { SaveGitProvider } from "@/components/dashboard/application/general/generic/save-git-provider";
|
||||||
|
import { SaveGiteaProvider } from "@/components/dashboard/application/general/generic/save-gitea-provider";
|
||||||
import { SaveGithubProvider } from "@/components/dashboard/application/general/generic/save-github-provider";
|
import { SaveGithubProvider } from "@/components/dashboard/application/general/generic/save-github-provider";
|
||||||
import {
|
import {
|
||||||
BitbucketIcon,
|
BitbucketIcon,
|
||||||
DockerIcon,
|
DockerIcon,
|
||||||
GitIcon,
|
GitIcon,
|
||||||
|
GiteaIcon,
|
||||||
GithubIcon,
|
GithubIcon,
|
||||||
GitlabIcon,
|
GitlabIcon,
|
||||||
} from "@/components/icons/data-tools-icons";
|
} from "@/components/icons/data-tools-icons";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { GitBranch, UploadCloud } from "lucide-react";
|
import { GitBranch, Loader2, UploadCloud } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { SaveBitbucketProvider } from "./save-bitbucket-provider";
|
import { SaveBitbucketProvider } from "./save-bitbucket-provider";
|
||||||
import { SaveDragNDrop } from "./save-drag-n-drop";
|
import { SaveDragNDrop } from "./save-drag-n-drop";
|
||||||
import { SaveGitlabProvider } from "./save-gitlab-provider";
|
import { SaveGitlabProvider } from "./save-gitlab-provider";
|
||||||
|
import { UnauthorizedGitProvider } from "./unauthorized-git-provider";
|
||||||
|
|
||||||
type TabState = "github" | "docker" | "git" | "drop" | "gitlab" | "bitbucket";
|
type TabState =
|
||||||
|
| "github"
|
||||||
|
| "docker"
|
||||||
|
| "git"
|
||||||
|
| "drop"
|
||||||
|
| "gitlab"
|
||||||
|
| "bitbucket"
|
||||||
|
| "gitea";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowProviderForm = ({ applicationId }: Props) => {
|
export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||||
const { data: githubProviders } = api.github.githubProviders.useQuery();
|
const { data: githubProviders, isLoading: isLoadingGithub } =
|
||||||
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
|
api.github.githubProviders.useQuery();
|
||||||
const { data: bitbucketProviders } =
|
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
|
||||||
|
api.gitlab.gitlabProviders.useQuery();
|
||||||
|
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
|
||||||
api.bitbucket.bitbucketProviders.useQuery();
|
api.bitbucket.bitbucketProviders.useQuery();
|
||||||
|
const { data: giteaProviders, isLoading: isLoadingGitea } =
|
||||||
|
api.gitea.giteaProviders.useQuery();
|
||||||
|
|
||||||
|
const { data: application, refetch } = api.application.one.useQuery({
|
||||||
|
applicationId,
|
||||||
|
});
|
||||||
|
const { mutateAsync: disconnectGitProvider } =
|
||||||
|
api.application.disconnectGitProvider.useMutation();
|
||||||
|
|
||||||
const { data: application } = api.application.one.useQuery({ applicationId });
|
|
||||||
const [tab, setSab] = useState<TabState>(application?.sourceType || "github");
|
const [tab, setSab] = useState<TabState>(application?.sourceType || "github");
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea;
|
||||||
|
|
||||||
|
const handleDisconnect = async () => {
|
||||||
|
try {
|
||||||
|
await disconnectGitProvider({ applicationId });
|
||||||
|
toast.success("Repository disconnected successfully");
|
||||||
|
await refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
`Failed to disconnect repository: ${
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="group relative w-full bg-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-start justify-between">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="flex flex-col space-y-0.5">Provider</span>
|
||||||
|
<p className="flex items-center text-sm font-normal text-muted-foreground">
|
||||||
|
Select the source of your code
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden space-y-1 text-sm font-normal md:block">
|
||||||
|
<GitBranch className="size-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex min-h-[25vh] items-center justify-center">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
<span>Loading providers...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user doesn't have access to the current git provider
|
||||||
|
if (
|
||||||
|
application &&
|
||||||
|
!application.hasGitProviderAccess &&
|
||||||
|
application.sourceType !== "docker" &&
|
||||||
|
application.sourceType !== "drop"
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Card className="group relative w-full bg-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-start justify-between">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="flex flex-col space-y-0.5">Provider</span>
|
||||||
|
<p className="flex items-center text-sm font-normal text-muted-foreground">
|
||||||
|
Repository connection through unauthorized provider
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden space-y-1 text-sm font-normal md:block">
|
||||||
|
<GitBranch className="size-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<UnauthorizedGitProvider
|
||||||
|
service={application}
|
||||||
|
onDisconnect={handleDisconnect}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="group relative w-full bg-transparent">
|
<Card className="group relative w-full bg-transparent">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -55,7 +153,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
|||||||
setSab(e as TabState);
|
setSab(e as TabState);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
<div className="flex flex-row items-center justify-between w-full gap-4">
|
||||||
<TabsList className="md:grid md:w-fit md:grid-cols-7 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
|
<TabsList className="md:grid md:w-fit md:grid-cols-7 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="github"
|
value="github"
|
||||||
@@ -78,6 +176,13 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
|||||||
<BitbucketIcon className="size-4 text-current fill-current" />
|
<BitbucketIcon className="size-4 text-current fill-current" />
|
||||||
Bitbucket
|
Bitbucket
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="gitea"
|
||||||
|
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||||
|
>
|
||||||
|
<GiteaIcon className="size-4 text-current fill-current" />
|
||||||
|
Gitea
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="docker"
|
value="docker"
|
||||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||||
@@ -106,7 +211,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
|||||||
{githubProviders && githubProviders?.length > 0 ? (
|
{githubProviders && githubProviders?.length > 0 ? (
|
||||||
<SaveGithubProvider applicationId={applicationId} />
|
<SaveGithubProvider applicationId={applicationId} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
<GithubIcon className="size-8 text-muted-foreground" />
|
<GithubIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To deploy using GitHub, you need to configure your account
|
To deploy using GitHub, you need to configure your account
|
||||||
@@ -126,7 +231,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
|||||||
{gitlabProviders && gitlabProviders?.length > 0 ? (
|
{gitlabProviders && gitlabProviders?.length > 0 ? (
|
||||||
<SaveGitlabProvider applicationId={applicationId} />
|
<SaveGitlabProvider applicationId={applicationId} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
<GitlabIcon className="size-8 text-muted-foreground" />
|
<GitlabIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To deploy using GitLab, you need to configure your account
|
To deploy using GitLab, you need to configure your account
|
||||||
@@ -146,7 +251,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
|||||||
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
|
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
|
||||||
<SaveBitbucketProvider applicationId={applicationId} />
|
<SaveBitbucketProvider applicationId={applicationId} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
<BitbucketIcon className="size-8 text-muted-foreground" />
|
<BitbucketIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To deploy using Bitbucket, you need to configure your account
|
To deploy using Bitbucket, you need to configure your account
|
||||||
@@ -162,6 +267,26 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="gitea" className="w-full p-2">
|
||||||
|
{giteaProviders && giteaProviders?.length > 0 ? (
|
||||||
|
<SaveGiteaProvider applicationId={applicationId} />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
|
<GiteaIcon className="size-8 text-muted-foreground" />
|
||||||
|
<span className="text-base text-muted-foreground">
|
||||||
|
To deploy using Gitea, you need to configure your account
|
||||||
|
first. Please, go to{" "}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/git-providers"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>{" "}
|
||||||
|
to do so.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="docker" className="w-full p-2">
|
<TabsContent value="docker" className="w-full p-2">
|
||||||
<SaveDockerProvider applicationId={applicationId} />
|
<SaveDockerProvider applicationId={applicationId} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import {
|
||||||
|
BitbucketIcon,
|
||||||
|
GitIcon,
|
||||||
|
GiteaIcon,
|
||||||
|
GithubIcon,
|
||||||
|
GitlabIcon,
|
||||||
|
} from "@/components/icons/data-tools-icons";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import type { RouterOutputs } from "@/utils/api";
|
||||||
|
import { AlertCircle, GitBranch, Unlink } from "lucide-react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
service:
|
||||||
|
| RouterOutputs["application"]["one"]
|
||||||
|
| RouterOutputs["compose"]["one"];
|
||||||
|
onDisconnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnauthorizedGitProvider = ({ service, onDisconnect }: Props) => {
|
||||||
|
const getProviderIcon = (sourceType: string) => {
|
||||||
|
switch (sourceType) {
|
||||||
|
case "github":
|
||||||
|
return <GithubIcon className="size-5 text-muted-foreground" />;
|
||||||
|
case "gitlab":
|
||||||
|
return <GitlabIcon className="size-5 text-muted-foreground" />;
|
||||||
|
case "bitbucket":
|
||||||
|
return <BitbucketIcon className="size-5 text-muted-foreground" />;
|
||||||
|
case "gitea":
|
||||||
|
return <GiteaIcon className="size-5 text-muted-foreground" />;
|
||||||
|
case "git":
|
||||||
|
return <GitIcon className="size-5 text-muted-foreground" />;
|
||||||
|
default:
|
||||||
|
return <GitBranch className="size-5 text-muted-foreground" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRepositoryInfo = () => {
|
||||||
|
switch (service.sourceType) {
|
||||||
|
case "github":
|
||||||
|
return {
|
||||||
|
repo: service.repository,
|
||||||
|
branch: service.branch,
|
||||||
|
owner: service.owner,
|
||||||
|
};
|
||||||
|
case "gitlab":
|
||||||
|
return {
|
||||||
|
repo: service.gitlabRepository,
|
||||||
|
branch: service.gitlabBranch,
|
||||||
|
owner: service.gitlabOwner,
|
||||||
|
};
|
||||||
|
case "bitbucket":
|
||||||
|
return {
|
||||||
|
repo: service.bitbucketRepository,
|
||||||
|
branch: service.bitbucketBranch,
|
||||||
|
owner: service.bitbucketOwner,
|
||||||
|
};
|
||||||
|
case "gitea":
|
||||||
|
return {
|
||||||
|
repo: service.giteaRepository,
|
||||||
|
branch: service.giteaBranch,
|
||||||
|
owner: service.giteaOwner,
|
||||||
|
};
|
||||||
|
case "git":
|
||||||
|
return {
|
||||||
|
repo: service.customGitUrl,
|
||||||
|
branch: service.customGitBranch,
|
||||||
|
owner: null,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return { repo: null, branch: null, owner: null };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { repo, branch, owner } = getRepositoryInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
This application is connected to a {service.sourceType} repository
|
||||||
|
through a git provider that you don't have access to. You can see
|
||||||
|
basic repository information below, but cannot modify the
|
||||||
|
configuration.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Card className="border-dashed border-2 border-muted-foreground/20 bg-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
{getProviderIcon(service.sourceType)}
|
||||||
|
<span className="capitalize text-sm font-medium">
|
||||||
|
{service.sourceType} Repository
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{owner && (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Owner:
|
||||||
|
</span>
|
||||||
|
<p className="text-sm">{owner}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{repo && (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Repository:
|
||||||
|
</span>
|
||||||
|
<p className="text-sm">{repo}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{branch && (
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Branch:
|
||||||
|
</span>
|
||||||
|
<p className="text-sm">{branch}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<DialogAction
|
||||||
|
title="Disconnect Repository"
|
||||||
|
description="Are you sure you want to disconnect this repository?"
|
||||||
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
onDisconnect();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="secondary" className="w-full">
|
||||||
|
<Unlink className="size-4 mr-2" />
|
||||||
|
Disconnect Repository
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Disconnecting will allow you to configure a new repository with
|
||||||
|
your own git providers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
|
||||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
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<
|
|
||||||
RouterOutputs["deployment"]["all"][number] | 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);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
<ShowDeployment
|
|
||||||
serverId={serverId || ""}
|
|
||||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
|
||||||
onClose={() => setActiveLog(null)}
|
|
||||||
logPath={activeLog?.logPath || ""}
|
|
||||||
errorMessage={activeLog?.errorMessage || ""}
|
|
||||||
/>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -17,15 +17,15 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
Layers,
|
Loader2,
|
||||||
PenSquare,
|
PenSquare,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
||||||
|
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
||||||
import { AddPreviewDomain } from "./add-preview-domain";
|
import { AddPreviewDomain } from "./add-preview-domain";
|
||||||
import { ShowPreviewBuilds } from "./show-preview-builds";
|
|
||||||
import { ShowPreviewSettings } from "./show-preview-settings";
|
import { ShowPreviewSettings } from "./show-preview-settings";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -38,13 +38,16 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
||||||
api.previewDeployment.delete.useMutation();
|
api.previewDeployment.delete.useMutation();
|
||||||
|
|
||||||
const { data: previewDeployments, refetch: refetchPreviewDeployments } =
|
const {
|
||||||
api.previewDeployment.all.useQuery(
|
data: previewDeployments,
|
||||||
{ applicationId },
|
refetch: refetchPreviewDeployments,
|
||||||
{
|
isLoading: isLoadingPreviewDeployments,
|
||||||
enabled: !!applicationId,
|
} = api.previewDeployment.all.useQuery(
|
||||||
},
|
{ applicationId },
|
||||||
);
|
{
|
||||||
|
enabled: !!applicationId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const handleDeletePreviewDeployment = async (previewDeploymentId: string) => {
|
const handleDeletePreviewDeployment = async (previewDeploymentId: string) => {
|
||||||
deletePreviewDeployment({
|
deletePreviewDeployment({
|
||||||
@@ -80,8 +83,15 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
each pull request you create.
|
each pull request you create.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!previewDeployments?.length ? (
|
{isLoadingPreviewDeployments ? (
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
<div className="flex w-full flex-row items-center justify-center gap-3 min-h-[35vh]">
|
||||||
|
<Loader2 className="size-5 text-muted-foreground animate-spin" />
|
||||||
|
<span className="text-base text-muted-foreground">
|
||||||
|
Loading preview deployments...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : !previewDeployments?.length ? (
|
||||||
|
<div className="flex w-full flex-col items-center justify-center gap-3 min-h-[35vh]">
|
||||||
<RocketIcon className="size-8 text-muted-foreground" />
|
<RocketIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
No preview deployments found
|
No preview deployments found
|
||||||
@@ -168,19 +178,10 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</ShowModalLogs>
|
</ShowModalLogs>
|
||||||
|
|
||||||
<ShowPreviewBuilds
|
<ShowDeploymentsModal
|
||||||
deployments={deployment.deployments || []}
|
id={deployment.previewDeploymentId}
|
||||||
|
type="previewDeployment"
|
||||||
serverId={data?.serverId || ""}
|
serverId={data?.serverId || ""}
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<Layers className="size-4" />
|
|
||||||
Builds
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddPreviewDomain
|
<AddPreviewDomain
|
||||||
|
|||||||
@@ -298,7 +298,11 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
refetch();
|
refetch();
|
||||||
toast.success("Preview deployments enabled");
|
toast.success(
|
||||||
|
checked
|
||||||
|
? "Preview deployments enabled"
|
||||||
|
: "Preview deployments disabled",
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
rollbackActive: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
applicationId: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const { data: application, refetch } = api.application.one.useQuery(
|
||||||
|
{
|
||||||
|
applicationId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!applicationId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: updateApplication, isLoading } =
|
||||||
|
api.application.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
rollbackActive: application?.rollbackActive ?? false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormValues) => {
|
||||||
|
await updateApplication({
|
||||||
|
applicationId,
|
||||||
|
rollbackActive: data.rollbackActive,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("Rollback settings updated");
|
||||||
|
setIsOpen(false);
|
||||||
|
refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Failed to update rollback settings");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Rollback Settings</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure how rollbacks work for this application
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="rollbackActive"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel className="text-base">
|
||||||
|
Enable Rollbacks
|
||||||
|
</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Allow rolling back to previous deployments
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||||
|
Save Settings
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
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 {
|
||||||
|
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 { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
DatabaseZap,
|
||||||
|
Info,
|
||||||
|
PenBoxIcon,
|
||||||
|
PlusCircle,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { CacheType } from "../domains/handle-domain";
|
||||||
|
|
||||||
|
export const commonCronExpressions = [
|
||||||
|
{ label: "Every minute", value: "* * * * *" },
|
||||||
|
{ label: "Every hour", value: "0 * * * *" },
|
||||||
|
{ label: "Every day at midnight", value: "0 0 * * *" },
|
||||||
|
{ label: "Every Sunday at midnight", value: "0 0 * * 0" },
|
||||||
|
{ label: "Every month on the 1st at midnight", value: "0 0 1 * *" },
|
||||||
|
{ label: "Every 15 minutes", value: "*/15 * * * *" },
|
||||||
|
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const formSchema = z
|
||||||
|
.object({
|
||||||
|
name: z.string().min(1, "Name is required"),
|
||||||
|
cronExpression: z.string().min(1, "Cron expression is required"),
|
||||||
|
shellType: z.enum(["bash", "sh"]).default("bash"),
|
||||||
|
command: z.string(),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
serviceName: z.string(),
|
||||||
|
scheduleType: z.enum([
|
||||||
|
"application",
|
||||||
|
"compose",
|
||||||
|
"server",
|
||||||
|
"dokploy-server",
|
||||||
|
]),
|
||||||
|
script: z.string(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.scheduleType === "compose" && !data.serviceName) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Service name is required",
|
||||||
|
path: ["serviceName"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(data.scheduleType === "dokploy-server" ||
|
||||||
|
data.scheduleType === "server") &&
|
||||||
|
!data.script
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Script is required",
|
||||||
|
path: ["script"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(data.scheduleType === "application" ||
|
||||||
|
data.scheduleType === "compose") &&
|
||||||
|
!data.command
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Command is required",
|
||||||
|
path: ["command"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
scheduleId?: string;
|
||||||
|
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
cronExpression: "",
|
||||||
|
shellType: "bash",
|
||||||
|
command: "",
|
||||||
|
enabled: true,
|
||||||
|
serviceName: "",
|
||||||
|
scheduleType: scheduleType || "application",
|
||||||
|
script: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduleTypeForm = form.watch("scheduleType");
|
||||||
|
|
||||||
|
const { data: schedule } = api.schedule.one.useQuery(
|
||||||
|
{ scheduleId: scheduleId || "" },
|
||||||
|
{ enabled: !!scheduleId },
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: services,
|
||||||
|
isFetching: isLoadingServices,
|
||||||
|
error: errorServices,
|
||||||
|
refetch: refetchServices,
|
||||||
|
} = api.compose.loadServices.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id || "",
|
||||||
|
type: cacheType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
enabled: !!id && scheduleType === "compose",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scheduleId && schedule) {
|
||||||
|
form.reset({
|
||||||
|
name: schedule.name,
|
||||||
|
cronExpression: schedule.cronExpression,
|
||||||
|
shellType: schedule.shellType,
|
||||||
|
command: schedule.command,
|
||||||
|
enabled: schedule.enabled,
|
||||||
|
serviceName: schedule.serviceName || "",
|
||||||
|
scheduleType: schedule.scheduleType,
|
||||||
|
script: schedule.script || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [form, schedule, scheduleId]);
|
||||||
|
|
||||||
|
const { mutateAsync, isLoading } = scheduleId
|
||||||
|
? api.schedule.update.useMutation()
|
||||||
|
: api.schedule.create.useMutation();
|
||||||
|
|
||||||
|
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||||
|
if (!id && !scheduleId) return;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
...values,
|
||||||
|
scheduleId: scheduleId || "",
|
||||||
|
...(scheduleType === "application" && {
|
||||||
|
applicationId: id || "",
|
||||||
|
}),
|
||||||
|
...(scheduleType === "compose" && {
|
||||||
|
composeId: id || "",
|
||||||
|
}),
|
||||||
|
...(scheduleType === "server" && {
|
||||||
|
serverId: id || "",
|
||||||
|
}),
|
||||||
|
...(scheduleType === "dokploy-server" && {
|
||||||
|
userId: id || "",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
`Schedule ${scheduleId ? "updated" : "created"} successfully`,
|
||||||
|
);
|
||||||
|
utils.schedule.list.invalidate({
|
||||||
|
id,
|
||||||
|
scheduleType,
|
||||||
|
});
|
||||||
|
setIsOpen(false);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "An unknown error occurred",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{scheduleId ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10"
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button>
|
||||||
|
<PlusCircle className="w-4 h-4 mr-2" />
|
||||||
|
Add Schedule
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"max-h-screen overflow-y-auto",
|
||||||
|
scheduleTypeForm === "dokploy-server" || scheduleTypeForm === "server"
|
||||||
|
? "max-h-[95vh] sm:max-w-2xl"
|
||||||
|
: " sm:max-w-lg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{scheduleId ? "Edit" : "Create"} Schedule</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
{scheduleTypeForm === "compose" && (
|
||||||
|
<div className="flex flex-col w-full gap-4">
|
||||||
|
{errorServices && (
|
||||||
|
<AlertBlock
|
||||||
|
type="warning"
|
||||||
|
className="[overflow-wrap:anywhere]"
|
||||||
|
>
|
||||||
|
{errorServices?.message}
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serviceName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Service Name</FormLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value || ""}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a service name" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{services?.map((service, index) => (
|
||||||
|
<SelectItem
|
||||||
|
value={service}
|
||||||
|
key={`${service}-${index}`}
|
||||||
|
>
|
||||||
|
{service}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="none" disabled>
|
||||||
|
Empty
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "fetch") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("fetch");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Fetch: Will clone the repository and load the
|
||||||
|
services
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "cache") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("cache");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Cache: If you previously deployed this compose,
|
||||||
|
it will read the services from the last
|
||||||
|
deployment/fetch from the repository
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
Task Name
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Daily Database Backup" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
A descriptive name for your scheduled task
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="cronExpression"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
Schedule
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Cron expression format: minute hour day month
|
||||||
|
weekday
|
||||||
|
</p>
|
||||||
|
<p>Example: 0 0 * * * (daily at midnight)</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</FormLabel>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a predefined schedule" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{commonCronExpressions.map((expr) => (
|
||||||
|
<SelectItem key={expr.value} value={expr.value}>
|
||||||
|
{expr.label} ({expr.value})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="relative">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
Choose a predefined schedule or enter a custom cron
|
||||||
|
expression
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(scheduleTypeForm === "application" ||
|
||||||
|
scheduleTypeForm === "compose") && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="shellType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
Shell Type
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select shell type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="bash">Bash</SelectItem>
|
||||||
|
<SelectItem value="sh">Sh</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Choose the shell to execute your command
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="command"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
Command
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="npm run backup" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
The command to execute in your container
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(scheduleTypeForm === "dokploy-server" ||
|
||||||
|
scheduleTypeForm === "server") && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="script"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Script</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<FormControl>
|
||||||
|
<CodeEditor
|
||||||
|
language="shell"
|
||||||
|
placeholder={`# This is a comment
|
||||||
|
echo "Hello, world!"
|
||||||
|
`}
|
||||||
|
className="h-96 font-mono"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
Enabled
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="submit" isLoading={isLoading} className="w-full">
|
||||||
|
{scheduleId ? "Update" : "Create"} Schedule
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import {
|
||||||
|
ClipboardList,
|
||||||
|
Clock,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
Terminal,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
||||||
|
import { HandleSchedules } from "./handle-schedules";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||||
|
const {
|
||||||
|
data: schedules,
|
||||||
|
isLoading: isLoadingSchedules,
|
||||||
|
refetch: refetchSchedules,
|
||||||
|
} = api.schedule.list.useQuery(
|
||||||
|
{
|
||||||
|
id: id || "",
|
||||||
|
scheduleType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
|
||||||
|
api.schedule.delete.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: runManually, isLoading } =
|
||||||
|
api.schedule.runManually.useMutation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||||
|
<CardHeader className="px-0">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||||
|
Scheduled Tasks
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Schedule tasks to run automatically at specified intervals.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{schedules && schedules.length > 0 && (
|
||||||
|
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-0">
|
||||||
|
{isLoadingSchedules ? (
|
||||||
|
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||||
|
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||||
|
<span className="text-sm text-muted-foreground/70">
|
||||||
|
Loading scheduled tasks...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : schedules && schedules.length > 0 ? (
|
||||||
|
<div className="grid xl:grid-cols-2 gap-4 grid-cols-1 h-full">
|
||||||
|
{schedules.map((schedule) => {
|
||||||
|
const serverId =
|
||||||
|
schedule.serverId ||
|
||||||
|
schedule.application?.serverId ||
|
||||||
|
schedule.compose?.serverId;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={schedule.scheduleId}
|
||||||
|
className=" flex items-center justify-between rounded-lg border p-3 transition-colors bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
||||||
|
<Clock className="size-4 text-primary/70" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-medium leading-none">
|
||||||
|
{schedule.name}
|
||||||
|
</h3>
|
||||||
|
<Badge
|
||||||
|
variant={schedule.enabled ? "default" : "secondary"}
|
||||||
|
className="text-[10px] px-1 py-0"
|
||||||
|
>
|
||||||
|
{schedule.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="font-mono text-[10px] bg-transparent"
|
||||||
|
>
|
||||||
|
Cron: {schedule.cronExpression}
|
||||||
|
</Badge>
|
||||||
|
{schedule.scheduleType !== "server" &&
|
||||||
|
schedule.scheduleType !== "dokploy-server" && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-muted-foreground/50">
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="font-mono text-[10px] bg-transparent"
|
||||||
|
>
|
||||||
|
{schedule.shellType}
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{schedule.command && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Terminal className="size-3.5 text-muted-foreground/70" />
|
||||||
|
<code className="font-mono text-[10px] text-muted-foreground/70">
|
||||||
|
{schedule.command}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ShowDeploymentsModal
|
||||||
|
id={schedule.scheduleId}
|
||||||
|
type="schedule"
|
||||||
|
serverId={serverId || undefined}
|
||||||
|
>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ClipboardList className="size-4 transition-colors " />
|
||||||
|
</Button>
|
||||||
|
</ShowDeploymentsModal>
|
||||||
|
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={async () => {
|
||||||
|
toast.success("Schedule run successfully");
|
||||||
|
|
||||||
|
await runManually({
|
||||||
|
scheduleId: schedule.scheduleId,
|
||||||
|
}).then(async () => {
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(resolve, 1500),
|
||||||
|
);
|
||||||
|
refetchSchedules();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="size-4 transition-colors" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Run Manual Schedule</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<HandleSchedules
|
||||||
|
scheduleId={schedule.scheduleId}
|
||||||
|
id={id}
|
||||||
|
scheduleType={scheduleType}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Schedule"
|
||||||
|
description="Are you sure you want to delete this schedule?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteSchedule({
|
||||||
|
scheduleId: schedule.scheduleId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
utils.schedule.list.invalidate({
|
||||||
|
id,
|
||||||
|
scheduleType,
|
||||||
|
});
|
||||||
|
toast.success("Schedule deleted successfully");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting schedule");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10 "
|
||||||
|
isLoading={isDeleting}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
|
||||||
|
<Clock className="size-8 mb-4 text-muted-foreground" />
|
||||||
|
<p className="text-lg font-medium text-muted-foreground">
|
||||||
|
No scheduled tasks
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Create your first scheduled task to automate your workflows
|
||||||
|
</p>
|
||||||
|
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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 { Paintbrush } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
composeId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CancelQueuesCompose = ({ composeId }: Props) => {
|
|
||||||
const { mutateAsync, isLoading } = api.compose.cleanQueues.useMutation();
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
|
||||||
|
|
||||||
if (isCloud) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
|
|
||||||
Cancel Queues
|
|
||||||
<Paintbrush className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>
|
|
||||||
Are you sure to cancel the incoming deployments?
|
|
||||||
</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will cancel all the incoming deployments
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={async () => {
|
|
||||||
await mutateAsync({
|
|
||||||
composeId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("Queues are being cleaned");
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { RefreshCcw } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
composeId: string;
|
|
||||||
}
|
|
||||||
export const RefreshTokenCompose = ({ composeId }: Props) => {
|
|
||||||
const { mutateAsync } = api.compose.refreshToken.useMutation();
|
|
||||||
const utils = api.useUtils();
|
|
||||||
return (
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger>
|
|
||||||
<RefreshCcw className="h-4 w-4 cursor-pointer text-muted-foreground" />
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This action cannot be undone. This will permanently change the token
|
|
||||||
and all the previous tokens will be invalidated
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={async () => {
|
|
||||||
await mutateAsync({
|
|
||||||
composeId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
utils.compose.one.invalidate({
|
|
||||||
composeId,
|
|
||||||
});
|
|
||||||
toast.success("Refresh Token updated");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error updating the refresh token");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { TerminalLine } from "../../docker/logs/terminal-line";
|
|
||||||
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
logPath: string | null;
|
|
||||||
serverId?: string;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
errorMessage?: string;
|
|
||||||
}
|
|
||||||
export const ShowDeploymentCompose = ({
|
|
||||||
logPath,
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
serverId,
|
|
||||||
errorMessage,
|
|
||||||
}: Props) => {
|
|
||||||
const [data, setData] = useState("");
|
|
||||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
|
||||||
const [showExtraLogs, setShowExtraLogs] = useState(false);
|
|
||||||
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(() => {
|
|
||||||
if (!open || !logPath) return;
|
|
||||||
|
|
||||||
setData("");
|
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
||||||
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/listen-deployment?logPath=${logPath}&serverId=${serverId}`;
|
|
||||||
const ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
wsRef.current = ws; // Store WebSocket instance in ref
|
|
||||||
|
|
||||||
ws.onmessage = (e) => {
|
|
||||||
setData((currentData) => currentData + e.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
|
||||||
console.error("WebSocket error: ", error);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
wsRef.current = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
||||||
ws.close();
|
|
||||||
wsRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [logPath, open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
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(() => {
|
|
||||||
scrollToBottom();
|
|
||||||
|
|
||||||
if (autoScroll && scrollRef.current) {
|
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [filteredLogs, autoScroll]);
|
|
||||||
|
|
||||||
const optionalErrors = parseLogs(errorMessage || "");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(e) => {
|
|
||||||
onClose();
|
|
||||||
if (!e) {
|
|
||||||
setData("");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wsRef.current) {
|
|
||||||
if (wsRef.current.readyState === WebSocket.OPEN) {
|
|
||||||
wsRef.current.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className={"sm:max-w-5xl max-h-screen"}>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Deployment</DialogTitle>
|
|
||||||
<DialogDescription className="flex items-center gap-2">
|
|
||||||
<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>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<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((log: LogLine, index: number) => (
|
|
||||||
<TerminalLine key={index} log={log} noTimestamp />
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{optionalErrors.length > 0 ? (
|
|
||||||
optionalErrors.map((log: LogLine, index: number) => (
|
|
||||||
<TerminalLine key={`extra-${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>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
|
||||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { type RouterOutputs, api } from "@/utils/api";
|
|
||||||
import { RocketIcon } from "lucide-react";
|
|
||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import { CancelQueuesCompose } from "./cancel-queues-compose";
|
|
||||||
import { RefreshTokenCompose } from "./refresh-token-compose";
|
|
||||||
import { ShowDeploymentCompose } from "./show-deployment-compose";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
composeId: string;
|
|
||||||
}
|
|
||||||
export const ShowDeploymentsCompose = ({ composeId }: Props) => {
|
|
||||||
const [activeLog, setActiveLog] = useState<
|
|
||||||
RouterOutputs["deployment"]["all"][number] | null
|
|
||||||
>(null);
|
|
||||||
const { data } = api.compose.one.useQuery({ composeId });
|
|
||||||
const { data: deployments } = api.deployment.allByCompose.useQuery(
|
|
||||||
{ composeId },
|
|
||||||
{
|
|
||||||
enabled: !!composeId,
|
|
||||||
refetchInterval: 5000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const [url, setUrl] = React.useState("");
|
|
||||||
useEffect(() => {
|
|
||||||
setUrl(document.location.origin);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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">Deployments</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
See all the 10 last deployments for this compose
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<CancelQueuesCompose composeId={composeId} />
|
|
||||||
{/* <CancelQueues applicationId={applicationId} /> */}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-2 text-sm">
|
|
||||||
<span>
|
|
||||||
If you want to re-deploy this application use this URL in the config
|
|
||||||
of your git provider or docker
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
|
||||||
<span>Webhook URL: </span>
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{`${url}/api/deploy/compose/${data?.refreshToken}`}
|
|
||||||
</span>
|
|
||||||
<RefreshTokenCompose composeId={composeId} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{data?.deployments?.length === 0 ? (
|
|
||||||
<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 deployments found
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{deployments?.map((deployment) => (
|
|
||||||
<div
|
|
||||||
key={deployment.deploymentId}
|
|
||||||
className="flex items-center justify-between rounded-lg border p-4"
|
|
||||||
>
|
|
||||||
<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="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);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ShowDeploymentCompose
|
|
||||||
serverId={data?.serverId || ""}
|
|
||||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
|
||||||
onClose={() => setActiveLog(null)}
|
|
||||||
logPath={activeLog?.logPath || ""}
|
|
||||||
errorMessage={activeLog?.errorMessage || ""}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,503 +0,0 @@
|
|||||||
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 { api } from "@/utils/api";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { domainCompose } from "@/server/db/validations/domain";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
|
||||||
import type z from "zod";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
type Domain = z.infer<typeof domainCompose>;
|
|
||||||
|
|
||||||
export type CacheType = "fetch" | "cache";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
composeId: string;
|
|
||||||
domainId?: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AddDomainCompose = ({
|
|
||||||
composeId,
|
|
||||||
domainId = "",
|
|
||||||
children,
|
|
||||||
}: Props) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
|
||||||
const utils = api.useUtils();
|
|
||||||
const { data, refetch } = api.domain.one.useQuery(
|
|
||||||
{
|
|
||||||
domainId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!domainId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: compose } = api.compose.one.useQuery(
|
|
||||||
{
|
|
||||||
composeId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!composeId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: services,
|
|
||||||
isFetching: isLoadingServices,
|
|
||||||
error: errorServices,
|
|
||||||
refetch: refetchServices,
|
|
||||||
} = api.compose.loadServices.useQuery(
|
|
||||||
{
|
|
||||||
composeId,
|
|
||||||
type: cacheType,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
retry: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
|
|
||||||
api.domain.generateDomain.useMutation();
|
|
||||||
|
|
||||||
const { mutateAsync, isError, error, isLoading } = domainId
|
|
||||||
? api.domain.update.useMutation()
|
|
||||||
: api.domain.create.useMutation();
|
|
||||||
|
|
||||||
const { data: canGenerateTraefikMeDomains } =
|
|
||||||
api.domain.canGenerateTraefikMeDomains.useQuery({
|
|
||||||
serverId: compose?.serverId || "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = useForm<Domain>({
|
|
||||||
resolver: zodResolver(domainCompose),
|
|
||||||
defaultValues: {
|
|
||||||
host: "",
|
|
||||||
path: undefined,
|
|
||||||
port: undefined,
|
|
||||||
https: false,
|
|
||||||
certificateType: undefined,
|
|
||||||
customCertResolver: undefined,
|
|
||||||
serviceName: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const https = form.watch("https");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
form.reset({
|
|
||||||
...data,
|
|
||||||
/* Convert null to undefined */
|
|
||||||
path: data?.path || undefined,
|
|
||||||
port: data?.port || undefined,
|
|
||||||
serviceName: data?.serviceName || undefined,
|
|
||||||
certificateType: data?.certificateType || undefined,
|
|
||||||
customCertResolver: data?.customCertResolver || undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!domainId) {
|
|
||||||
form.reset({
|
|
||||||
host: "",
|
|
||||||
path: undefined,
|
|
||||||
port: undefined,
|
|
||||||
https: false,
|
|
||||||
certificateType: undefined,
|
|
||||||
customCertResolver: undefined,
|
|
||||||
serviceName: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [form, form.reset, data, isLoading]);
|
|
||||||
|
|
||||||
const dictionary = {
|
|
||||||
success: domainId ? "Domain Updated" : "Domain Created",
|
|
||||||
error: domainId ? "Error updating the domain" : "Error creating 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,
|
|
||||||
composeId,
|
|
||||||
domainType: "compose",
|
|
||||||
...data,
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
await utils.domain.byComposeId.invalidate({
|
|
||||||
composeId,
|
|
||||||
});
|
|
||||||
toast.success(dictionary.success);
|
|
||||||
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>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<AlertBlock type="info">
|
|
||||||
Deploy is required to apply changes after creating or updating a
|
|
||||||
domain.
|
|
||||||
</AlertBlock>
|
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
{errorServices && (
|
|
||||||
<AlertBlock
|
|
||||||
type="warning"
|
|
||||||
className="[overflow-wrap:anywhere]"
|
|
||||||
>
|
|
||||||
{errorServices?.message}
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-row items-end w-full gap-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="serviceName"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
<FormLabel>Service Name</FormLabel>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value || ""}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select a service name" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<SelectContent>
|
|
||||||
{services?.map((service, index) => (
|
|
||||||
<SelectItem
|
|
||||||
value={service}
|
|
||||||
key={`${service}-${index}`}
|
|
||||||
>
|
|
||||||
{service}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectItem value="none" disabled>
|
|
||||||
Empty
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
type="button"
|
|
||||||
isLoading={isLoadingServices}
|
|
||||||
onClick={() => {
|
|
||||||
if (cacheType === "fetch") {
|
|
||||||
refetchServices();
|
|
||||||
} else {
|
|
||||||
setCacheType("fetch");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RefreshCw className="size-4 text-muted-foreground" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="left"
|
|
||||||
sideOffset={5}
|
|
||||||
className="max-w-[10rem]"
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
Fetch: Will clone the repository and load the
|
|
||||||
services
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
type="button"
|
|
||||||
isLoading={isLoadingServices}
|
|
||||||
onClick={() => {
|
|
||||||
if (cacheType === "cache") {
|
|
||||||
refetchServices();
|
|
||||||
} else {
|
|
||||||
setCacheType("cache");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DatabaseZap className="size-4 text-muted-foreground" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
side="left"
|
|
||||||
sideOffset={5}
|
|
||||||
className="max-w-[10rem]"
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
Cache: If you previously deployed this
|
|
||||||
compose, it will read the services from the
|
|
||||||
last deployment/fetch from the repository
|
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="host"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
{!canGenerateTraefikMeDomains &&
|
|
||||||
field.value.includes("traefik.me") && (
|
|
||||||
<AlertBlock type="warning">
|
|
||||||
You need to set an IP address in your{" "}
|
|
||||||
<Link
|
|
||||||
href="/dashboard/settings/server"
|
|
||||||
className="text-primary"
|
|
||||||
>
|
|
||||||
{compose?.serverId
|
|
||||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
|
||||||
: "Web Server -> Server -> Update Server IP"}
|
|
||||||
</Link>{" "}
|
|
||||||
to make your traefik.me domain work.
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
<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({
|
|
||||||
serverId: compose?.serverId || "",
|
|
||||||
appName: compose?.appName || "",
|
|
||||||
})
|
|
||||||
.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>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
<SelectItem value={"custom"}>Custom</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{form.getValues().certificateType === "custom" && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="customCertResolver"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="col-span-2">
|
|
||||||
<FormLabel>Custom Certificate Resolver</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Enter your custom certificate resolver"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
isLoading={form.formState.isSubmitting}
|
|
||||||
form="hook-form"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{dictionary.submit}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { AddDomainCompose } from "./add-domain";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
composeId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ShowDomainsCompose = ({ composeId }: Props) => {
|
|
||||||
const { data, refetch } = api.domain.byComposeId.useQuery(
|
|
||||||
{
|
|
||||||
composeId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!composeId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
|
||||||
api.domain.delete.useMutation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
|
||||||
<Card className="bg-background">
|
|
||||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<CardTitle className="text-xl">Domains</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Domains are used to access to the application
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
|
||||||
{data && data?.length > 0 && (
|
|
||||||
<AddDomainCompose composeId={composeId}>
|
|
||||||
<Button>
|
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
|
||||||
</Button>
|
|
||||||
</AddDomainCompose>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex w-full flex-row gap-4">
|
|
||||||
{data?.length === 0 ? (
|
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-3">
|
|
||||||
<GlobeIcon className="size-8 text-muted-foreground" />
|
|
||||||
<span className="text-base text-muted-foreground">
|
|
||||||
To access to the application it is required to set at least 1
|
|
||||||
domain
|
|
||||||
</span>
|
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
|
||||||
<AddDomainCompose composeId={composeId}>
|
|
||||||
<Button>
|
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
|
||||||
</Button>
|
|
||||||
</AddDomainCompose>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex w-full flex-col gap-4">
|
|
||||||
{data?.map((item) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.domainId}
|
|
||||||
className="flex w-full items-center justify-between gap-4 border p-4 md:px-6 rounded-lg flex-wrap"
|
|
||||||
>
|
|
||||||
<div className="md:basis-1/2 flex gap-6 w-full items-center">
|
|
||||||
<span className="opacity-50 text-center font-medium text-sm whitespace-nowrap">
|
|
||||||
{item.serviceName}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
className="flex gap-2 items-center hover:underline transition-all w-full max-w-[calc(100%-4rem)]"
|
|
||||||
target="_blank"
|
|
||||||
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
|
||||||
>
|
|
||||||
<span className="truncate text-sm">{item.host}</span>
|
|
||||||
<ExternalLink className="size-4 min-w-4" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-8">
|
|
||||||
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
|
|
||||||
<span>{item.path}</span>
|
|
||||||
<span>{item.port}</span>
|
|
||||||
<span>{item.https ? "HTTPS" : "HTTP"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<AddDomainCompose
|
|
||||||
composeId={composeId}
|
|
||||||
domainId={item.domainId}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-blue-500/10 "
|
|
||||||
>
|
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
|
||||||
</Button>
|
|
||||||
</AddDomainCompose>
|
|
||||||
<DialogAction
|
|
||||||
title="Delete Domain"
|
|
||||||
description="Are you sure you want to delete this domain?"
|
|
||||||
type="destructive"
|
|
||||||
onClick={async () => {
|
|
||||||
await deleteDomain({
|
|
||||||
domainId: item.domainId,
|
|
||||||
})
|
|
||||||
.then((_data) => {
|
|
||||||
refetch();
|
|
||||||
toast.success("Domain deleted successfully");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error deleting domain");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-red-500/10"
|
|
||||||
isLoading={isRemoving}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -44,8 +44,10 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
resolver: zodResolver(AddComposeFile),
|
resolver: zodResolver(AddComposeFile),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const composeFile = form.watch("composeFile");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data && !composeFile) {
|
||||||
form.reset({
|
form.reset({
|
||||||
composeFile: data.composeFile || "",
|
composeFile: data.composeFile || "",
|
||||||
});
|
});
|
||||||
@@ -79,6 +81,22 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
toast.error("Error updating the Compose config");
|
toast.error("Error updating the Compose config");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
||||||
|
e.preventDefault();
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [form, onSubmit, isLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex flex-col gap-4 ">
|
<div className="w-full flex flex-col gap-4 ">
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -29,6 +31,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -39,13 +42,11 @@ 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";
|
||||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
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 { Badge } from "@/components/ui/badge";
|
|
||||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
const BitbucketProviderSchema = z.object({
|
const BitbucketProviderSchema = z.object({
|
||||||
composePath: z.string().min(1),
|
composePath: z.string().min(1),
|
||||||
@@ -58,6 +59,7 @@ const BitbucketProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
|
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
|
||||||
@@ -84,6 +86,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
bitbucketId: "",
|
bitbucketId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(BitbucketProviderSchema),
|
resolver: zodResolver(BitbucketProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -130,9 +133,10 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
bitbucketId: data.bitbucketId || "",
|
bitbucketId: data.bitbucketId || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data?.composeId, form]);
|
||||||
|
|
||||||
const onSubmit = async (data: BitbucketProvider) => {
|
const onSubmit = async (data: BitbucketProvider) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -145,6 +149,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
sourceType: "bitbucket",
|
sourceType: "bitbucket",
|
||||||
composeStatus: "idle",
|
composeStatus: "idle",
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provided Saved");
|
toast.success("Service Provided Saved");
|
||||||
@@ -432,7 +437,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -451,7 +456,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const input = document.querySelector(
|
const input = document.querySelector(
|
||||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
const value = input.value.trim();
|
const value = input.value.trim();
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -469,6 +474,21 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableSubmodules"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { GitIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -27,13 +29,12 @@ import {
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useEffect } from "react";
|
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 { GitIcon } from "@/components/icons/data-tools-icons";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
const GitProviderSchema = z.object({
|
const GitProviderSchema = z.object({
|
||||||
composePath: z.string().min(1),
|
composePath: z.string().min(1),
|
||||||
@@ -43,6 +44,7 @@ const GitProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch required"),
|
branch: z.string().min(1, "Branch required"),
|
||||||
sshKey: z.string().optional(),
|
sshKey: z.string().optional(),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GitProvider = z.infer<typeof GitProviderSchema>;
|
type GitProvider = z.infer<typeof GitProviderSchema>;
|
||||||
@@ -65,6 +67,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: "./docker-compose.yml",
|
composePath: "./docker-compose.yml",
|
||||||
sshKey: undefined,
|
sshKey: undefined,
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GitProviderSchema),
|
resolver: zodResolver(GitProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -77,6 +80,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
repositoryURL: data.customGitUrl || "",
|
repositoryURL: data.customGitUrl || "",
|
||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
@@ -91,6 +95,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: values.composePath,
|
composePath: values.composePath,
|
||||||
composeStatus: "idle",
|
composeStatus: "idle",
|
||||||
watchPaths: values.watchPaths || [],
|
watchPaths: values.watchPaths || [],
|
||||||
|
enableSubmodules: values.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Git Provider Saved");
|
toast.success("Git Provider Saved");
|
||||||
@@ -258,7 +263,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -277,7 +282,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const input = document.querySelector(
|
const input = document.querySelector(
|
||||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
const value = input.value.trim();
|
const value = input.value.trim();
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -295,6 +300,21 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableSubmodules"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row justify-end">
|
<div className="flex flex-row justify-end">
|
||||||
|
|||||||
@@ -0,0 +1,503 @@
|
|||||||
|
import { GiteaIcon } from "@/components/icons/data-tools-icons";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
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 { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import type { Repository } from "@/utils/gitea-utils";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const GiteaProviderSchema = z.object({
|
||||||
|
composePath: z.string().min(1),
|
||||||
|
repository: z
|
||||||
|
.object({
|
||||||
|
repo: z.string().min(1, "Repo is required"),
|
||||||
|
owner: z.string().min(1, "Owner is required"),
|
||||||
|
})
|
||||||
|
.required(),
|
||||||
|
branch: z.string().min(1, "Branch is required"),
|
||||||
|
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||||
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
enableSubmodules: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
composeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||||
|
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
|
||||||
|
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
||||||
|
const { mutateAsync, isLoading: isSavingGiteaProvider } =
|
||||||
|
api.compose.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<GiteaProvider>({
|
||||||
|
defaultValues: {
|
||||||
|
composePath: "./docker-compose.yml",
|
||||||
|
repository: {
|
||||||
|
owner: "",
|
||||||
|
repo: "",
|
||||||
|
},
|
||||||
|
giteaId: "",
|
||||||
|
branch: "",
|
||||||
|
watchPaths: [],
|
||||||
|
enableSubmodules: false,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(GiteaProviderSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const repository = form.watch("repository");
|
||||||
|
const giteaId = form.watch("giteaId");
|
||||||
|
|
||||||
|
const { data: giteaUrl } = api.gitea.getGiteaUrl.useQuery(
|
||||||
|
{ giteaId },
|
||||||
|
{
|
||||||
|
enabled: !!giteaId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: repositories,
|
||||||
|
isLoading: isLoadingRepositories,
|
||||||
|
error,
|
||||||
|
} = api.gitea.getGiteaRepositories.useQuery<Repository[]>(
|
||||||
|
{
|
||||||
|
giteaId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!giteaId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: branches,
|
||||||
|
fetchStatus,
|
||||||
|
status,
|
||||||
|
} = api.gitea.getGiteaBranches.useQuery(
|
||||||
|
{
|
||||||
|
owner: repository?.owner,
|
||||||
|
repositoryName: repository?.repo,
|
||||||
|
giteaId: giteaId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!repository?.owner && !!repository?.repo && !!giteaId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) {
|
||||||
|
form.reset({
|
||||||
|
branch: data.giteaBranch || "",
|
||||||
|
repository: {
|
||||||
|
repo: data.giteaRepository || "",
|
||||||
|
owner: data.giteaOwner || "",
|
||||||
|
},
|
||||||
|
composePath: data.composePath || "./docker-compose.yml",
|
||||||
|
giteaId: data.giteaId || "",
|
||||||
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [form.reset, data?.composeId, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: GiteaProvider) => {
|
||||||
|
await mutateAsync({
|
||||||
|
giteaBranch: data.branch,
|
||||||
|
giteaRepository: data.repository.repo,
|
||||||
|
giteaOwner: data.repository.owner,
|
||||||
|
composePath: data.composePath,
|
||||||
|
giteaId: data.giteaId,
|
||||||
|
composeId,
|
||||||
|
sourceType: "gitea",
|
||||||
|
composeStatus: "idle",
|
||||||
|
watchPaths: data.watchPaths,
|
||||||
|
enableSubmodules: data.enableSubmodules,
|
||||||
|
} as any)
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Service Provider Saved");
|
||||||
|
await refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error saving the Gitea provider");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="grid w-full gap-4 py-3"
|
||||||
|
>
|
||||||
|
{error && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="giteaId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2 flex flex-col">
|
||||||
|
<FormLabel>Gitea Account</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
form.setValue("repository", {
|
||||||
|
owner: "",
|
||||||
|
repo: "",
|
||||||
|
});
|
||||||
|
form.setValue("branch", "");
|
||||||
|
}}
|
||||||
|
defaultValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a Gitea Account" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{giteaProviders?.map((giteaProvider) => (
|
||||||
|
<SelectItem
|
||||||
|
key={giteaProvider.giteaId}
|
||||||
|
value={giteaProvider.giteaId}
|
||||||
|
>
|
||||||
|
{giteaProvider.gitProvider.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="repository"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel>Repository</FormLabel>
|
||||||
|
{field.value.owner && field.value.repo && (
|
||||||
|
<Link
|
||||||
|
href={`${giteaUrl}/${field.value.owner}/${field.value.repo}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
|
>
|
||||||
|
<GiteaIcon className="h-4 w-4" />
|
||||||
|
<span>View Repository</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between !bg-input",
|
||||||
|
!field.value && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoadingRepositories
|
||||||
|
? "Loading...."
|
||||||
|
: field.value.owner
|
||||||
|
? repositories?.find(
|
||||||
|
(repo) => repo.name === field.value.repo,
|
||||||
|
)?.name
|
||||||
|
: "Select repository"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search repository..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
{isLoadingRepositories && (
|
||||||
|
<span className="py-6 text-center text-sm">
|
||||||
|
Loading Repositories....
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||||
|
<ScrollArea className="h-96">
|
||||||
|
<CommandGroup>
|
||||||
|
{repositories?.map((repo) => (
|
||||||
|
<CommandItem
|
||||||
|
key={repo.url}
|
||||||
|
value={repo.name}
|
||||||
|
onSelect={() => {
|
||||||
|
form.setValue("repository", {
|
||||||
|
owner: repo.owner.username,
|
||||||
|
repo: repo.name,
|
||||||
|
});
|
||||||
|
form.setValue("branch", "");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>{repo.name}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{repo.owner.username}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
repo.name === field.value.repo
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</ScrollArea>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{form.formState.errors.repository && (
|
||||||
|
<p className={cn("text-sm font-medium text-destructive")}>
|
||||||
|
Repository is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="branch"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="block w-full">
|
||||||
|
<FormLabel>Branch</FormLabel>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between !bg-input",
|
||||||
|
!field.value && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status === "loading" && fetchStatus === "fetching"
|
||||||
|
? "Loading...."
|
||||||
|
: field.value
|
||||||
|
? branches?.find(
|
||||||
|
(branch) => branch.name === field.value,
|
||||||
|
)?.name
|
||||||
|
: "Select branch"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search branches..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
<CommandEmpty>No branches found.</CommandEmpty>
|
||||||
|
<ScrollArea className="h-96">
|
||||||
|
<CommandGroup>
|
||||||
|
{branches?.map((branch) => (
|
||||||
|
<CommandItem
|
||||||
|
key={branch.name}
|
||||||
|
value={branch.name}
|
||||||
|
onSelect={() =>
|
||||||
|
form.setValue("branch", branch.name)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{branch.name}
|
||||||
|
</span>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
branch.name === field.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</ScrollArea>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{form.formState.errors.branch && (
|
||||||
|
<p className={cn("text-sm font-medium text-destructive")}>
|
||||||
|
Branch is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="composePath"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Compose Path</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="docker-compose.yml" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="watchPaths"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
|
?
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Add paths to watch for changes. When files in these
|
||||||
|
paths change, a new deployment will be triggered.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{field.value?.map((path, index) => (
|
||||||
|
<Badge key={index} variant="secondary">
|
||||||
|
{path}
|
||||||
|
<X
|
||||||
|
className="ml-1 size-3 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const newPaths = [...(field.value || [])];
|
||||||
|
newPaths.splice(index, 1);
|
||||||
|
form.setValue("watchPaths", newPaths);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (value) {
|
||||||
|
const newPaths = [...(field.value || []), value];
|
||||||
|
form.setValue("watchPaths", newPaths);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => {
|
||||||
|
const input = document.querySelector(
|
||||||
|
'input[placeholder*="Enter a path"]',
|
||||||
|
) as HTMLInputElement;
|
||||||
|
const path = input.value.trim();
|
||||||
|
if (path) {
|
||||||
|
field.onChange([...(field.value || []), path]);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableSubmodules"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" isLoading={isSavingGiteaProvider}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -38,13 +40,12 @@ import {
|
|||||||
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";
|
||||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
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 { GithubIcon } from "@/components/icons/data-tools-icons";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
const GithubProviderSchema = z.object({
|
const GithubProviderSchema = z.object({
|
||||||
composePath: z.string().min(1),
|
composePath: z.string().min(1),
|
||||||
@@ -57,6 +58,8 @@ const GithubProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
githubId: z.string().min(1, "Github Provider is required"),
|
githubId: z.string().min(1, "Github Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GithubProvider = z.infer<typeof GithubProviderSchema>;
|
type GithubProvider = z.infer<typeof GithubProviderSchema>;
|
||||||
@@ -82,13 +85,15 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
githubId: "",
|
githubId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
triggerType: "push",
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GithubProviderSchema),
|
resolver: zodResolver(GithubProviderSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const repository = form.watch("repository");
|
const repository = form.watch("repository");
|
||||||
const githubId = form.watch("githubId");
|
const githubId = form.watch("githubId");
|
||||||
|
const triggerType = form.watch("triggerType");
|
||||||
const { data: repositories, isLoading: isLoadingRepositories } =
|
const { data: repositories, isLoading: isLoadingRepositories } =
|
||||||
api.github.getGithubRepositories.useQuery(
|
api.github.getGithubRepositories.useQuery(
|
||||||
{
|
{
|
||||||
@@ -125,9 +130,11 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
githubId: data.githubId || "",
|
githubId: data.githubId || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
triggerType: data.triggerType || "push",
|
||||||
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data?.composeId, form]);
|
||||||
|
|
||||||
const onSubmit = async (data: GithubProvider) => {
|
const onSubmit = async (data: GithubProvider) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -140,6 +147,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
sourceType: "github",
|
sourceType: "github",
|
||||||
composeStatus: "idle",
|
composeStatus: "idle",
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
|
enableSubmodules: data.enableSubmodules,
|
||||||
|
triggerType: data.triggerType,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provided Saved");
|
toast.success("Service Provided Saved");
|
||||||
@@ -384,79 +393,140 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="watchPaths"
|
name="triggerType"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="md:col-span-2">
|
<FormItem className="md:col-span-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Trigger Type</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||||
?
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
Add paths to watch for changes. When files in these
|
Choose when to trigger deployments: on push to the
|
||||||
paths change, a new deployment will be triggered.
|
selected branch or when a new tag is created.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<Select
|
||||||
{field.value?.map((path, index) => (
|
onValueChange={field.onChange}
|
||||||
<Badge key={index} variant="secondary">
|
defaultValue={field.value}
|
||||||
{path}
|
value={field.value}
|
||||||
<X
|
>
|
||||||
className="ml-1 size-3 cursor-pointer"
|
<FormControl>
|
||||||
onClick={() => {
|
<SelectTrigger>
|
||||||
const newPaths = [...(field.value || [])];
|
<SelectValue placeholder="Select a trigger type" />
|
||||||
newPaths.splice(index, 1);
|
</SelectTrigger>
|
||||||
form.setValue("watchPaths", newPaths);
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="push">On Push</SelectItem>
|
||||||
|
<SelectItem value="tag">On Tag</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{triggerType === "push" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="watchPaths"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
|
?
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Add paths to watch for changes. When files in
|
||||||
|
these paths change, a new deployment will be
|
||||||
|
triggered.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{field.value?.map((path, index) => (
|
||||||
|
<Badge key={index} variant="secondary">
|
||||||
|
{path}
|
||||||
|
<X
|
||||||
|
className="ml-1 size-3 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const newPaths = [...(field.value || [])];
|
||||||
|
newPaths.splice(index, 1);
|
||||||
|
form.setValue("watchPaths", newPaths);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (value) {
|
||||||
|
const newPaths = [
|
||||||
|
...(field.value || []),
|
||||||
|
value,
|
||||||
|
];
|
||||||
|
form.setValue("watchPaths", newPaths);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Badge>
|
<Button
|
||||||
))}
|
type="button"
|
||||||
</div>
|
variant="secondary"
|
||||||
<FormControl>
|
onClick={() => {
|
||||||
<div className="flex gap-2">
|
const input = document.querySelector(
|
||||||
<Input
|
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
|
||||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
) as HTMLInputElement;
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
const input = e.currentTarget;
|
|
||||||
const value = input.value.trim();
|
const value = input.value.trim();
|
||||||
if (value) {
|
if (value) {
|
||||||
const newPaths = [...(field.value || []), value];
|
const newPaths = [...(field.value || []), value];
|
||||||
form.setValue("watchPaths", newPaths);
|
form.setValue("watchPaths", newPaths);
|
||||||
input.value = "";
|
input.value = "";
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}}
|
>
|
||||||
/>
|
Add
|
||||||
<Button
|
</Button>
|
||||||
type="button"
|
</div>
|
||||||
variant="secondary"
|
</FormControl>
|
||||||
onClick={() => {
|
<FormMessage />
|
||||||
const input = document.querySelector(
|
</FormItem>
|
||||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
)}
|
||||||
) as HTMLInputElement;
|
/>
|
||||||
const value = input.value.trim();
|
)}
|
||||||
if (value) {
|
<FormField
|
||||||
const newPaths = [...(field.value || []), value];
|
control={form.control}
|
||||||
form.setValue("watchPaths", newPaths);
|
name="enableSubmodules"
|
||||||
input.value = "";
|
render={({ field }) => (
|
||||||
}
|
<FormItem className="flex items-center space-x-2">
|
||||||
}}
|
<FormControl>
|
||||||
>
|
<Switch
|
||||||
Add
|
checked={field.value}
|
||||||
</Button>
|
onCheckedChange={field.onChange}
|
||||||
</div>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -29,6 +31,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -39,13 +42,11 @@ 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";
|
||||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
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 { Badge } from "@/components/ui/badge";
|
|
||||||
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
const GitlabProviderSchema = z.object({
|
const GitlabProviderSchema = z.object({
|
||||||
composePath: z.string().min(1),
|
composePath: z.string().min(1),
|
||||||
@@ -60,6 +61,7 @@ const GitlabProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
|
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
|
||||||
@@ -87,6 +89,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
gitlabId: "",
|
gitlabId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GitlabProviderSchema),
|
resolver: zodResolver(GitlabProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -136,9 +139,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
gitlabId: data.gitlabId || "",
|
gitlabId: data.gitlabId || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data?.composeId, form]);
|
||||||
|
|
||||||
const onSubmit = async (data: GitlabProvider) => {
|
const onSubmit = async (data: GitlabProvider) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -153,6 +157,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
sourceType: "gitlab",
|
sourceType: "gitlab",
|
||||||
composeStatus: "idle",
|
composeStatus: "idle",
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provided Saved");
|
toast.success("Service Provided Saved");
|
||||||
@@ -448,7 +453,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -467,7 +472,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const input = document.querySelector(
|
const input = document.querySelector(
|
||||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
const value = input.value.trim();
|
const value = input.value.trim();
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -485,6 +490,21 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableSubmodules"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,35 +1,123 @@
|
|||||||
import {
|
import {
|
||||||
BitbucketIcon,
|
BitbucketIcon,
|
||||||
GitIcon,
|
GitIcon,
|
||||||
|
GiteaIcon,
|
||||||
GithubIcon,
|
GithubIcon,
|
||||||
GitlabIcon,
|
GitlabIcon,
|
||||||
} from "@/components/icons/data-tools-icons";
|
} from "@/components/icons/data-tools-icons";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { CodeIcon, GitBranch } from "lucide-react";
|
import { CodeIcon, GitBranch, Loader2 } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ComposeFileEditor } from "../compose-file-editor";
|
import { ComposeFileEditor } from "../compose-file-editor";
|
||||||
import { ShowConvertedCompose } from "../show-converted-compose";
|
import { ShowConvertedCompose } from "../show-converted-compose";
|
||||||
import { SaveBitbucketProviderCompose } from "./save-bitbucket-provider-compose";
|
import { SaveBitbucketProviderCompose } from "./save-bitbucket-provider-compose";
|
||||||
import { SaveGitProviderCompose } from "./save-git-provider-compose";
|
import { SaveGitProviderCompose } from "./save-git-provider-compose";
|
||||||
|
import { SaveGiteaProviderCompose } from "./save-gitea-provider-compose";
|
||||||
import { SaveGithubProviderCompose } from "./save-github-provider-compose";
|
import { SaveGithubProviderCompose } from "./save-github-provider-compose";
|
||||||
import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose";
|
import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose";
|
||||||
|
import { UnauthorizedGitProvider } from "@/components/dashboard/application/general/generic/unauthorized-git-provider";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket";
|
type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket" | "gitea";
|
||||||
interface Props {
|
interface Props {
|
||||||
composeId: string;
|
composeId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
||||||
const { data: githubProviders } = api.github.githubProviders.useQuery();
|
const { data: githubProviders, isLoading: isLoadingGithub } =
|
||||||
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
|
api.github.githubProviders.useQuery();
|
||||||
const { data: bitbucketProviders } =
|
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
|
||||||
|
api.gitlab.gitlabProviders.useQuery();
|
||||||
|
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
|
||||||
api.bitbucket.bitbucketProviders.useQuery();
|
api.bitbucket.bitbucketProviders.useQuery();
|
||||||
|
const { data: giteaProviders, isLoading: isLoadingGitea } =
|
||||||
|
api.gitea.giteaProviders.useQuery();
|
||||||
|
|
||||||
const { data: compose } = api.compose.one.useQuery({ composeId });
|
const { mutateAsync: disconnectGitProvider } =
|
||||||
|
api.compose.disconnectGitProvider.useMutation();
|
||||||
|
|
||||||
|
const { data: compose, refetch } = api.compose.one.useQuery({ composeId });
|
||||||
const [tab, setSab] = useState<TabState>(compose?.sourceType || "github");
|
const [tab, setSab] = useState<TabState>(compose?.sourceType || "github");
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea;
|
||||||
|
|
||||||
|
const handleDisconnect = async () => {
|
||||||
|
try {
|
||||||
|
await disconnectGitProvider({ composeId });
|
||||||
|
toast.success("Repository disconnected successfully");
|
||||||
|
await refetch();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
`Failed to disconnect repository: ${
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="group relative w-full bg-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-start justify-between">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="flex flex-col space-y-0.5">Provider</span>
|
||||||
|
<p className="flex items-center text-sm font-normal text-muted-foreground">
|
||||||
|
Select the source of your code
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden space-y-1 text-sm font-normal md:block">
|
||||||
|
<GitBranch className="size-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex min-h-[25vh] items-center justify-center">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
<span>Loading providers...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user doesn't have access to the current git provider
|
||||||
|
if (
|
||||||
|
compose &&
|
||||||
|
!compose.hasGitProviderAccess &&
|
||||||
|
compose.sourceType !== "raw"
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Card className="group relative w-full bg-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-start justify-between">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="flex flex-col space-y-0.5">Provider</span>
|
||||||
|
<p className="flex items-center text-sm font-normal text-muted-foreground">
|
||||||
|
Repository connection through unauthorized provider
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden space-y-1 text-sm font-normal md:block">
|
||||||
|
<GitBranch className="size-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<UnauthorizedGitProvider
|
||||||
|
service={compose}
|
||||||
|
onDisconnect={handleDisconnect}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="group relative w-full bg-transparent">
|
<Card className="group relative w-full bg-transparent">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -54,21 +142,21 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
|||||||
setSab(e as TabState);
|
setSab(e as TabState);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
<div className="flex flex-row items-center justify-between w-full gap-4">
|
||||||
<TabsList className="md:grid md:w-fit md:grid-cols-5 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
|
<TabsList className="md:grid md:w-fit md:grid-cols-6 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="github"
|
value="github"
|
||||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||||
>
|
>
|
||||||
<GithubIcon className="size-4 text-current fill-current" />
|
<GithubIcon className="size-4 text-current fill-current" />
|
||||||
Github
|
GitHub
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="gitlab"
|
value="gitlab"
|
||||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||||
>
|
>
|
||||||
<GitlabIcon className="size-4 text-current fill-current" />
|
<GitlabIcon className="size-4 text-current fill-current" />
|
||||||
Gitlab
|
GitLab
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="bitbucket"
|
value="bitbucket"
|
||||||
@@ -77,7 +165,12 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
|||||||
<BitbucketIcon className="size-4 text-current fill-current" />
|
<BitbucketIcon className="size-4 text-current fill-current" />
|
||||||
Bitbucket
|
Bitbucket
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="gitea"
|
||||||
|
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||||
|
>
|
||||||
|
<GiteaIcon className="size-4 text-current fill-current" /> Gitea
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="git"
|
value="git"
|
||||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||||
@@ -89,16 +182,17 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
|||||||
value="raw"
|
value="raw"
|
||||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||||
>
|
>
|
||||||
<CodeIcon className="size-4 " />
|
<CodeIcon className="size-4" />
|
||||||
Raw
|
Raw
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="github" className="w-full p-2">
|
<TabsContent value="github" className="w-full p-2">
|
||||||
{githubProviders && githubProviders?.length > 0 ? (
|
{githubProviders && githubProviders?.length > 0 ? (
|
||||||
<SaveGithubProviderCompose composeId={composeId} />
|
<SaveGithubProviderCompose composeId={composeId} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
<GithubIcon className="size-8 text-muted-foreground" />
|
<GithubIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To deploy using GitHub, you need to configure your account
|
To deploy using GitHub, you need to configure your account
|
||||||
@@ -118,7 +212,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
|||||||
{gitlabProviders && gitlabProviders?.length > 0 ? (
|
{gitlabProviders && gitlabProviders?.length > 0 ? (
|
||||||
<SaveGitlabProviderCompose composeId={composeId} />
|
<SaveGitlabProviderCompose composeId={composeId} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
<GitlabIcon className="size-8 text-muted-foreground" />
|
<GitlabIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To deploy using GitLab, you need to configure your account
|
To deploy using GitLab, you need to configure your account
|
||||||
@@ -138,7 +232,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
|||||||
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
|
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
|
||||||
<SaveBitbucketProviderCompose composeId={composeId} />
|
<SaveBitbucketProviderCompose composeId={composeId} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
<BitbucketIcon className="size-8 text-muted-foreground" />
|
<BitbucketIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground">
|
||||||
To deploy using Bitbucket, you need to configure your account
|
To deploy using Bitbucket, you need to configure your account
|
||||||
@@ -154,6 +248,26 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="gitea" className="w-full p-2">
|
||||||
|
{giteaProviders && giteaProviders?.length > 0 ? (
|
||||||
|
<SaveGiteaProviderCompose composeId={composeId} />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||||
|
<GiteaIcon className="size-8 text-muted-foreground" />
|
||||||
|
<span className="text-base text-muted-foreground">
|
||||||
|
To deploy using Gitea, you need to configure your account
|
||||||
|
first. Please, go to{" "}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/git-providers"
|
||||||
|
className="text-foreground"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>{" "}
|
||||||
|
to do so.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="git" className="w-full p-2">
|
<TabsContent value="git" className="w-full p-2">
|
||||||
<SaveGitProviderCompose composeId={composeId} />
|
<SaveGitProviderCompose composeId={composeId} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
|
|||||||
isolatedDeployment: formData?.isolatedDeployment || false,
|
isolatedDeployment: formData?.isolatedDeployment || false,
|
||||||
})
|
})
|
||||||
.then(async (_data) => {
|
.then(async (_data) => {
|
||||||
randomizeCompose();
|
await randomizeCompose();
|
||||||
refetch();
|
await refetch();
|
||||||
toast.success("Compose updated");
|
toast.success("Compose updated");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -84,15 +84,10 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
|
|||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
composeId,
|
composeId,
|
||||||
suffix: data?.appName || "",
|
suffix: data?.appName || "",
|
||||||
})
|
}).then(async (data) => {
|
||||||
.then(async (data) => {
|
await utils.project.all.invalidate();
|
||||||
await utils.project.all.invalidate();
|
setCompose(data);
|
||||||
setCompose(data);
|
});
|
||||||
toast.success("Compose Isolated");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error isolating the compose");
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -147,7 +142,9 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>Enable Isolated Deployment ({data?.appName})</FormLabel>
|
<FormLabel>
|
||||||
|
Enable Isolated Deployment ({data?.appName})
|
||||||
|
</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Enable isolated deployment to the compose file.
|
Enable isolated deployment to the compose file.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ export const RandomizeCompose = ({ composeId }: Props) => {
|
|||||||
randomize: formData?.randomize || false,
|
randomize: formData?.randomize || false,
|
||||||
})
|
})
|
||||||
.then(async (_data) => {
|
.then(async (_data) => {
|
||||||
randomizeCompose();
|
await randomizeCompose();
|
||||||
refetch();
|
await refetch();
|
||||||
toast.success("Compose updated");
|
toast.success("Compose updated");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
@@ -90,15 +90,10 @@ export const RandomizeCompose = ({ composeId }: Props) => {
|
|||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
composeId,
|
composeId,
|
||||||
suffix,
|
suffix,
|
||||||
})
|
}).then(async (data) => {
|
||||||
.then(async (data) => {
|
await utils.project.all.invalidate();
|
||||||
await utils.project.all.invalidate();
|
setCompose(data);
|
||||||
setCompose(data);
|
});
|
||||||
toast.success("Compose randomized");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error randomizing the compose");
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { Puzzle, RefreshCw } from "lucide-react";
|
import { Loader2, Puzzle, RefreshCw } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -62,35 +62,54 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<AlertBlock type="info">
|
||||||
<Button
|
Preview your docker-compose file with added domains. Note: At least
|
||||||
variant="secondary"
|
one domain must be specified for this conversion to take effect.
|
||||||
isLoading={isLoading}
|
</AlertBlock>
|
||||||
onClick={() => {
|
{isLoading ? (
|
||||||
mutateAsync({ composeId })
|
<div className="flex flex-row items-center justify-center min-h-[25rem] border p-4 rounded-md">
|
||||||
.then(() => {
|
<Loader2 className="h-8 w-8 text-muted-foreground mb-2 animate-spin" />
|
||||||
refetch();
|
</div>
|
||||||
toast.success("Fetched source type");
|
) : compose?.length === 5 ? (
|
||||||
})
|
<div className="border p-4 rounded-md flex flex-col items-center justify-center min-h-[25rem]">
|
||||||
.catch((err) => {
|
<Puzzle className="h-8 w-8 text-muted-foreground mb-2" />
|
||||||
toast.error("Error fetching source type", {
|
<span className="text-muted-foreground">
|
||||||
description: err.message,
|
No converted compose data available.
|
||||||
});
|
</span>
|
||||||
});
|
</div>
|
||||||
}}
|
) : (
|
||||||
>
|
<>
|
||||||
Refresh <RefreshCw className="ml-2 h-4 w-4" />
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
</Button>
|
<Button
|
||||||
</div>
|
variant="secondary"
|
||||||
|
isLoading={isLoading}
|
||||||
|
onClick={() => {
|
||||||
|
mutateAsync({ composeId })
|
||||||
|
.then(() => {
|
||||||
|
refetch();
|
||||||
|
toast.success("Fetched source type");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error("Error fetching source type", {
|
||||||
|
description: err.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh <RefreshCw className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
<CodeEditor
|
<CodeEditor
|
||||||
value={compose || ""}
|
value={compose || ""}
|
||||||
language="yaml"
|
language="yaml"
|
||||||
readOnly
|
readOnly
|
||||||
height="50rem"
|
height="50rem"
|
||||||
/>
|
/>
|
||||||
</pre>
|
</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,334 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
} from "@/components/ui/command";
|
|
||||||
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 {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { PlusIcon } from "lucide-react";
|
|
||||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const AddPostgresBackup1Schema = z.object({
|
|
||||||
destinationId: z.string().min(1, "Destination required"),
|
|
||||||
schedule: z.string().min(1, "Schedule (Cron) required"),
|
|
||||||
// .regex(
|
|
||||||
// new RegExp(
|
|
||||||
// /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/,
|
|
||||||
// ),
|
|
||||||
// "Invalid Cron",
|
|
||||||
// ),
|
|
||||||
prefix: z.string().min(1, "Prefix required"),
|
|
||||||
enabled: z.boolean(),
|
|
||||||
database: z.string().min(1, "Database required"),
|
|
||||||
keepLatestCount: z.coerce.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
databaseId: string;
|
|
||||||
databaseType: "postgres" | "mariadb" | "mysql" | "mongo";
|
|
||||||
refetch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
|
|
||||||
const { data, isLoading } = api.destination.all.useQuery();
|
|
||||||
|
|
||||||
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
|
|
||||||
api.backup.create.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<AddPostgresBackup>({
|
|
||||||
defaultValues: {
|
|
||||||
database: "",
|
|
||||||
destinationId: "",
|
|
||||||
enabled: true,
|
|
||||||
prefix: "/",
|
|
||||||
schedule: "",
|
|
||||||
keepLatestCount: undefined,
|
|
||||||
},
|
|
||||||
resolver: zodResolver(AddPostgresBackup1Schema),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.reset({
|
|
||||||
database: "",
|
|
||||||
destinationId: "",
|
|
||||||
enabled: true,
|
|
||||||
prefix: "/",
|
|
||||||
schedule: "",
|
|
||||||
keepLatestCount: undefined,
|
|
||||||
});
|
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: AddPostgresBackup) => {
|
|
||||||
const getDatabaseId =
|
|
||||||
databaseType === "postgres"
|
|
||||||
? {
|
|
||||||
postgresId: databaseId,
|
|
||||||
}
|
|
||||||
: databaseType === "mariadb"
|
|
||||||
? {
|
|
||||||
mariadbId: databaseId,
|
|
||||||
}
|
|
||||||
: databaseType === "mysql"
|
|
||||||
? {
|
|
||||||
mysqlId: databaseId,
|
|
||||||
}
|
|
||||||
: databaseType === "mongo"
|
|
||||||
? {
|
|
||||||
mongoId: databaseId,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
await createBackup({
|
|
||||||
destinationId: data.destinationId,
|
|
||||||
prefix: data.prefix,
|
|
||||||
schedule: data.schedule,
|
|
||||||
enabled: data.enabled,
|
|
||||||
database: data.database,
|
|
||||||
keepLatestCount: data.keepLatestCount,
|
|
||||||
databaseType,
|
|
||||||
...getDatabaseId,
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
toast.success("Backup Created");
|
|
||||||
refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error creating a backup");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button>
|
|
||||||
<PlusIcon className="h-4 w-4" />
|
|
||||||
Create Backup
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-lg max-h-screen overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create a backup</DialogTitle>
|
|
||||||
<DialogDescription>Add a new backup</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
id="hook-form-add-backup"
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="grid w-full gap-4"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="destinationId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="">
|
|
||||||
<FormLabel>Destination</FormLabel>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-between !bg-input",
|
|
||||||
!field.value && "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? "Loading...."
|
|
||||||
: field.value
|
|
||||||
? data?.find(
|
|
||||||
(destination) =>
|
|
||||||
destination.destinationId === field.value,
|
|
||||||
)?.name
|
|
||||||
: "Select Destination"}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search Destination..."
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
{isLoading && (
|
|
||||||
<span className="py-6 text-center text-sm">
|
|
||||||
Loading Destinations....
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<CommandEmpty>No destinations found.</CommandEmpty>
|
|
||||||
<ScrollArea className="h-64">
|
|
||||||
<CommandGroup>
|
|
||||||
{data?.map((destination) => (
|
|
||||||
<CommandItem
|
|
||||||
value={destination.destinationId}
|
|
||||||
key={destination.destinationId}
|
|
||||||
onSelect={() => {
|
|
||||||
form.setValue(
|
|
||||||
"destinationId",
|
|
||||||
destination.destinationId,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{destination.name}
|
|
||||||
<CheckIcon
|
|
||||||
className={cn(
|
|
||||||
"ml-auto h-4 w-4",
|
|
||||||
destination.destinationId === field.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</ScrollArea>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="database"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Database</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"dokploy"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="schedule"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Schedule (Cron)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"0 0 * * *"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="prefix"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Prefix Destination</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"dokploy/"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Use if you want to back up in a specific path of your
|
|
||||||
destination/bucket
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="keepLatestCount"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Keep the latest</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="number" placeholder={"keeps all the backups if left empty"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Optional. If provided, only keeps the latest N backups in the cloud.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Enabled</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Enable or disable the backup
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
isLoading={isCreatingPostgresBackup}
|
|
||||||
form="hook-form-add-backup"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,828 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
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 {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
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 { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
DatabaseZap,
|
||||||
|
Info,
|
||||||
|
PenBoxIcon,
|
||||||
|
PlusIcon,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { commonCronExpressions } from "../../application/schedules/handle-schedules";
|
||||||
|
|
||||||
|
type CacheType = "cache" | "fetch";
|
||||||
|
|
||||||
|
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
|
||||||
|
|
||||||
|
const Schema = z
|
||||||
|
.object({
|
||||||
|
destinationId: z.string().min(1, "Destination required"),
|
||||||
|
schedule: z.string().min(1, "Schedule (Cron) required"),
|
||||||
|
prefix: z.string().min(1, "Prefix required"),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
database: z.string().min(1, "Database required"),
|
||||||
|
keepLatestCount: z.coerce.number().optional(),
|
||||||
|
serviceName: z.string().nullable(),
|
||||||
|
databaseType: z
|
||||||
|
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
|
||||||
|
.optional(),
|
||||||
|
backupType: z.enum(["database", "compose"]),
|
||||||
|
metadata: z
|
||||||
|
.object({
|
||||||
|
postgres: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mariadb: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mongo: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mysql: z
|
||||||
|
.object({
|
||||||
|
databaseRootPassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.backupType === "compose" && !data.databaseType) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database type is required for compose backups",
|
||||||
|
path: ["databaseType"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.backupType === "compose" && !data.serviceName) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Service name is required for compose backups",
|
||||||
|
path: ["serviceName"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.backupType === "compose" && data.databaseType) {
|
||||||
|
if (data.databaseType === "postgres") {
|
||||||
|
if (!data.metadata?.postgres?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for PostgreSQL",
|
||||||
|
path: ["metadata", "postgres", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mariadb") {
|
||||||
|
if (!data.metadata?.mariadb?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for MariaDB",
|
||||||
|
path: ["metadata", "mariadb", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.metadata?.mariadb?.databasePassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database password is required for MariaDB",
|
||||||
|
path: ["metadata", "mariadb", "databasePassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mongo") {
|
||||||
|
if (!data.metadata?.mongo?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for MongoDB",
|
||||||
|
path: ["metadata", "mongo", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.metadata?.mongo?.databasePassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database password is required for MongoDB",
|
||||||
|
path: ["metadata", "mongo", "databasePassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mysql") {
|
||||||
|
if (!data.metadata?.mysql?.databaseRootPassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Root password is required for MySQL",
|
||||||
|
path: ["metadata", "mysql", "databaseRootPassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
backupId?: string;
|
||||||
|
databaseType?: DatabaseType;
|
||||||
|
refetch: () => void;
|
||||||
|
backupType: "database" | "compose";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HandleBackup = ({
|
||||||
|
id,
|
||||||
|
backupId,
|
||||||
|
databaseType = "postgres",
|
||||||
|
refetch,
|
||||||
|
backupType = "database",
|
||||||
|
}: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = api.destination.all.useQuery();
|
||||||
|
const { data: backup } = api.backup.one.useQuery(
|
||||||
|
{
|
||||||
|
backupId: backupId ?? "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!backupId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||||
|
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
|
||||||
|
backupId
|
||||||
|
? api.backup.update.useMutation()
|
||||||
|
: api.backup.create.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof Schema>>({
|
||||||
|
defaultValues: {
|
||||||
|
database: databaseType === "web-server" ? "dokploy" : "",
|
||||||
|
destinationId: "",
|
||||||
|
enabled: true,
|
||||||
|
prefix: "/",
|
||||||
|
schedule: "",
|
||||||
|
keepLatestCount: undefined,
|
||||||
|
serviceName: null,
|
||||||
|
databaseType: backupType === "compose" ? undefined : databaseType,
|
||||||
|
backupType: backupType,
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
resolver: zodResolver(Schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: services,
|
||||||
|
isFetching: isLoadingServices,
|
||||||
|
error: errorServices,
|
||||||
|
refetch: refetchServices,
|
||||||
|
} = api.compose.loadServices.useQuery(
|
||||||
|
{
|
||||||
|
composeId: backup?.composeId ?? id ?? "",
|
||||||
|
type: cacheType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
enabled: backupType === "compose" && !!backup?.composeId && !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
database: backup?.database
|
||||||
|
? backup?.database
|
||||||
|
: databaseType === "web-server"
|
||||||
|
? "dokploy"
|
||||||
|
: "",
|
||||||
|
destinationId: backup?.destinationId ?? "",
|
||||||
|
enabled: backup?.enabled ?? true,
|
||||||
|
prefix: backup?.prefix ?? "/",
|
||||||
|
schedule: backup?.schedule ?? "",
|
||||||
|
keepLatestCount: backup?.keepLatestCount ?? undefined,
|
||||||
|
serviceName: backup?.serviceName ?? null,
|
||||||
|
databaseType: backup?.databaseType ?? databaseType,
|
||||||
|
backupType: backup?.backupType ?? backupType,
|
||||||
|
metadata: backup?.metadata ?? {},
|
||||||
|
});
|
||||||
|
}, [form, form.reset, backupId, backup]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: z.infer<typeof Schema>) => {
|
||||||
|
const getDatabaseId =
|
||||||
|
backupType === "compose"
|
||||||
|
? {
|
||||||
|
composeId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "postgres"
|
||||||
|
? {
|
||||||
|
postgresId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "mariadb"
|
||||||
|
? {
|
||||||
|
mariadbId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "mysql"
|
||||||
|
? {
|
||||||
|
mysqlId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "mongo"
|
||||||
|
? {
|
||||||
|
mongoId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "web-server"
|
||||||
|
? {
|
||||||
|
userId: id,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await createBackup({
|
||||||
|
destinationId: data.destinationId,
|
||||||
|
prefix: data.prefix,
|
||||||
|
schedule: data.schedule,
|
||||||
|
enabled: data.enabled,
|
||||||
|
database: data.database,
|
||||||
|
keepLatestCount: data.keepLatestCount ?? null,
|
||||||
|
databaseType: data.databaseType || databaseType,
|
||||||
|
serviceName: data.serviceName,
|
||||||
|
...getDatabaseId,
|
||||||
|
backupId: backupId ?? "",
|
||||||
|
backupType,
|
||||||
|
metadata: data.metadata,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success(`Backup ${backupId ? "Updated" : "Created"}`);
|
||||||
|
refetch();
|
||||||
|
setIsOpen(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(`Error ${backupId ? "updating" : "creating"} a backup`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{backupId ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10 size-8"
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
{backupId ? "Update Backup" : "Create Backup"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-2xl max-h-screen overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{backupId ? "Update Backup" : "Create Backup"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{backupId ? "Update a backup" : "Add a new backup"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="hook-form-add-backup"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="grid w-full gap-4"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{errorServices && (
|
||||||
|
<AlertBlock type="warning" className="[overflow-wrap:anywhere]">
|
||||||
|
{errorServices?.message}
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="databaseType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Type</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value as DatabaseType);
|
||||||
|
form.setValue("metadata", {});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select a database type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||||||
|
<SelectItem value="mariadb">MariaDB</SelectItem>
|
||||||
|
<SelectItem value="mysql">MySQL</SelectItem>
|
||||||
|
<SelectItem value="mongo">MongoDB</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="destinationId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="">
|
||||||
|
<FormLabel>Destination</FormLabel>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between !bg-input",
|
||||||
|
!field.value && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? "Loading...."
|
||||||
|
: field.value
|
||||||
|
? data?.find(
|
||||||
|
(destination) =>
|
||||||
|
destination.destinationId === field.value,
|
||||||
|
)?.name
|
||||||
|
: "Select Destination"}
|
||||||
|
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search Destination..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
{isLoading && (
|
||||||
|
<span className="py-6 text-center text-sm">
|
||||||
|
Loading Destinations....
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<CommandEmpty>No destinations found.</CommandEmpty>
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<CommandGroup>
|
||||||
|
{data?.map((destination) => (
|
||||||
|
<CommandItem
|
||||||
|
value={destination.destinationId}
|
||||||
|
key={destination.destinationId}
|
||||||
|
onSelect={() => {
|
||||||
|
form.setValue(
|
||||||
|
"destinationId",
|
||||||
|
destination.destinationId,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{destination.name}
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
destination.destinationId === field.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</ScrollArea>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<div className="flex flex-row items-end w-full gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serviceName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Service Name</FormLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value || undefined}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a service name" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{services?.map((service, index) => (
|
||||||
|
<SelectItem
|
||||||
|
value={service}
|
||||||
|
key={`${service}-${index}`}
|
||||||
|
>
|
||||||
|
{service}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{(!services || services.length === 0) && (
|
||||||
|
<SelectItem value="none" disabled>
|
||||||
|
Empty
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "fetch") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("fetch");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Fetch: Will clone the repository and load the
|
||||||
|
services
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "cache") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("cache");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Cache: If you previously deployed this
|
||||||
|
compose, it will read the services from the
|
||||||
|
last deployment/fetch from the repository
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="database"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
disabled={databaseType === "web-server"}
|
||||||
|
placeholder={"dokploy"}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="schedule"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
Schedule
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Cron expression format: minute hour day month
|
||||||
|
weekday
|
||||||
|
</p>
|
||||||
|
<p>Example: 0 0 * * * (daily at midnight)</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</FormLabel>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a predefined schedule" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{commonCronExpressions.map((expr) => (
|
||||||
|
<SelectItem key={expr.value} value={expr.value}>
|
||||||
|
{expr.label} ({expr.value})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="relative">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<FormDescription>
|
||||||
|
Choose a predefined schedule or enter a custom cron
|
||||||
|
expression
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="prefix"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Prefix Destination</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={"dokploy/"} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Use if you want to back up in a specific path of your
|
||||||
|
destination/bucket
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="keepLatestCount"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Keep the latest</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={"keeps all the backups if left empty"}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Optional. If provided, only keeps the latest N backups
|
||||||
|
in the cloud.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Enabled</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Enable or disable the backup
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<>
|
||||||
|
{form.watch("databaseType") === "postgres" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.postgres.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="postgres" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.watch("databaseType") === "mariadb" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mariadb.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="mariadb" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mariadb.databasePassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.watch("databaseType") === "mongo" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mongo.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="mongo" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mongo.databasePassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.watch("databaseType") === "mysql" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mysql.databaseRootPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Root Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
isLoading={isCreatingPostgresBackup}
|
||||||
|
form="hook-form-add-backup"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{backupId ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -23,89 +25,230 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
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";
|
||||||
import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
|
import copy from "copy-to-clipboard";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Copy,
|
||||||
|
DatabaseZap,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { ServiceType } from "../../application/advanced/show-resources";
|
import type { ServiceType } from "../../application/advanced/show-resources";
|
||||||
import { debounce } from "lodash";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
||||||
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
type DatabaseType =
|
||||||
import copy from "copy-to-clipboard";
|
| Exclude<ServiceType, "application" | "redis">
|
||||||
import { toast } from "sonner";
|
| "web-server";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
databaseId: string;
|
id: string;
|
||||||
databaseType: Exclude<ServiceType, "application" | "redis">;
|
databaseType?: DatabaseType;
|
||||||
serverId: string | null;
|
serverId?: string | null;
|
||||||
|
backupType?: "database" | "compose";
|
||||||
}
|
}
|
||||||
|
|
||||||
const RestoreBackupSchema = z.object({
|
const RestoreBackupSchema = z
|
||||||
destinationId: z
|
.object({
|
||||||
.string({
|
destinationId: z
|
||||||
required_error: "Please select a destination",
|
.string({
|
||||||
})
|
required_error: "Please select a destination",
|
||||||
.min(1, {
|
})
|
||||||
message: "Destination is required",
|
.min(1, {
|
||||||
}),
|
message: "Destination is required",
|
||||||
backupFile: z
|
}),
|
||||||
.string({
|
backupFile: z
|
||||||
required_error: "Please select a backup file",
|
.string({
|
||||||
})
|
required_error: "Please select a backup file",
|
||||||
.min(1, {
|
})
|
||||||
message: "Backup file is required",
|
.min(1, {
|
||||||
}),
|
message: "Backup file is required",
|
||||||
databaseName: z
|
}),
|
||||||
.string({
|
databaseName: z
|
||||||
required_error: "Please enter a database name",
|
.string({
|
||||||
})
|
required_error: "Please enter a database name",
|
||||||
.min(1, {
|
})
|
||||||
message: "Database name is required",
|
.min(1, {
|
||||||
}),
|
message: "Database name is required",
|
||||||
});
|
}),
|
||||||
|
databaseType: z
|
||||||
|
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
|
||||||
|
.optional(),
|
||||||
|
backupType: z.enum(["database", "compose"]).default("database"),
|
||||||
|
metadata: z
|
||||||
|
.object({
|
||||||
|
postgres: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mariadb: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mongo: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mysql: z
|
||||||
|
.object({
|
||||||
|
databaseRootPassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
serviceName: z.string().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.backupType === "compose" && !data.databaseType) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database type is required for compose backups",
|
||||||
|
path: ["databaseType"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
type RestoreBackup = z.infer<typeof RestoreBackupSchema>;
|
if (data.backupType === "compose" && !data.metadata?.serviceName) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Service name is required for compose backups",
|
||||||
|
path: ["metadata", "serviceName"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.backupType === "compose" && data.databaseType) {
|
||||||
|
if (data.databaseType === "postgres") {
|
||||||
|
if (!data.metadata?.postgres?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for PostgreSQL",
|
||||||
|
path: ["metadata", "postgres", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mariadb") {
|
||||||
|
if (!data.metadata?.mariadb?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for MariaDB",
|
||||||
|
path: ["metadata", "mariadb", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.metadata?.mariadb?.databasePassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database password is required for MariaDB",
|
||||||
|
path: ["metadata", "mariadb", "databasePassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mongo") {
|
||||||
|
if (!data.metadata?.mongo?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for MongoDB",
|
||||||
|
path: ["metadata", "mongo", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.metadata?.mongo?.databasePassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database password is required for MongoDB",
|
||||||
|
path: ["metadata", "mongo", "databasePassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mysql") {
|
||||||
|
if (!data.metadata?.mysql?.databaseRootPassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Root password is required for MySQL",
|
||||||
|
path: ["metadata", "mysql", "databaseRootPassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return "0 Bytes";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const RestoreBackup = ({
|
export const RestoreBackup = ({
|
||||||
databaseId,
|
id,
|
||||||
databaseType,
|
databaseType,
|
||||||
serverId,
|
serverId,
|
||||||
|
backupType = "database",
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||||
|
|
||||||
const { data: destinations = [] } = api.destination.all.useQuery();
|
const { data: destinations = [] } = api.destination.all.useQuery();
|
||||||
|
|
||||||
const form = useForm<RestoreBackup>({
|
const form = useForm<z.infer<typeof RestoreBackupSchema>>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
destinationId: "",
|
destinationId: "",
|
||||||
backupFile: "",
|
backupFile: "",
|
||||||
databaseName: "",
|
databaseName: databaseType === "web-server" ? "dokploy" : "",
|
||||||
|
databaseType:
|
||||||
|
backupType === "compose" ? ("postgres" as DatabaseType) : databaseType,
|
||||||
|
backupType: backupType,
|
||||||
|
metadata: {},
|
||||||
},
|
},
|
||||||
resolver: zodResolver(RestoreBackupSchema),
|
resolver: zodResolver(RestoreBackupSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const destionationId = form.watch("destinationId");
|
const destionationId = form.watch("destinationId");
|
||||||
|
const currentDatabaseType = form.watch("databaseType");
|
||||||
|
const metadata = form.watch("metadata");
|
||||||
|
|
||||||
const debouncedSetSearch = debounce((value: string) => {
|
const debouncedSetSearch = debounce((value: string) => {
|
||||||
|
setDebouncedSearchTerm(value);
|
||||||
|
}, 350);
|
||||||
|
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
setSearch(value);
|
setSearch(value);
|
||||||
}, 300);
|
debouncedSetSearch(value);
|
||||||
|
};
|
||||||
|
|
||||||
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
|
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
|
||||||
{
|
{
|
||||||
destinationId: destionationId,
|
destinationId: destionationId,
|
||||||
search,
|
search: debouncedSearchTerm,
|
||||||
serverId: serverId ?? "",
|
serverId: serverId ?? "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -117,16 +260,15 @@ export const RestoreBackup = ({
|
|||||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||||
const [isDeploying, setIsDeploying] = useState(false);
|
const [isDeploying, setIsDeploying] = useState(false);
|
||||||
|
|
||||||
// const { mutateAsync: restore, isLoading: isRestoring } =
|
|
||||||
// api.backup.restoreBackup.useMutation();
|
|
||||||
|
|
||||||
api.backup.restoreBackupWithLogs.useSubscription(
|
api.backup.restoreBackupWithLogs.useSubscription(
|
||||||
{
|
{
|
||||||
databaseId,
|
databaseId: id,
|
||||||
databaseType,
|
databaseType: currentDatabaseType as DatabaseType,
|
||||||
databaseName: form.watch("databaseName"),
|
databaseName: form.watch("databaseName"),
|
||||||
backupFile: form.watch("backupFile"),
|
backupFile: form.watch("backupFile"),
|
||||||
destinationId: form.watch("destinationId"),
|
destinationId: form.watch("destinationId"),
|
||||||
|
backupType: backupType,
|
||||||
|
metadata: metadata,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: isDeploying,
|
enabled: isDeploying,
|
||||||
@@ -148,10 +290,32 @@ export const RestoreBackup = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmit = async (_data: RestoreBackup) => {
|
const onSubmit = async (data: z.infer<typeof RestoreBackupSchema>) => {
|
||||||
|
if (backupType === "compose" && !data.databaseType) {
|
||||||
|
toast.error("Please select a database type");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log({ data });
|
||||||
setIsDeploying(true);
|
setIsDeploying(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [cacheType, setCacheType] = useState<"fetch" | "cache">("cache");
|
||||||
|
const {
|
||||||
|
data: services = [],
|
||||||
|
isLoading: isLoadingServices,
|
||||||
|
refetch: refetchServices,
|
||||||
|
} = api.compose.loadServices.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id,
|
||||||
|
type: cacheType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
enabled: backupType === "compose",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -160,7 +324,7 @@ export const RestoreBackup = ({
|
|||||||
Restore Backup
|
Restore Backup
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center">
|
<DialogTitle className="flex items-center">
|
||||||
<RotateCcw className="mr-2 size-4" />
|
<RotateCcw className="mr-2 size-4" />
|
||||||
@@ -265,7 +429,7 @@ export const RestoreBackup = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Popover>
|
<Popover modal>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
@@ -284,7 +448,8 @@ export const RestoreBackup = ({
|
|||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search backup files..."
|
placeholder="Search backup files..."
|
||||||
onValueChange={debouncedSetSearch}
|
value={search}
|
||||||
|
onValueChange={handleSearchChange}
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -301,26 +466,51 @@ export const RestoreBackup = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="h-64">
|
<ScrollArea className="h-64">
|
||||||
<CommandGroup>
|
<CommandGroup className="w-96">
|
||||||
{files.map((file) => (
|
{files?.map((file) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={file}
|
value={file.Path}
|
||||||
key={file}
|
key={file.Path}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
form.setValue("backupFile", file);
|
form.setValue("backupFile", file.Path);
|
||||||
|
if (file.IsDir) {
|
||||||
|
setSearch(`${file.Path}/`);
|
||||||
|
setDebouncedSearchTerm(`${file.Path}/`);
|
||||||
|
} else {
|
||||||
|
setSearch(file.Path);
|
||||||
|
setDebouncedSearchTerm(file.Path);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex w-full justify-between">
|
<div className="flex w-full flex-col gap-1">
|
||||||
<span>{file}</span>
|
<div className="flex w-full justify-between">
|
||||||
|
<span className="font-medium">
|
||||||
|
{file.Path}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
file.Path === field.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Size: {formatBytes(file.Size)}
|
||||||
|
</span>
|
||||||
|
{file.IsDir && (
|
||||||
|
<span className="text-blue-500">
|
||||||
|
Directory
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{file.Hashes?.MD5 && (
|
||||||
|
<span>MD5: {file.Hashes.MD5}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CheckIcon
|
|
||||||
className={cn(
|
|
||||||
"ml-auto h-4 w-4",
|
|
||||||
file === field.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
@@ -337,21 +527,274 @@ export const RestoreBackup = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="databaseName"
|
name="databaseName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="">
|
<FormItem>
|
||||||
<FormLabel>Database Name</FormLabel>
|
<FormLabel>Database Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="Enter database name" />
|
<Input
|
||||||
|
placeholder="Enter database name"
|
||||||
|
{...field}
|
||||||
|
disabled={databaseType === "web-server"}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="databaseType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Type</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(value: DatabaseType) => {
|
||||||
|
field.onChange(value);
|
||||||
|
form.setValue("metadata", {});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select database type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||||||
|
<SelectItem value="mariadb">MariaDB</SelectItem>
|
||||||
|
<SelectItem value="mongo">MongoDB</SelectItem>
|
||||||
|
<SelectItem value="mysql">MySQL</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.serviceName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Service Name</FormLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value || undefined}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a service name" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{services?.map((service, index) => (
|
||||||
|
<SelectItem
|
||||||
|
value={service}
|
||||||
|
key={`${service}-${index}`}
|
||||||
|
>
|
||||||
|
{service}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{(!services || services.length === 0) && (
|
||||||
|
<SelectItem value="none" disabled>
|
||||||
|
Empty
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "fetch") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("fetch");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Fetch: Will clone the repository and load the
|
||||||
|
services
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "cache") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("cache");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Cache: If you previously deployed this compose,
|
||||||
|
it will read the services from the last
|
||||||
|
deployment/fetch from the repository
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{currentDatabaseType === "postgres" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.postgres.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Enter database user" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentDatabaseType === "mariadb" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mariadb.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter database user"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mariadb.databasePassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter database password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentDatabaseType === "mongo" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mongo.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter database user"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mongo.databasePassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter database password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentDatabaseType === "mysql" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mysql.databaseRootPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Root Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter root password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isDeploying}
|
isLoading={isDeploying}
|
||||||
form="hook-form-restore-backup"
|
form="hook-form-restore-backup"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!form.watch("backupFile")}
|
// disabled={
|
||||||
|
// !form.watch("backupFile") ||
|
||||||
|
// (backupType === "compose" && !form.watch("databaseType"))
|
||||||
|
// }
|
||||||
>
|
>
|
||||||
Restore
|
Restore
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import {
|
||||||
|
MariadbIcon,
|
||||||
|
MongodbIcon,
|
||||||
|
MysqlIcon,
|
||||||
|
PostgresqlIcon,
|
||||||
|
} from "@/components/icons/data-tools-icons";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -13,48 +20,77 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { DatabaseBackup, Play, Trash2 } from "lucide-react";
|
import {
|
||||||
|
ClipboardList,
|
||||||
|
Database,
|
||||||
|
DatabaseBackup,
|
||||||
|
Play,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { ServiceType } from "../../application/advanced/show-resources";
|
import type { ServiceType } from "../../application/advanced/show-resources";
|
||||||
import { AddBackup } from "./add-backup";
|
import { ShowDeploymentsModal } from "../../application/deployments/show-deployments-modal";
|
||||||
import { UpdateBackup } from "./update-backup";
|
import { HandleBackup } from "./handle-backup";
|
||||||
import { RestoreBackup } from "./restore-backup";
|
import { RestoreBackup } from "./restore-backup";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
type: Exclude<ServiceType, "application" | "redis">;
|
databaseType?: Exclude<ServiceType, "application" | "redis"> | "web-server";
|
||||||
|
backupType?: "database" | "compose";
|
||||||
}
|
}
|
||||||
export const ShowBackups = ({ id, type }: Props) => {
|
export const ShowBackups = ({
|
||||||
|
id,
|
||||||
|
databaseType,
|
||||||
|
backupType = "database",
|
||||||
|
}: Props) => {
|
||||||
const [activeManualBackup, setActiveManualBackup] = useState<
|
const [activeManualBackup, setActiveManualBackup] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>();
|
>();
|
||||||
const queryMap = {
|
const queryMap =
|
||||||
postgres: () =>
|
backupType === "database"
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
? {
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
postgres: () =>
|
||||||
mariadb: () =>
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
mysql: () =>
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
};
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
mongo: () =>
|
||||||
|
api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
"web-server": () => api.user.getBackups.useQuery(),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
compose: () =>
|
||||||
|
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
const { data } = api.destination.all.useQuery();
|
const { data } = api.destination.all.useQuery();
|
||||||
const { data: postgres, refetch } = queryMap[type]
|
const key = backupType === "database" ? databaseType : "compose";
|
||||||
? queryMap[type]()
|
const query = queryMap[key as keyof typeof queryMap];
|
||||||
|
const { data: postgres, refetch } = query
|
||||||
|
? query()
|
||||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
const mutationMap = {
|
const mutationMap =
|
||||||
postgres: () => api.backup.manualBackupPostgres.useMutation(),
|
backupType === "database"
|
||||||
mysql: () => api.backup.manualBackupMySql.useMutation(),
|
? {
|
||||||
mariadb: () => api.backup.manualBackupMariadb.useMutation(),
|
postgres: api.backup.manualBackupPostgres.useMutation(),
|
||||||
mongo: () => api.backup.manualBackupMongo.useMutation(),
|
mysql: api.backup.manualBackupMySql.useMutation(),
|
||||||
};
|
mariadb: api.backup.manualBackupMariadb.useMutation(),
|
||||||
|
mongo: api.backup.manualBackupMongo.useMutation(),
|
||||||
|
"web-server": api.backup.manualBackupWebServer.useMutation(),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
compose: api.backup.manualBackupCompose.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutationMap[
|
const mutation = mutationMap[key as keyof typeof mutationMap];
|
||||||
type
|
|
||||||
]
|
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutation
|
||||||
? mutationMap[type]()
|
? mutation
|
||||||
: api.backup.manualBackupMongo.useMutation();
|
: api.backup.manualBackupMongo.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: deleteBackup, isLoading: isRemoving } =
|
const { mutateAsync: deleteBackup, isLoading: isRemoving } =
|
||||||
@@ -64,7 +100,10 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
|
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<CardTitle className="text-xl">Backups</CardTitle>
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
|
<Database className="size-6 text-muted-foreground" />
|
||||||
|
Backups
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Add backups to your database to save the data to a different
|
Add backups to your database to save the data to a different
|
||||||
provider.
|
provider.
|
||||||
@@ -73,18 +112,26 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
|
|
||||||
{postgres && postgres?.backups?.length > 0 && (
|
{postgres && postgres?.backups?.length > 0 && (
|
||||||
<div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto">
|
<div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto">
|
||||||
<AddBackup databaseId={id} databaseType={type} refetch={refetch} />
|
{databaseType !== "web-server" && (
|
||||||
|
<HandleBackup
|
||||||
|
id={id}
|
||||||
|
databaseType={databaseType}
|
||||||
|
backupType={backupType}
|
||||||
|
refetch={refetch}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<RestoreBackup
|
<RestoreBackup
|
||||||
databaseId={id}
|
id={id}
|
||||||
databaseType={type}
|
databaseType={databaseType}
|
||||||
serverId={postgres.serverId}
|
backupType={backupType}
|
||||||
|
serverId={"serverId" in postgres ? postgres.serverId : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
{data?.length === 0 ? (
|
{data?.length === 0 ? (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3 min-h-[35vh] justify-center">
|
||||||
<DatabaseBackup className="size-8 text-muted-foreground" />
|
<DatabaseBackup className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground text-center">
|
<span className="text-base text-muted-foreground text-center">
|
||||||
To create a backup it is required to set at least 1 provider.
|
To create a backup it is required to set at least 1 provider.
|
||||||
@@ -99,7 +146,7 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div className="flex flex-col gap-4 w-full">
|
||||||
{postgres?.backups.length === 0 ? (
|
{postgres?.backups.length === 0 ? (
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||||
<DatabaseBackup className="size-8 text-muted-foreground" />
|
<DatabaseBackup className="size-8 text-muted-foreground" />
|
||||||
@@ -107,132 +154,222 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
No backups configured
|
No backups configured
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||||||
<AddBackup
|
<HandleBackup
|
||||||
databaseId={id}
|
id={id}
|
||||||
databaseType={type}
|
databaseType={databaseType}
|
||||||
|
backupType={backupType}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
/>
|
/>
|
||||||
<RestoreBackup
|
<RestoreBackup
|
||||||
databaseId={id}
|
id={id}
|
||||||
databaseType={type}
|
databaseType={databaseType}
|
||||||
serverId={postgres.serverId}
|
backupType={backupType}
|
||||||
|
serverId={
|
||||||
|
"serverId" in postgres ? postgres.serverId : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col pt-2">
|
<div className="flex flex-col pt-2 gap-4">
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<AlertBlock title="Compose Backups">
|
||||||
|
Make sure the compose is running before creating a backup.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{postgres?.backups.map((backup) => (
|
{postgres?.backups.map((backup) => {
|
||||||
<div key={backup.backupId}>
|
const serverId =
|
||||||
<div className="flex w-full flex-col md:flex-row md:items-center justify-between gap-4 md:gap-10 border rounded-lg p-4">
|
"serverId" in postgres ? postgres.serverId : undefined;
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-6 flex-col gap-8">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Destination</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{backup.destination.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Database</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{backup.database}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Scheduled</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{backup.schedule}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Prefix Storage</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{backup.prefix}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Enabled</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{backup.enabled ? "Yes" : "No"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Keep Latest</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{backup.keepLatestCount || "All"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row gap-4">
|
|
||||||
<TooltipProvider delayDuration={0}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
isLoading={
|
|
||||||
isManualBackup &&
|
|
||||||
activeManualBackup === backup.backupId
|
|
||||||
}
|
|
||||||
onClick={async () => {
|
|
||||||
setActiveManualBackup(backup.backupId);
|
|
||||||
await manualBackup({
|
|
||||||
backupId: backup.backupId as string,
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
toast.success(
|
|
||||||
"Manual Backup Successful",
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error(
|
|
||||||
"Error creating the manual backup",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
setActiveManualBackup(undefined);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Play className="size-5 text-muted-foreground" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Run Manual Backup</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<UpdateBackup
|
return (
|
||||||
backupId={backup.backupId}
|
<div key={backup.backupId}>
|
||||||
refetch={refetch}
|
<div className="flex w-full flex-col md:flex-row md:items-start justify-between gap-4 border rounded-lg p-4 hover:bg-muted/50 transition-colors">
|
||||||
/>
|
<div className="flex flex-col w-full gap-4">
|
||||||
<DialogAction
|
<div className="flex items-center gap-3">
|
||||||
title="Delete Backup"
|
{backup.backupType === "compose" && (
|
||||||
description="Are you sure you want to delete this backup?"
|
<div className="flex items-center justify-center size-10 rounded-lg">
|
||||||
type="destructive"
|
{backup.databaseType === "postgres" && (
|
||||||
onClick={async () => {
|
<PostgresqlIcon className="size-7" />
|
||||||
await deleteBackup({
|
)}
|
||||||
backupId: backup.backupId,
|
{backup.databaseType === "mysql" && (
|
||||||
})
|
<MysqlIcon className="size-7" />
|
||||||
.then(() => {
|
)}
|
||||||
refetch();
|
{backup.databaseType === "mariadb" && (
|
||||||
toast.success("Backup deleted successfully");
|
<MariadbIcon className="size-7" />
|
||||||
})
|
)}
|
||||||
.catch(() => {
|
{backup.databaseType === "mongo" && (
|
||||||
toast.error("Error deleting backup");
|
<MongodbIcon className="size-7" />
|
||||||
});
|
)}
|
||||||
}}
|
</div>
|
||||||
>
|
)}
|
||||||
<Button
|
<div className="flex flex-col gap-1">
|
||||||
variant="ghost"
|
{backup.backupType === "compose" && (
|
||||||
size="icon"
|
<div className="flex items-center gap-2">
|
||||||
className="group hover:bg-red-500/10"
|
<h3 className="font-medium">
|
||||||
isLoading={isRemoving}
|
{backup.serviceName}
|
||||||
|
</h3>
|
||||||
|
<span className="px-1.5 py-0.5 rounded-full bg-muted text-xs font-medium capitalize">
|
||||||
|
{backup.databaseType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 rounded-full",
|
||||||
|
backup.enabled
|
||||||
|
? "bg-green-500"
|
||||||
|
: "bg-red-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{backup.enabled ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-x-8 gap-y-2">
|
||||||
|
<div className="min-w-[200px]">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Destination
|
||||||
|
</span>
|
||||||
|
<p className="font-medium text-sm mt-0.5">
|
||||||
|
{backup.destination.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[150px]">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Database
|
||||||
|
</span>
|
||||||
|
<p className="font-medium text-sm mt-0.5">
|
||||||
|
{backup.database}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[120px]">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Schedule
|
||||||
|
</span>
|
||||||
|
<p className="font-medium text-sm mt-0.5">
|
||||||
|
{backup.schedule}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[150px]">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Prefix Storage
|
||||||
|
</span>
|
||||||
|
<p className="font-medium text-sm mt-0.5">
|
||||||
|
{backup.prefix}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[100px]">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Keep Latest
|
||||||
|
</span>
|
||||||
|
<p className="font-medium text-sm mt-0.5">
|
||||||
|
{backup.keepLatestCount || "All"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row md:flex-col gap-1.5">
|
||||||
|
<ShowDeploymentsModal
|
||||||
|
id={backup.backupId}
|
||||||
|
type="backup"
|
||||||
|
serverId={serverId || undefined}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Button
|
||||||
</Button>
|
variant="ghost"
|
||||||
</DialogAction>
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
>
|
||||||
|
<ClipboardList className="size-4 transition-colors " />
|
||||||
|
</Button>
|
||||||
|
</ShowDeploymentsModal>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
isLoading={
|
||||||
|
isManualBackup &&
|
||||||
|
activeManualBackup === backup.backupId
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
setActiveManualBackup(backup.backupId);
|
||||||
|
await manualBackup({
|
||||||
|
backupId: backup.backupId as string,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success(
|
||||||
|
"Manual Backup Successful",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(
|
||||||
|
"Error creating the manual backup",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
setActiveManualBackup(undefined);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play className="size-4 " />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Run Manual Backup
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<HandleBackup
|
||||||
|
backupType={backup.backupType}
|
||||||
|
backupId={backup.backupId}
|
||||||
|
databaseType={backup.databaseType}
|
||||||
|
refetch={refetch}
|
||||||
|
/>
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Backup"
|
||||||
|
description="Are you sure you want to delete this backup?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteBackup({
|
||||||
|
backupId: backup.backupId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
refetch();
|
||||||
|
toast.success(
|
||||||
|
"Backup deleted successfully",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting backup");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10 size-8"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,322 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
} from "@/components/ui/command";
|
|
||||||
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 {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { CheckIcon, ChevronsUpDown, PenBoxIcon } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const UpdateBackupSchema = z.object({
|
|
||||||
destinationId: z.string().min(1, "Destination required"),
|
|
||||||
schedule: z.string().min(1, "Schedule (Cron) required"),
|
|
||||||
prefix: z.string().min(1, "Prefix required"),
|
|
||||||
enabled: z.boolean(),
|
|
||||||
database: z.string().min(1, "Database required"),
|
|
||||||
keepLatestCount: z.coerce.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type UpdateBackup = z.infer<typeof UpdateBackupSchema>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
backupId: string;
|
|
||||||
refetch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UpdateBackup = ({ backupId, refetch }: Props) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const { data, isLoading } = api.destination.all.useQuery();
|
|
||||||
const { data: backup } = api.backup.one.useQuery(
|
|
||||||
{
|
|
||||||
backupId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!backupId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync, isLoading: isLoadingUpdate } =
|
|
||||||
api.backup.update.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<UpdateBackup>({
|
|
||||||
defaultValues: {
|
|
||||||
database: "",
|
|
||||||
destinationId: "",
|
|
||||||
enabled: true,
|
|
||||||
prefix: "/",
|
|
||||||
schedule: "",
|
|
||||||
keepLatestCount: undefined,
|
|
||||||
},
|
|
||||||
resolver: zodResolver(UpdateBackupSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (backup) {
|
|
||||||
form.reset({
|
|
||||||
database: backup.database,
|
|
||||||
destinationId: backup.destinationId,
|
|
||||||
enabled: backup.enabled || false,
|
|
||||||
prefix: backup.prefix,
|
|
||||||
schedule: backup.schedule,
|
|
||||||
keepLatestCount: backup.keepLatestCount ? Number(backup.keepLatestCount) : undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [form, form.reset, backup]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: UpdateBackup) => {
|
|
||||||
await mutateAsync({
|
|
||||||
backupId,
|
|
||||||
destinationId: data.destinationId,
|
|
||||||
prefix: data.prefix,
|
|
||||||
schedule: data.schedule,
|
|
||||||
enabled: data.enabled,
|
|
||||||
database: data.database,
|
|
||||||
keepLatestCount: data.keepLatestCount as number | null,
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
toast.success("Backup Updated");
|
|
||||||
refetch();
|
|
||||||
setIsOpen(false);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error updating the Backup");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-blue-500/10 "
|
|
||||||
>
|
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Update Backup</DialogTitle>
|
|
||||||
<DialogDescription>Update the backup</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
id="hook-form-update-backup"
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="grid w-full gap-4"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="destinationId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="">
|
|
||||||
<FormLabel>Destination</FormLabel>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-between !bg-input",
|
|
||||||
!field.value && "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? "Loading...."
|
|
||||||
: field.value
|
|
||||||
? data?.find(
|
|
||||||
(destination) =>
|
|
||||||
destination.destinationId === field.value,
|
|
||||||
)?.name
|
|
||||||
: "Select Destination"}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search Destination..."
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
{isLoading && (
|
|
||||||
<span className="py-6 text-center text-sm">
|
|
||||||
Loading Destinations....
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<CommandEmpty>No destinations found.</CommandEmpty>
|
|
||||||
<ScrollArea className="h-64">
|
|
||||||
<CommandGroup>
|
|
||||||
{data?.map((destination) => (
|
|
||||||
<CommandItem
|
|
||||||
value={destination.destinationId}
|
|
||||||
key={destination.destinationId}
|
|
||||||
onSelect={() => {
|
|
||||||
form.setValue(
|
|
||||||
"destinationId",
|
|
||||||
destination.destinationId,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{destination.name}
|
|
||||||
<CheckIcon
|
|
||||||
className={cn(
|
|
||||||
"ml-auto h-4 w-4",
|
|
||||||
destination.destinationId === field.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</ScrollArea>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="database"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Database</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"dokploy"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="schedule"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Schedule (Cron)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"0 0 * * *"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="prefix"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Prefix Destination</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"dokploy/"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Use if you want to back up in a specific path of your
|
|
||||||
destination/bucket
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="keepLatestCount"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Keep the latest</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input type="number" placeholder={"keeps all the backups if left empty"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Optional. If provided, only keeps the latest N backups in the cloud.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Enabled</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Enable or disable the backup
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
isLoading={isLoadingUpdate}
|
|
||||||
form="hook-form-update-backup"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -140,7 +140,14 @@ export const DockerLogsId: React.FC<Props> = ({
|
|||||||
|
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
if (!isCurrentConnection) return;
|
if (!isCurrentConnection) return;
|
||||||
setRawLogs((prev) => prev + e.data);
|
setRawLogs((prev) => {
|
||||||
|
const updated = prev + e.data;
|
||||||
|
const splitLines = updated.split("\n");
|
||||||
|
if (splitLines.length > lines) {
|
||||||
|
return splitLines.slice(-lines).join("\n");
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
if (noDataTimeout) clearTimeout(noDataTimeout);
|
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,454 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Logo } from "@/components/shared/logo";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Calendar,
|
||||||
|
CheckIcon,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Copy,
|
||||||
|
CreditCard,
|
||||||
|
Fingerprint,
|
||||||
|
Key,
|
||||||
|
Server,
|
||||||
|
Settings2,
|
||||||
|
Shield,
|
||||||
|
UserIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type User = typeof authClient.$Infer.Session.user;
|
||||||
|
|
||||||
|
export const ImpersonationBar = () => {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
|
const [isImpersonating, setIsImpersonating] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showBar, setShowBar] = useState(false);
|
||||||
|
const { data } = api.user.get.useQuery();
|
||||||
|
|
||||||
|
const fetchUsers = async (search?: string) => {
|
||||||
|
try {
|
||||||
|
const session = await authClient.getSession();
|
||||||
|
if (session?.data?.session?.impersonatedBy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await authClient.admin.listUsers({
|
||||||
|
query: {
|
||||||
|
limit: 30,
|
||||||
|
...(search && {
|
||||||
|
searchField: "email",
|
||||||
|
searchOperator: "contains",
|
||||||
|
searchValue: search,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredUsers = response.data?.users.filter(
|
||||||
|
// @ts-ignore
|
||||||
|
(user) => user.allowImpersonation && data?.user?.email !== user.email,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.error) {
|
||||||
|
// @ts-ignore
|
||||||
|
setUsers(filteredUsers || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching users:", error);
|
||||||
|
toast.error("Error loading users");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImpersonate = async () => {
|
||||||
|
if (!selectedUser) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authClient.admin.impersonateUser({
|
||||||
|
userId: selectedUser.id,
|
||||||
|
});
|
||||||
|
setIsImpersonating(true);
|
||||||
|
setOpen(false);
|
||||||
|
|
||||||
|
toast.success("Successfully impersonating user", {
|
||||||
|
description: `You are now viewing as ${selectedUser.name || selectedUser.email}`,
|
||||||
|
});
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error impersonating user:", error);
|
||||||
|
toast.error("Error impersonating user");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStopImpersonating = async () => {
|
||||||
|
try {
|
||||||
|
await authClient.admin.stopImpersonating();
|
||||||
|
setIsImpersonating(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
setShowBar(false);
|
||||||
|
toast.success("Stopped impersonating user");
|
||||||
|
window.location.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error stopping impersonation:", error);
|
||||||
|
toast.error("Error stopping impersonation");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkImpersonation = async () => {
|
||||||
|
try {
|
||||||
|
const session = await authClient.getSession();
|
||||||
|
if (session?.data?.session?.impersonatedBy) {
|
||||||
|
setIsImpersonating(true);
|
||||||
|
setShowBar(true);
|
||||||
|
// setSelectedUser(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error checking impersonation status:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkImpersonation();
|
||||||
|
fetchUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"fixed bottom-4 right-4 z-50 rounded-full shadow-lg",
|
||||||
|
isImpersonating &&
|
||||||
|
!showBar &&
|
||||||
|
"bg-red-100 hover:bg-red-200 border-red-200",
|
||||||
|
)}
|
||||||
|
onClick={() => setShowBar(!showBar)}
|
||||||
|
>
|
||||||
|
<Settings2
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
isImpersonating && !showBar && "text-red-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{isImpersonating ? "Impersonation Controls" : "User Impersonation"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed bottom-0 left-0 right-0 bg-background border-t border-border p-4 flex items-center justify-center gap-4 z-40 transition-all duration-200 ease-in-out",
|
||||||
|
showBar ? "translate-y-0" : "translate-y-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4 px-4 md:px-20 w-full">
|
||||||
|
<Logo className="w-10 h-10" />
|
||||||
|
{!isImpersonating ? (
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="w-[300px] justify-between"
|
||||||
|
>
|
||||||
|
{selectedUser ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||||
|
<span className="truncate flex flex-col items-start">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{selectedUser.name || ""}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{selectedUser.email}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserIcon className="mr-2 h-4 w-4" />
|
||||||
|
<span>Select user to impersonate</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[300px] p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search users by email or name..."
|
||||||
|
onValueChange={(search) => {
|
||||||
|
fetchUsers(search);
|
||||||
|
}}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-6 text-center text-sm">
|
||||||
|
Loading users...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CommandEmpty>No users found.</CommandEmpty>
|
||||||
|
<CommandList>
|
||||||
|
<CommandGroup heading="All Users">
|
||||||
|
{users.map((user) => (
|
||||||
|
<CommandItem
|
||||||
|
key={user.id}
|
||||||
|
value={user.email}
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2 flex-1">
|
||||||
|
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span className="flex flex-col items-start">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{user.name || ""}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{user.email} • {user.role}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
selectedUser?.id === user.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<Button
|
||||||
|
onClick={handleImpersonate}
|
||||||
|
disabled={!selectedUser}
|
||||||
|
variant="default"
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
Impersonate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-4 w-full flex-wrap">
|
||||||
|
<div className="flex items-center gap-4 flex-1 flex-wrap">
|
||||||
|
<Avatar className="h-10 w-10">
|
||||||
|
<AvatarImage
|
||||||
|
src={data?.user?.image || ""}
|
||||||
|
alt={data?.user?.name || ""}
|
||||||
|
/>
|
||||||
|
<AvatarFallback>
|
||||||
|
{data?.user?.name?.slice(0, 2).toUpperCase() || "U"}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="gap-1 py-1 text-yellow-500 bg-yellow-50/20"
|
||||||
|
>
|
||||||
|
<Shield className="h-3 w-3" />
|
||||||
|
Impersonating
|
||||||
|
</Badge>
|
||||||
|
<span className="font-medium">
|
||||||
|
{data?.user?.name || ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<UserIcon className="h-3 w-3" />
|
||||||
|
{data?.user?.email} • {data?.role}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Key className="h-3 w-3" />
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
ID: {data?.user?.id?.slice(0, 8)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-4 w-4 hover:bg-muted/50"
|
||||||
|
onClick={() => {
|
||||||
|
if (data?.id) {
|
||||||
|
copy(data.id);
|
||||||
|
toast.success("ID copied to clipboard");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Building2 className="h-3 w-3" />
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
Org: {data?.organizationId?.slice(0, 8)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-4 w-4 hover:bg-muted/50"
|
||||||
|
onClick={() => {
|
||||||
|
if (data?.organizationId) {
|
||||||
|
copy(data.organizationId);
|
||||||
|
toast.success(
|
||||||
|
"Organization ID copied to clipboard",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{data?.user?.stripeCustomerId && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CreditCard className="h-3 w-3" />
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
Customer:
|
||||||
|
{data?.user?.stripeCustomerId?.slice(0, 8)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-4 w-4 hover:bg-muted/50"
|
||||||
|
onClick={() => {
|
||||||
|
copy(data?.user?.stripeCustomerId || "");
|
||||||
|
toast.success(
|
||||||
|
"Stripe Customer ID copied to clipboard",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{data?.user?.stripeSubscriptionId && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CreditCard className="h-3 w-3" />
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
Sub: {data?.user?.stripeSubscriptionId?.slice(0, 8)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-4 w-4 hover:bg-muted/50"
|
||||||
|
onClick={() => {
|
||||||
|
copy(data.user.stripeSubscriptionId || "");
|
||||||
|
toast.success(
|
||||||
|
"Stripe Subscription ID copied to clipboard",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{data?.user?.serversQuantity !== undefined && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Server className="h-3 w-3" />
|
||||||
|
<span>Servers: {data.user.serversQuantity}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{data?.createdAt && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
Created:{" "}
|
||||||
|
{format(new Date(data.createdAt), "MMM d, yyyy")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="flex items-center gap-1 cursor-default">
|
||||||
|
<Fingerprint
|
||||||
|
className={cn(
|
||||||
|
"h-3 w-3",
|
||||||
|
data?.user?.twoFactorEnabled
|
||||||
|
? "text-green-500"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
data?.user?.twoFactorEnabled
|
||||||
|
? "green"
|
||||||
|
: "secondary"
|
||||||
|
}
|
||||||
|
className="text-[10px] px-1 py-0"
|
||||||
|
>
|
||||||
|
2FA{" "}
|
||||||
|
{data?.user?.twoFactorEnabled
|
||||||
|
? "Enabled"
|
||||||
|
: "Disabled"}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
Two-Factor Authentication Status
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={handleStopImpersonating}
|
||||||
|
variant="secondary"
|
||||||
|
className="gap-2"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<XIcon className="w-4 h-4" />
|
||||||
|
Stop Impersonating
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -27,145 +27,149 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const DockerProviderSchema = z.object({
|
const DockerProviderSchema = z.object({
|
||||||
externalPort: z.preprocess((a) => {
|
externalPort: z.preprocess((a) => {
|
||||||
if (a !== null) {
|
if (a !== null) {
|
||||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||||
return Number.isNaN(parsed) ? null : parsed;
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()),
|
}, z
|
||||||
|
.number()
|
||||||
|
.gte(0, "Range must be 0 - 65535")
|
||||||
|
.lte(65535, "Range must be 0 - 65535")
|
||||||
|
.nullable()),
|
||||||
});
|
});
|
||||||
|
|
||||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mariadbId: string;
|
mariadbId: string;
|
||||||
}
|
}
|
||||||
export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||||
const { data: ip } = api.settings.getIp.useQuery();
|
const { data: ip } = api.settings.getIp.useQuery();
|
||||||
const { data, refetch } = api.mariadb.one.useQuery({ mariadbId });
|
const { data, refetch } = api.mariadb.one.useQuery({ mariadbId });
|
||||||
const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation();
|
const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation();
|
||||||
const [connectionUrl, setConnectionUrl] = useState("");
|
const [connectionUrl, setConnectionUrl] = useState("");
|
||||||
const getIp = data?.server?.ipAddress || ip;
|
const getIp = data?.server?.ipAddress || ip;
|
||||||
const form = useForm<DockerProvider>({
|
const form = useForm<DockerProvider>({
|
||||||
defaultValues: {},
|
defaultValues: {},
|
||||||
resolver: zodResolver(DockerProviderSchema),
|
resolver: zodResolver(DockerProviderSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.externalPort) {
|
if (data?.externalPort) {
|
||||||
form.reset({
|
form.reset({
|
||||||
externalPort: data.externalPort,
|
externalPort: data.externalPort,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
|
|
||||||
const onSubmit = async (values: DockerProvider) => {
|
const onSubmit = async (values: DockerProvider) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
externalPort: values.externalPort,
|
externalPort: values.externalPort,
|
||||||
mariadbId,
|
mariadbId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("External Port updated");
|
toast.success("External Port updated");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error saving the external port");
|
toast.error("Error saving the external port");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const buildConnectionUrl = () => {
|
const buildConnectionUrl = () => {
|
||||||
const port = form.watch("externalPort") || data?.externalPort;
|
const port = form.watch("externalPort") || data?.externalPort;
|
||||||
|
|
||||||
return `mariadb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
|
return `mariadb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
setConnectionUrl(buildConnectionUrl());
|
setConnectionUrl(buildConnectionUrl());
|
||||||
}, [
|
}, [
|
||||||
data?.appName,
|
data?.appName,
|
||||||
data?.externalPort,
|
data?.externalPort,
|
||||||
data?.databasePassword,
|
data?.databasePassword,
|
||||||
form,
|
form,
|
||||||
data?.databaseName,
|
data?.databaseName,
|
||||||
data?.databaseUser,
|
data?.databaseUser,
|
||||||
getIp,
|
getIp,
|
||||||
]);
|
]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
In order to make the database reachable trought internet is
|
In order to make the database reachable trought internet is
|
||||||
required to set a port, make sure the port is not used by another
|
required to set a port, make sure the port is not used by another
|
||||||
application or database
|
application or database
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-col gap-4">
|
<CardContent className="flex w-full flex-col gap-4">
|
||||||
{!getIp && (
|
{!getIp && (
|
||||||
<AlertBlock type="warning">
|
<AlertBlock type="warning">
|
||||||
You need to set an IP address in your{" "}
|
You need to set an IP address in your{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings/server"
|
href="/dashboard/settings/server"
|
||||||
className="text-primary"
|
className="text-primary"
|
||||||
>
|
>
|
||||||
{data?.serverId
|
{data?.serverId
|
||||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||||
: "Web Server -> Server -> Update Server IP"}
|
: "Web Server -> Server -> Update Server IP"}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
to fix the database url connection.
|
to fix the database url connection.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
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="md:col-span-2 space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="externalPort"
|
name="externalPort"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>External Port (Internet)</FormLabel>
|
<FormLabel>External Port (Internet)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="3306"
|
placeholder="3306"
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value || ""}
|
value={field.value || ""}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!!data?.externalPort && (
|
{!!data?.externalPort && (
|
||||||
<div className="grid w-full gap-8">
|
<div className="grid w-full gap-8">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* jdbc:mariadb://5.161.59.207:3306/pixel-calculate?user=mariadb&password=HdVXfq6hM7W7F1 */}
|
{/* jdbc:mariadb://5.161.59.207:3306/pixel-calculate?user=mariadb&password=HdVXfq6hM7W7F1 */}
|
||||||
<Label>External Host</Label>
|
<Label>External Host</Label>
|
||||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" isLoading={isLoading}>
|
<Button type="submit" isLoading={isLoading}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,144 +27,148 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const DockerProviderSchema = z.object({
|
const DockerProviderSchema = z.object({
|
||||||
externalPort: z.preprocess((a) => {
|
externalPort: z.preprocess((a) => {
|
||||||
if (a !== null) {
|
if (a !== null) {
|
||||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||||
return Number.isNaN(parsed) ? null : parsed;
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()),
|
}, z
|
||||||
|
.number()
|
||||||
|
.gte(0, "Range must be 0 - 65535")
|
||||||
|
.lte(65535, "Range must be 0 - 65535")
|
||||||
|
.nullable()),
|
||||||
});
|
});
|
||||||
|
|
||||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mongoId: string;
|
mongoId: string;
|
||||||
}
|
}
|
||||||
export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||||
const { data: ip } = api.settings.getIp.useQuery();
|
const { data: ip } = api.settings.getIp.useQuery();
|
||||||
const { data, refetch } = api.mongo.one.useQuery({ mongoId });
|
const { data, refetch } = api.mongo.one.useQuery({ mongoId });
|
||||||
const { mutateAsync, isLoading } = api.mongo.saveExternalPort.useMutation();
|
const { mutateAsync, isLoading } = api.mongo.saveExternalPort.useMutation();
|
||||||
const [connectionUrl, setConnectionUrl] = useState("");
|
const [connectionUrl, setConnectionUrl] = useState("");
|
||||||
const getIp = data?.server?.ipAddress || ip;
|
const getIp = data?.server?.ipAddress || ip;
|
||||||
const form = useForm<DockerProvider>({
|
const form = useForm<DockerProvider>({
|
||||||
defaultValues: {},
|
defaultValues: {},
|
||||||
resolver: zodResolver(DockerProviderSchema),
|
resolver: zodResolver(DockerProviderSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.externalPort) {
|
if (data?.externalPort) {
|
||||||
form.reset({
|
form.reset({
|
||||||
externalPort: data.externalPort,
|
externalPort: data.externalPort,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
|
|
||||||
const onSubmit = async (values: DockerProvider) => {
|
const onSubmit = async (values: DockerProvider) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
externalPort: values.externalPort,
|
externalPort: values.externalPort,
|
||||||
mongoId,
|
mongoId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("External Port updated");
|
toast.success("External Port updated");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error saving the external port");
|
toast.error("Error saving the external port");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const buildConnectionUrl = () => {
|
const buildConnectionUrl = () => {
|
||||||
const port = form.watch("externalPort") || data?.externalPort;
|
const port = form.watch("externalPort") || data?.externalPort;
|
||||||
|
|
||||||
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
|
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
setConnectionUrl(buildConnectionUrl());
|
setConnectionUrl(buildConnectionUrl());
|
||||||
}, [
|
}, [
|
||||||
data?.appName,
|
data?.appName,
|
||||||
data?.externalPort,
|
data?.externalPort,
|
||||||
data?.databasePassword,
|
data?.databasePassword,
|
||||||
form,
|
form,
|
||||||
data?.databaseUser,
|
data?.databaseUser,
|
||||||
getIp,
|
getIp,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
In order to make the database reachable trought internet is
|
In order to make the database reachable trought internet is
|
||||||
required to set a port, make sure the port is not used by another
|
required to set a port, make sure the port is not used by another
|
||||||
application or database
|
application or database
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-col gap-4">
|
<CardContent className="flex w-full flex-col gap-4">
|
||||||
{!getIp && (
|
{!getIp && (
|
||||||
<AlertBlock type="warning">
|
<AlertBlock type="warning">
|
||||||
You need to set an IP address in your{" "}
|
You need to set an IP address in your{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings/server"
|
href="/dashboard/settings/server"
|
||||||
className="text-primary"
|
className="text-primary"
|
||||||
>
|
>
|
||||||
{data?.serverId
|
{data?.serverId
|
||||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||||
: "Web Server -> Server -> Update Server IP"}
|
: "Web Server -> Server -> Update Server IP"}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
to fix the database url connection.
|
to fix the database url connection.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-4 ">
|
<div className="grid grid-cols-2 gap-4 ">
|
||||||
<div className="col-span-2 space-y-4">
|
<div className="col-span-2 space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="externalPort"
|
name="externalPort"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>External Port (Internet)</FormLabel>
|
<FormLabel>External Port (Internet)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="27017"
|
placeholder="27017"
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value || ""}
|
value={field.value || ""}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!!data?.externalPort && (
|
{!!data?.externalPort && (
|
||||||
<div className="grid w-full gap-8">
|
<div className="grid w-full gap-8">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Label>External Host</Label>
|
<Label>External Host</Label>
|
||||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" isLoading={isLoading}>
|
<Button type="submit" isLoading={isLoading}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,144 +27,148 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const DockerProviderSchema = z.object({
|
const DockerProviderSchema = z.object({
|
||||||
externalPort: z.preprocess((a) => {
|
externalPort: z.preprocess((a) => {
|
||||||
if (a !== null) {
|
if (a !== null) {
|
||||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||||
return Number.isNaN(parsed) ? null : parsed;
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()),
|
}, z
|
||||||
|
.number()
|
||||||
|
.gte(0, "Range must be 0 - 65535")
|
||||||
|
.lte(65535, "Range must be 0 - 65535")
|
||||||
|
.nullable()),
|
||||||
});
|
});
|
||||||
|
|
||||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mysqlId: string;
|
mysqlId: string;
|
||||||
}
|
}
|
||||||
export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||||
const { data: ip } = api.settings.getIp.useQuery();
|
const { data: ip } = api.settings.getIp.useQuery();
|
||||||
const { data, refetch } = api.mysql.one.useQuery({ mysqlId });
|
const { data, refetch } = api.mysql.one.useQuery({ mysqlId });
|
||||||
const { mutateAsync, isLoading } = api.mysql.saveExternalPort.useMutation();
|
const { mutateAsync, isLoading } = api.mysql.saveExternalPort.useMutation();
|
||||||
const [connectionUrl, setConnectionUrl] = useState("");
|
const [connectionUrl, setConnectionUrl] = useState("");
|
||||||
const getIp = data?.server?.ipAddress || ip;
|
const getIp = data?.server?.ipAddress || ip;
|
||||||
const form = useForm<DockerProvider>({
|
const form = useForm<DockerProvider>({
|
||||||
defaultValues: {},
|
defaultValues: {},
|
||||||
resolver: zodResolver(DockerProviderSchema),
|
resolver: zodResolver(DockerProviderSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.externalPort) {
|
if (data?.externalPort) {
|
||||||
form.reset({
|
form.reset({
|
||||||
externalPort: data.externalPort,
|
externalPort: data.externalPort,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
|
|
||||||
const onSubmit = async (values: DockerProvider) => {
|
const onSubmit = async (values: DockerProvider) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
externalPort: values.externalPort,
|
externalPort: values.externalPort,
|
||||||
mysqlId,
|
mysqlId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("External Port updated");
|
toast.success("External Port updated");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error saving the external port");
|
toast.error("Error saving the external port");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const buildConnectionUrl = () => {
|
const buildConnectionUrl = () => {
|
||||||
const port = form.watch("externalPort") || data?.externalPort;
|
const port = form.watch("externalPort") || data?.externalPort;
|
||||||
|
|
||||||
return `mysql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
|
return `mysql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
setConnectionUrl(buildConnectionUrl());
|
setConnectionUrl(buildConnectionUrl());
|
||||||
}, [
|
}, [
|
||||||
data?.appName,
|
data?.appName,
|
||||||
data?.externalPort,
|
data?.externalPort,
|
||||||
data?.databasePassword,
|
data?.databasePassword,
|
||||||
data?.databaseName,
|
data?.databaseName,
|
||||||
data?.databaseUser,
|
data?.databaseUser,
|
||||||
form,
|
form,
|
||||||
getIp,
|
getIp,
|
||||||
]);
|
]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
In order to make the database reachable trought internet is
|
In order to make the database reachable trought internet is
|
||||||
required to set a port, make sure the port is not used by another
|
required to set a port, make sure the port is not used by another
|
||||||
application or database
|
application or database
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-col gap-4">
|
<CardContent className="flex w-full flex-col gap-4">
|
||||||
{!getIp && (
|
{!getIp && (
|
||||||
<AlertBlock type="warning">
|
<AlertBlock type="warning">
|
||||||
You need to set an IP address in your{" "}
|
You need to set an IP address in your{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings/server"
|
href="/dashboard/settings/server"
|
||||||
className="text-primary"
|
className="text-primary"
|
||||||
>
|
>
|
||||||
{data?.serverId
|
{data?.serverId
|
||||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||||
: "Web Server -> Server -> Update Server IP"}
|
: "Web Server -> Server -> Update Server IP"}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
to fix the database url connection.
|
to fix the database url connection.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-4 ">
|
<div className="grid grid-cols-2 gap-4 ">
|
||||||
<div className="col-span-2 space-y-4">
|
<div className="col-span-2 space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="externalPort"
|
name="externalPort"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>External Port (Internet)</FormLabel>
|
<FormLabel>External Port (Internet)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="3306"
|
placeholder="3306"
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value || ""}
|
value={field.value || ""}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!!data?.externalPort && (
|
{!!data?.externalPort && (
|
||||||
<div className="grid w-full gap-8">
|
<div className="grid w-full gap-8">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Label>External Host</Label>
|
<Label>External Host</Label>
|
||||||
<ToggleVisibilityInput disabled value={connectionUrl} />
|
<ToggleVisibilityInput disabled value={connectionUrl} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" isLoading={isLoading}>
|
<Button type="submit" isLoading={isLoading}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,146 +27,150 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const DockerProviderSchema = z.object({
|
const DockerProviderSchema = z.object({
|
||||||
externalPort: z.preprocess((a) => {
|
externalPort: z.preprocess((a) => {
|
||||||
if (a !== null) {
|
if (a !== null) {
|
||||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||||
return Number.isNaN(parsed) ? null : parsed;
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()),
|
}, z
|
||||||
|
.number()
|
||||||
|
.gte(0, "Range must be 0 - 65535")
|
||||||
|
.lte(65535, "Range must be 0 - 65535")
|
||||||
|
.nullable()),
|
||||||
});
|
});
|
||||||
|
|
||||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
postgresId: string;
|
postgresId: string;
|
||||||
}
|
}
|
||||||
export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||||
const { data: ip } = api.settings.getIp.useQuery();
|
const { data: ip } = api.settings.getIp.useQuery();
|
||||||
const { data, refetch } = api.postgres.one.useQuery({ postgresId });
|
const { data, refetch } = api.postgres.one.useQuery({ postgresId });
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isLoading } =
|
||||||
api.postgres.saveExternalPort.useMutation();
|
api.postgres.saveExternalPort.useMutation();
|
||||||
const getIp = data?.server?.ipAddress || ip;
|
const getIp = data?.server?.ipAddress || ip;
|
||||||
const [connectionUrl, setConnectionUrl] = useState("");
|
const [connectionUrl, setConnectionUrl] = useState("");
|
||||||
|
|
||||||
const form = useForm<DockerProvider>({
|
const form = useForm<DockerProvider>({
|
||||||
defaultValues: {},
|
defaultValues: {},
|
||||||
resolver: zodResolver(DockerProviderSchema),
|
resolver: zodResolver(DockerProviderSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.externalPort) {
|
if (data?.externalPort) {
|
||||||
form.reset({
|
form.reset({
|
||||||
externalPort: data.externalPort,
|
externalPort: data.externalPort,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
|
|
||||||
const onSubmit = async (values: DockerProvider) => {
|
const onSubmit = async (values: DockerProvider) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
externalPort: values.externalPort,
|
externalPort: values.externalPort,
|
||||||
postgresId,
|
postgresId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("External Port updated");
|
toast.success("External Port updated");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error saving the external port");
|
toast.error("Error saving the external port");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const buildConnectionUrl = () => {
|
const buildConnectionUrl = () => {
|
||||||
const port = form.watch("externalPort") || data?.externalPort;
|
const port = form.watch("externalPort") || data?.externalPort;
|
||||||
|
|
||||||
return `postgresql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
|
return `postgresql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
setConnectionUrl(buildConnectionUrl());
|
setConnectionUrl(buildConnectionUrl());
|
||||||
}, [
|
}, [
|
||||||
data?.appName,
|
data?.appName,
|
||||||
data?.externalPort,
|
data?.externalPort,
|
||||||
data?.databasePassword,
|
data?.databasePassword,
|
||||||
form,
|
form,
|
||||||
data?.databaseName,
|
data?.databaseName,
|
||||||
getIp,
|
getIp,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
In order to make the database reachable trought internet is
|
In order to make the database reachable trought internet is
|
||||||
required to set a port, make sure the port is not used by another
|
required to set a port, make sure the port is not used by another
|
||||||
application or database
|
application or database
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-col gap-4">
|
<CardContent className="flex w-full flex-col gap-4">
|
||||||
{!getIp && (
|
{!getIp && (
|
||||||
<AlertBlock type="warning">
|
<AlertBlock type="warning">
|
||||||
You need to set an IP address in your{" "}
|
You need to set an IP address in your{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings/server"
|
href="/dashboard/settings/server"
|
||||||
className="text-primary"
|
className="text-primary"
|
||||||
>
|
>
|
||||||
{data?.serverId
|
{data?.serverId
|
||||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||||
: "Web Server -> Server -> Update Server IP"}
|
: "Web Server -> Server -> Update Server IP"}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
to fix the database url connection.
|
to fix the database url connection.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-4 ">
|
<div className="grid grid-cols-2 gap-4 ">
|
||||||
<div className="col-span-2 space-y-4">
|
<div className="col-span-2 space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="externalPort"
|
name="externalPort"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>External Port (Internet)</FormLabel>
|
<FormLabel>External Port (Internet)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="5432"
|
placeholder="5432"
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value || ""}
|
value={field.value || ""}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!!data?.externalPort && (
|
{!!data?.externalPort && (
|
||||||
<div className="grid w-full gap-8">
|
<div className="grid w-full gap-8">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Label>External Host</Label>
|
<Label>External Host</Label>
|
||||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" isLoading={isLoading}>
|
<Button type="submit" isLoading={isLoading}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,58 +5,58 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
postgresId: string;
|
postgresId: string;
|
||||||
}
|
}
|
||||||
export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||||
const { data } = api.postgres.one.useQuery({ postgresId });
|
const { data } = api.postgres.one.useQuery({ postgresId });
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">Internal Credentials</CardTitle>
|
<CardTitle className="text-xl">Internal Credentials</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-row gap-4">
|
<CardContent className="flex w-full flex-row gap-4">
|
||||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>User</Label>
|
<Label>User</Label>
|
||||||
<Input disabled value={data?.databaseUser} />
|
<Input disabled value={data?.databaseUser} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Database Name</Label>
|
<Label>Database Name</Label>
|
||||||
<Input disabled value={data?.databaseName} />
|
<Input disabled value={data?.databaseName} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-row gap-4">
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
value={data?.databasePassword}
|
value={data?.databasePassword}
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Internal Port (Container)</Label>
|
<Label>Internal Port (Container)</Label>
|
||||||
<Input disabled value="5432" />
|
<Input disabled value="5432" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Internal Host</Label>
|
<Label>Internal Host</Label>
|
||||||
<Input disabled value={data?.appName} />
|
<Input disabled value={data?.appName} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Internal Connection URL </Label>
|
<Label>Internal Connection URL </Label>
|
||||||
<ToggleVisibilityInput
|
<ToggleVisibilityInput
|
||||||
disabled
|
disabled
|
||||||
value={`postgresql://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5432/${data?.databaseName}`}
|
value={`postgresql://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5432/${data?.databaseName}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
// ReplyError: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk. Commands that may modify the data set are disabled, because this instance is configured to report errors during writes if RDB snapshotting fails (stop-w
|
// ReplyError: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk. Commands that may modify the data set are disabled, because this instance is configured to report errors during writes if RDB snapshotting fails (stop-w
|
||||||
|
|||||||
@@ -28,139 +28,139 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const updatePostgresSchema = z.object({
|
const updatePostgresSchema = z.object({
|
||||||
name: z.string().min(1, {
|
name: z.string().min(1, {
|
||||||
message: "Name is required",
|
message: "Name is required",
|
||||||
}),
|
}),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type UpdatePostgres = z.infer<typeof updatePostgresSchema>;
|
type UpdatePostgres = z.infer<typeof updatePostgresSchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
postgresId: string;
|
postgresId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UpdatePostgres = ({ postgresId }: Props) => {
|
export const UpdatePostgres = ({ postgresId }: 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 } =
|
||||||
api.postgres.update.useMutation();
|
api.postgres.update.useMutation();
|
||||||
const { data } = api.postgres.one.useQuery(
|
const { data } = api.postgres.one.useQuery(
|
||||||
{
|
{
|
||||||
postgresId,
|
postgresId,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!postgresId,
|
enabled: !!postgresId,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
const form = useForm<UpdatePostgres>({
|
const form = useForm<UpdatePostgres>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
description: data?.description ?? "",
|
description: data?.description ?? "",
|
||||||
name: data?.name ?? "",
|
name: data?.name ?? "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(updatePostgresSchema),
|
resolver: zodResolver(updatePostgresSchema),
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
description: data.description ?? "",
|
description: data.description ?? "",
|
||||||
name: data.name,
|
name: data.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form, form.reset]);
|
}, [data, form, form.reset]);
|
||||||
|
|
||||||
const onSubmit = async (formData: UpdatePostgres) => {
|
const onSubmit = async (formData: UpdatePostgres) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
postgresId: postgresId,
|
postgresId: postgresId,
|
||||||
description: formData.description || "",
|
description: formData.description || "",
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Postgres updated successfully");
|
toast.success("Postgres updated successfully");
|
||||||
utils.postgres.one.invalidate({
|
utils.postgres.one.invalidate({
|
||||||
postgresId: postgresId,
|
postgresId: postgresId,
|
||||||
});
|
});
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error updating Postgres");
|
toast.error("Error updating Postgres");
|
||||||
})
|
})
|
||||||
.finally(() => {});
|
.finally(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="group hover:bg-blue-500/10 focus-visible:ring-2 focus-visible:ring-offset-2"
|
className="group hover:bg-blue-500/10 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<PenBox className="size-3.5 text-primary group-hover:text-blue-500" />
|
<PenBox className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
</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-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Modify Postgres</DialogTitle>
|
<DialogTitle>Modify Postgres</DialogTitle>
|
||||||
<DialogDescription>Update the Postgres data</DialogDescription>
|
<DialogDescription>Update the Postgres data</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
{isError && <AlertBlock type="error">{error?.message}</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">
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
id="hook-form-update-postgres"
|
id="hook-form-update-postgres"
|
||||||
className="grid w-full gap-4 "
|
className="grid w-full gap-4 "
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Vandelay Industries" {...field} />
|
<Input placeholder="Vandelay Industries" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Description</FormLabel>
|
<FormLabel>Description</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Description about your project..."
|
placeholder="Description about your project..."
|
||||||
className="resize-none"
|
className="resize-none"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
form="hook-form-update-postgres"
|
form="hook-form-update-postgres"
|
||||||
type="submit"
|
type="submit"
|
||||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
Update
|
Update
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -145,10 +145,8 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
|
|||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value?.trim() || "";
|
const val = e.target.value?.trim() || "";
|
||||||
form.setValue(
|
const serviceName = slugify(val);
|
||||||
"appName",
|
form.setValue("appName", `${slug}-${serviceName}`);
|
||||||
`${slug}-${val.toLowerCase().replaceAll(" ", "-")}`,
|
|
||||||
);
|
|
||||||
field.onChange(val);
|
field.onChange(val);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -152,10 +152,8 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
|
|||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value?.trim() || "";
|
const val = e.target.value?.trim() || "";
|
||||||
form.setValue(
|
const serviceName = slugify(val);
|
||||||
"appName",
|
form.setValue("appName", `${slug}-${serviceName}`);
|
||||||
`${slug}-${val.toLowerCase()}`,
|
|
||||||
);
|
|
||||||
field.onChange(val);
|
field.onChange(val);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -363,10 +363,8 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
|
|||||||
{...field}
|
{...field}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const val = e.target.value?.trim() || "";
|
const val = e.target.value?.trim() || "";
|
||||||
form.setValue(
|
const serviceName = slugify(val);
|
||||||
"appName",
|
form.setValue("appName", `${slug}-${serviceName}`);
|
||||||
`${slug}-${val.toLowerCase()}`,
|
|
||||||
);
|
|
||||||
field.onChange(val);
|
field.onChange(val);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ import {
|
|||||||
SearchIcon,
|
SearchIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url";
|
const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url";
|
||||||
@@ -307,7 +307,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
|||||||
>
|
>
|
||||||
{templates?.map((template) => (
|
{templates?.map((template) => (
|
||||||
<div
|
<div
|
||||||
key={template.id}
|
key={template?.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col border rounded-lg overflow-hidden relative",
|
"flex flex-col border rounded-lg overflow-hidden relative",
|
||||||
viewMode === "icon" && "h-[200px]",
|
viewMode === "icon" && "h-[200px]",
|
||||||
@@ -315,7 +315,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Badge className="absolute top-2 right-2" variant="blue">
|
<Badge className="absolute top-2 right-2" variant="blue">
|
||||||
{template.version}
|
{template?.version}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -324,21 +324,21 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template.id}/${template.logo}`}
|
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template?.id}/${template?.logo}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"object-contain",
|
"object-contain",
|
||||||
viewMode === "detailed" ? "size-24" : "size-16",
|
viewMode === "detailed" ? "size-24" : "size-16",
|
||||||
)}
|
)}
|
||||||
alt={template.name}
|
alt={template?.name}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<span className="text-sm font-medium line-clamp-1">
|
<span className="text-sm font-medium line-clamp-1">
|
||||||
{template.name}
|
{template?.name}
|
||||||
</span>
|
</span>
|
||||||
{viewMode === "detailed" &&
|
{viewMode === "detailed" &&
|
||||||
template.tags.length > 0 && (
|
template?.tags?.length > 0 && (
|
||||||
<div className="flex flex-wrap justify-center gap-1.5">
|
<div className="flex flex-wrap justify-center gap-1.5">
|
||||||
{template.tags.map((tag) => (
|
{template?.tags?.map((tag) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={tag}
|
key={tag}
|
||||||
variant="green"
|
variant="green"
|
||||||
@@ -356,7 +356,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
|||||||
{viewMode === "detailed" && (
|
{viewMode === "detailed" && (
|
||||||
<ScrollArea className="flex-1 p-6">
|
<ScrollArea className="flex-1 p-6">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{template.description}
|
{template?.description}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
@@ -372,25 +372,27 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
|||||||
>
|
>
|
||||||
{viewMode === "detailed" && (
|
{viewMode === "detailed" && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link
|
{template?.links?.github && (
|
||||||
href={template.links.github}
|
|
||||||
target="_blank"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<GithubIcon className="size-5" />
|
|
||||||
</Link>
|
|
||||||
{template.links.website && (
|
|
||||||
<Link
|
<Link
|
||||||
href={template.links.website}
|
href={template?.links?.github}
|
||||||
|
target="_blank"
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<GithubIcon className="size-5" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{template?.links?.website && (
|
||||||
|
<Link
|
||||||
|
href={template?.links?.website}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<Globe className="size-5" />
|
<Globe className="size-5" />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{template.links.docs && (
|
{template?.links?.docs && (
|
||||||
<Link
|
<Link
|
||||||
href={template.links.docs}
|
href={template?.links?.docs}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
@@ -419,7 +421,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
|||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will create an application from the{" "}
|
This will create an application from the{" "}
|
||||||
{template.name} template and add it to your
|
{template?.name} template and add it to your
|
||||||
project.
|
project.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
const examples = [
|
const examples = [
|
||||||
"Make a personal blog",
|
"Make a personal blog",
|
||||||
@@ -23,7 +22,7 @@ const examples = [
|
|||||||
"Sendgrid service opensource analogue",
|
"Sendgrid service opensource analogue",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const StepOne = ({ nextStep, setTemplateInfo, templateInfo }: any) => {
|
export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
|
||||||
// Get servers from the API
|
// Get servers from the API
|
||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
|
|
||||||
|
|||||||
208
apps/dokploy/components/dashboard/project/duplicate-project.tsx
Normal file
208
apps/dokploy/components/dashboard/project/duplicate-project.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { Copy, Loader2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export type Services = {
|
||||||
|
appName: string;
|
||||||
|
serverId?: string | null;
|
||||||
|
name: string;
|
||||||
|
type:
|
||||||
|
| "mariadb"
|
||||||
|
| "application"
|
||||||
|
| "postgres"
|
||||||
|
| "mysql"
|
||||||
|
| "mongo"
|
||||||
|
| "redis"
|
||||||
|
| "compose";
|
||||||
|
description?: string | null;
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
status?: "idle" | "running" | "done" | "error";
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DuplicateProjectProps {
|
||||||
|
projectId: string;
|
||||||
|
services: Services[];
|
||||||
|
selectedServiceIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DuplicateProject = ({
|
||||||
|
projectId,
|
||||||
|
services,
|
||||||
|
selectedServiceIds,
|
||||||
|
}: DuplicateProjectProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "same-project"
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const selectedServices = services.filter((service) =>
|
||||||
|
selectedServiceIds.includes(service.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: duplicateProject, isLoading } =
|
||||||
|
api.project.duplicate.useMutation({
|
||||||
|
onSuccess: async (newProject) => {
|
||||||
|
await utils.project.all.invalidate();
|
||||||
|
toast.success(
|
||||||
|
duplicateType === "new-project"
|
||||||
|
? "Project duplicated successfully"
|
||||||
|
: "Services duplicated successfully",
|
||||||
|
);
|
||||||
|
setOpen(false);
|
||||||
|
if (duplicateType === "new-project") {
|
||||||
|
router.push(`/dashboard/project/${newProject.projectId}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDuplicate = async () => {
|
||||||
|
if (duplicateType === "new-project" && !name) {
|
||||||
|
toast.error("Project name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await duplicateProject({
|
||||||
|
sourceProjectId: projectId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
includeServices: true,
|
||||||
|
selectedServices: selectedServices.map((service) => ({
|
||||||
|
id: service.id,
|
||||||
|
type: service.type,
|
||||||
|
})),
|
||||||
|
duplicateInSameProject: duplicateType === "same-project",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
setOpen(isOpen);
|
||||||
|
if (!isOpen) {
|
||||||
|
// Reset form when closing
|
||||||
|
setName("");
|
||||||
|
setDescription("");
|
||||||
|
setDuplicateType("new-project");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" className="w-full justify-start">
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Duplicate
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Duplicate Services</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choose where to duplicate the selected services
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Duplicate to</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={duplicateType}
|
||||||
|
onValueChange={setDuplicateType}
|
||||||
|
className="grid gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="new-project" id="new-project" />
|
||||||
|
<Label htmlFor="new-project">New project</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="same-project" id="same-project" />
|
||||||
|
<Label htmlFor="same-project">Same project</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{duplicateType === "new-project" && (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="New project name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Project description (optional)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Selected services to duplicate</Label>
|
||||||
|
<div className="space-y-2 max-h-[200px] overflow-y-auto border rounded-md p-4">
|
||||||
|
{selectedServices.map((service) => (
|
||||||
|
<div key={service.id} className="flex items-center space-x-2">
|
||||||
|
<span className="text-sm">
|
||||||
|
{service.name} ({service.type})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleDuplicate} disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{duplicateType === "new-project"
|
||||||
|
? "Duplicating project..."
|
||||||
|
: "Duplicating services..."}
|
||||||
|
</>
|
||||||
|
) : duplicateType === "new-project" ? (
|
||||||
|
"Duplicate project"
|
||||||
|
) : (
|
||||||
|
"Duplicate services"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -31,9 +31,25 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const AddProjectSchema = z.object({
|
const AddProjectSchema = z.object({
|
||||||
name: z.string().min(1, {
|
name: z
|
||||||
message: "Name is required",
|
.string()
|
||||||
}),
|
.min(1, "Project name is required")
|
||||||
|
.refine(
|
||||||
|
(name) => {
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
const validNameRegex =
|
||||||
|
/^[\p{L}\p{N}_-][\p{L}\p{N}\s_.-]*[\p{L}\p{N}_-]$/u;
|
||||||
|
return validNameRegex.test(trimmedName);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Project name must start and end with a letter, number, hyphen or underscore. Spaces are allowed in between.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine((name) => !/^\d/.test(name.trim()), {
|
||||||
|
message: "Project name cannot start with a number",
|
||||||
|
})
|
||||||
|
.transform((name) => name.trim()),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,18 +113,6 @@ export const HandleProject = ({ projectId }: Props) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
// useEffect(() => {
|
|
||||||
// const getUsers = async () => {
|
|
||||||
// const users = await authClient.admin.listUsers({
|
|
||||||
// query: {
|
|
||||||
// limit: 100,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
// console.log(users);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// getUsers();
|
|
||||||
// });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export const ShowProjects = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
|
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
|
||||||
{filteredProjects?.map((project) => {
|
{filteredProjects?.map((project) => {
|
||||||
const emptyServices =
|
const emptyServices =
|
||||||
project?.mariadb.length === 0 &&
|
project?.mariadb.length === 0 &&
|
||||||
@@ -186,7 +186,9 @@ export const ShowProjects = () => {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||||
>
|
>
|
||||||
<span className="truncate">{domain.host}</span>
|
<span className="truncate">
|
||||||
|
{domain.host}
|
||||||
|
</span>
|
||||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -222,7 +224,9 @@ export const ShowProjects = () => {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||||
>
|
>
|
||||||
<span className="truncate">{domain.host}</span>
|
<span className="truncate">
|
||||||
|
{domain.host}
|
||||||
|
</span>
|
||||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -27,138 +27,142 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const DockerProviderSchema = z.object({
|
const DockerProviderSchema = z.object({
|
||||||
externalPort: z.preprocess((a) => {
|
externalPort: z.preprocess((a) => {
|
||||||
if (a !== null) {
|
if (a !== null) {
|
||||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||||
return Number.isNaN(parsed) ? null : parsed;
|
return Number.isNaN(parsed) ? null : parsed;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()),
|
}, z
|
||||||
|
.number()
|
||||||
|
.gte(0, "Range must be 0 - 65535")
|
||||||
|
.lte(65535, "Range must be 0 - 65535")
|
||||||
|
.nullable()),
|
||||||
});
|
});
|
||||||
|
|
||||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
redisId: string;
|
redisId: string;
|
||||||
}
|
}
|
||||||
export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
|
export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
|
||||||
const { data: ip } = api.settings.getIp.useQuery();
|
const { data: ip } = api.settings.getIp.useQuery();
|
||||||
const { data, refetch } = api.redis.one.useQuery({ redisId });
|
const { data, refetch } = api.redis.one.useQuery({ redisId });
|
||||||
const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation();
|
const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation();
|
||||||
const [connectionUrl, setConnectionUrl] = useState("");
|
const [connectionUrl, setConnectionUrl] = useState("");
|
||||||
const getIp = data?.server?.ipAddress || ip;
|
const getIp = data?.server?.ipAddress || ip;
|
||||||
|
|
||||||
const form = useForm<DockerProvider>({
|
const form = useForm<DockerProvider>({
|
||||||
defaultValues: {},
|
defaultValues: {},
|
||||||
resolver: zodResolver(DockerProviderSchema),
|
resolver: zodResolver(DockerProviderSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.externalPort) {
|
if (data?.externalPort) {
|
||||||
form.reset({
|
form.reset({
|
||||||
externalPort: data.externalPort,
|
externalPort: data.externalPort,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
|
|
||||||
const onSubmit = async (values: DockerProvider) => {
|
const onSubmit = async (values: DockerProvider) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
externalPort: values.externalPort,
|
externalPort: values.externalPort,
|
||||||
redisId,
|
redisId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("External Port updated");
|
toast.success("External Port updated");
|
||||||
await refetch();
|
await refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error saving the external port");
|
toast.error("Error saving the external port");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const buildConnectionUrl = () => {
|
const buildConnectionUrl = () => {
|
||||||
const _hostname = window.location.hostname;
|
const _hostname = window.location.hostname;
|
||||||
const port = form.watch("externalPort") || data?.externalPort;
|
const port = form.watch("externalPort") || data?.externalPort;
|
||||||
|
|
||||||
return `redis://default:${data?.databasePassword}@${getIp}:${port}`;
|
return `redis://default:${data?.databasePassword}@${getIp}:${port}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
setConnectionUrl(buildConnectionUrl());
|
setConnectionUrl(buildConnectionUrl());
|
||||||
}, [data?.appName, data?.externalPort, data?.databasePassword, form, getIp]);
|
}, [data?.appName, data?.externalPort, data?.databasePassword, form, getIp]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
In order to make the database reachable trought internet is
|
In order to make the database reachable trought internet is
|
||||||
required to set a port, make sure the port is not used by another
|
required to set a port, make sure the port is not used by another
|
||||||
application or database
|
application or database
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-col gap-4">
|
<CardContent className="flex w-full flex-col gap-4">
|
||||||
{!getIp && (
|
{!getIp && (
|
||||||
<AlertBlock type="warning">
|
<AlertBlock type="warning">
|
||||||
You need to set an IP address in your{" "}
|
You need to set an IP address in your{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings/server"
|
href="/dashboard/settings/server"
|
||||||
className="text-primary"
|
className="text-primary"
|
||||||
>
|
>
|
||||||
{data?.serverId
|
{data?.serverId
|
||||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||||
: "Web Server -> Server -> Update Server IP"}
|
: "Web Server -> Server -> Update Server IP"}
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
to fix the database url connection.
|
to fix the database url connection.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
className="flex flex-col gap-4"
|
className="flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-4 ">
|
<div className="grid grid-cols-2 gap-4 ">
|
||||||
<div className="col-span-2 space-y-4">
|
<div className="col-span-2 space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="externalPort"
|
name="externalPort"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>External Port (Internet)</FormLabel>
|
<FormLabel>External Port (Internet)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="6379"
|
placeholder="6379"
|
||||||
{...field}
|
{...field}
|
||||||
value={field.value || ""}
|
value={field.value || ""}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!!data?.externalPort && (
|
{!!data?.externalPort && (
|
||||||
<div className="grid w-full gap-8">
|
<div className="grid w-full gap-8">
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<Label>External Host</Label>
|
<Label>External Host</Label>
|
||||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" isLoading={isLoading}>
|
<Button type="submit" isLoading={isLoading}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { api } from "@/utils/api";
|
|
||||||
import {
|
import {
|
||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
} from "@/components/ui/chart";
|
} from "@/components/ui/chart";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
import {
|
import {
|
||||||
Area,
|
Area,
|
||||||
AreaChart,
|
AreaChart,
|
||||||
|
|||||||
@@ -25,13 +25,13 @@ import {
|
|||||||
import { type RouterOutputs, api } from "@/utils/api";
|
import { type RouterOutputs, api } from "@/utils/api";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import {
|
import {
|
||||||
ArrowDownUp,
|
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
InfoIcon,
|
ArrowDownUp,
|
||||||
Calendar as CalendarIcon,
|
Calendar as CalendarIcon,
|
||||||
|
InfoIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { RequestDistributionChart } from "./request-distribution-chart";
|
import { RequestDistributionChart } from "./request-distribution-chart";
|
||||||
import { RequestsTable } from "./requests-table";
|
import { RequestsTable } from "./requests-table";
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const AiForm = () => {
|
|||||||
key={config.aiId}
|
key={config.aiId}
|
||||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{config.name}
|
{config.name}
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
DialogDescription,
|
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -17,22 +25,14 @@ 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 { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import copy from "copy-to-clipboard";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
FormDescription,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import copy from "copy-to-clipboard";
|
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -7,13 +9,11 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ExternalLinkIcon, KeyIcon, Trash2, Clock, Tag } from "lucide-react";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { Clock, ExternalLinkIcon, KeyIcon, Tag, Trash2 } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
|
||||||
import { AddApiKey } from "./add-api-key";
|
import { AddApiKey } from "./add-api-key";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
|
|
||||||
export const ShowApiKeys = () => {
|
export const ShowApiKeys = () => {
|
||||||
const { data, refetch } = api.user.get.useQuery();
|
const { data, refetch } = api.user.get.useQuery();
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export const ShowCertificates = () => {
|
|||||||
key={certificate.certificateId}
|
key={certificate.certificateId}
|
||||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-2 flex-col">
|
<div className="flex gap-2 flex-col">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
|
|||||||
@@ -1,68 +1,93 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
export const extractExpirationDate = (certData: string): Date | null => {
|
export const extractExpirationDate = (certData: string): Date | null => {
|
||||||
try {
|
try {
|
||||||
const match = certData.match(
|
// Decode PEM base64 to DER binary
|
||||||
/-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/,
|
const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
|
||||||
);
|
const binStr = atob(b64);
|
||||||
if (!match?.[1]) return null;
|
const der = new Uint8Array(binStr.length);
|
||||||
|
for (let i = 0; i < binStr.length; i++) {
|
||||||
const base64Cert = match[1].replace(/\s/g, "");
|
der[i] = binStr.charCodeAt(i);
|
||||||
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;
|
let offset = 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) {
|
// Helper: read ASN.1 length field
|
||||||
dateFound++;
|
function readLength(pos: number): { length: number; offset: number } {
|
||||||
i += dateLength + 1;
|
// biome-ignore lint/style/noParameterAssign: <explanation>
|
||||||
continue;
|
let len = der[pos++];
|
||||||
|
if (len & 0x80) {
|
||||||
|
const bytes = len & 0x7f;
|
||||||
|
len = 0;
|
||||||
|
for (let i = 0; i < bytes; i++) {
|
||||||
|
// biome-ignore lint/style/noParameterAssign: <explanation>
|
||||||
|
len = (len << 8) + der[pos++];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return { length: len, offset: pos };
|
||||||
|
}
|
||||||
|
|
||||||
let dateStr = "";
|
// Skip the outer certificate sequence
|
||||||
for (let j = 0; j < dateLength; j++) {
|
if (der[offset++] !== 0x30) throw new Error("Expected sequence");
|
||||||
const charCode = bytes[i + 2 + j];
|
({ offset } = readLength(offset));
|
||||||
if (typeof charCode === "undefined") continue;
|
|
||||||
dateStr += String.fromCharCode(charCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateType === 0x17) {
|
// Skip tbsCertificate sequence
|
||||||
// UTCTime (YYMMDDhhmmssZ)
|
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate");
|
||||||
const year = Number.parseInt(dateStr.slice(0, 2));
|
({ offset } = readLength(offset));
|
||||||
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)
|
// Check for optional version field (context-specific tag [0])
|
||||||
|
if (der[offset] === 0xa0) {
|
||||||
|
offset++;
|
||||||
|
const versionLen = readLength(offset);
|
||||||
|
offset = versionLen.offset + versionLen.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip serialNumber, signature, issuer
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (der[offset] !== 0x30 && der[offset] !== 0x02)
|
||||||
|
throw new Error("Unexpected structure");
|
||||||
|
offset++;
|
||||||
|
const fieldLen = readLength(offset);
|
||||||
|
offset = fieldLen.offset + fieldLen.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validity sequence (notBefore and notAfter)
|
||||||
|
if (der[offset++] !== 0x30) throw new Error("Expected validity sequence");
|
||||||
|
const validityLen = readLength(offset);
|
||||||
|
offset = validityLen.offset;
|
||||||
|
|
||||||
|
// notBefore
|
||||||
|
offset++;
|
||||||
|
const notBeforeLen = readLength(offset);
|
||||||
|
offset = notBeforeLen.offset + notBeforeLen.length;
|
||||||
|
|
||||||
|
// notAfter
|
||||||
|
offset++;
|
||||||
|
const notAfterLen = readLength(offset);
|
||||||
|
const notAfterStr = new TextDecoder().decode(
|
||||||
|
der.slice(notAfterLen.offset, notAfterLen.offset + notAfterLen.length),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse GeneralizedTime (15 chars) or UTCTime (13 chars)
|
||||||
|
function parseTime(str: string): Date {
|
||||||
|
if (str.length === 13) {
|
||||||
|
// UTCTime YYMMDDhhmmssZ
|
||||||
|
const year = Number.parseInt(str.slice(0, 2), 10);
|
||||||
|
const fullYear = year < 50 ? 2000 + year : 1900 + year;
|
||||||
return new Date(
|
return new Date(
|
||||||
Date.UTC(
|
`${fullYear}-${str.slice(2, 4)}-${str.slice(4, 6)}T${str.slice(6, 8)}:${str.slice(8, 10)}:${str.slice(10, 12)}Z`,
|
||||||
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)),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (str.length === 15) {
|
||||||
|
// GeneralizedTime YYYYMMDDhhmmssZ
|
||||||
|
return new Date(
|
||||||
|
`${str.slice(0, 4)}-${str.slice(4, 6)}-${str.slice(6, 8)}T${str.slice(8, 10)}:${str.slice(10, 12)}:${str.slice(12, 14)}Z`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("Invalid ASN.1 time format");
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
return parseTime(notAfterStr);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error parsing certificate:", error);
|
console.error("Error parsing certificate:", error);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -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 {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -48,6 +49,10 @@ export const AddNode = ({ serverId }: Props) => {
|
|||||||
Architecture
|
Architecture
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
Make sure you use the same architecture as the node you are
|
||||||
|
adding.
|
||||||
|
</AlertBlock>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -56,10 +61,10 @@ export const AddNode = ({ serverId }: Props) => {
|
|||||||
<TabsTrigger value="worker">Worker</TabsTrigger>
|
<TabsTrigger value="worker">Worker</TabsTrigger>
|
||||||
<TabsTrigger value="manager">Manager</TabsTrigger>
|
<TabsTrigger value="manager">Manager</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="worker" className="pt-4">
|
<TabsContent value="worker" className="pt-4 overflow-hidden">
|
||||||
<AddWorker serverId={serverId} />
|
<AddWorker serverId={serverId} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="manager" className="pt-4">
|
<TabsContent value="manager" className="pt-4 overflow-hidden">
|
||||||
<AddManager serverId={serverId} />
|
<AddManager serverId={serverId} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { CardContent } from "@/components/ui/card";
|
import { CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
@@ -6,7 +7,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { CopyIcon } from "lucide-react";
|
import { CopyIcon, Loader2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -14,56 +15,66 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const AddManager = ({ serverId }: Props) => {
|
export const AddManager = ({ serverId }: Props) => {
|
||||||
const { data } = api.cluster.addManager.useQuery({ serverId });
|
const { data, isLoading, error, isError } = api.cluster.addManager.useQuery({
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<CardContent className="sm:max-w-4xl flex flex-col gap-4 px-0">
|
||||||
<CardContent className="sm:max-w-4xl max-h-screen overflow-y-auto flex flex-col gap-4 px-0">
|
<DialogHeader>
|
||||||
<DialogHeader>
|
<DialogTitle>Add a new manager</DialogTitle>
|
||||||
<DialogTitle>Add a new manager</DialogTitle>
|
<DialogDescription>Add a new manager</DialogDescription>
|
||||||
<DialogDescription>Add a new manager</DialogDescription>
|
</DialogHeader>
|
||||||
</DialogHeader>
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
<div className="flex flex-col gap-2.5 text-sm">
|
{isLoading ? (
|
||||||
<span>1. Go to your new server and run the following command</span>
|
<Loader2 className="w-full animate-spin text-muted-foreground" />
|
||||||
<span className="bg-muted rounded-lg p-2 flex justify-between">
|
) : (
|
||||||
curl https://get.docker.com | sh -s -- --version {data?.version}
|
<>
|
||||||
<button
|
<div className="flex flex-col gap-2.5 text-sm">
|
||||||
type="button"
|
<span>
|
||||||
className="self-center"
|
1. Go to your new server and run the following command
|
||||||
onClick={() => {
|
</span>
|
||||||
copy(
|
<span className="bg-muted rounded-lg p-2 flex justify-between">
|
||||||
`curl https://get.docker.com | sh -s -- --version ${data?.version}`,
|
curl https://get.docker.com | sh -s -- --version {data?.version}
|
||||||
);
|
<button
|
||||||
toast.success("Copied to clipboard");
|
type="button"
|
||||||
}}
|
className="self-center"
|
||||||
>
|
onClick={() => {
|
||||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
copy(
|
||||||
</button>
|
`curl https://get.docker.com | sh -s -- --version ${data?.version}`,
|
||||||
</span>
|
);
|
||||||
</div>
|
toast.success("Copied to clipboard");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2.5 text-sm">
|
<div className="flex flex-col gap-2.5 text-sm">
|
||||||
<span>
|
<span>
|
||||||
2. Run the following command to add the node(manager) to your
|
2. Run the following command to add the node(manager) to your
|
||||||
cluster
|
cluster
|
||||||
</span>
|
</span>
|
||||||
<span className="bg-muted rounded-lg p-2 flex">
|
|
||||||
{data?.command}
|
<span className="bg-muted rounded-lg p-2 flex">
|
||||||
<button
|
{data?.command}
|
||||||
type="button"
|
<button
|
||||||
className="self-start"
|
type="button"
|
||||||
onClick={() => {
|
className="self-start"
|
||||||
copy(data?.command || "");
|
onClick={() => {
|
||||||
toast.success("Copied to clipboard");
|
copy(data?.command || "");
|
||||||
}}
|
toast.success("Copied to clipboard");
|
||||||
>
|
}}
|
||||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
>
|
||||||
</button>
|
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</span>
|
||||||
</CardContent>
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const ShowNodesModal = ({ serverId }: Props) => {
|
|||||||
className="w-full cursor-pointer "
|
className="w-full cursor-pointer "
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
Show Nodes
|
Show Swarm Nodes
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen ">
|
<DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen ">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user