mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-16 20:55:21 +02:00
Compare commits
763 Commits
v0.25.3
...
core-model
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b965dedd7d | ||
|
|
2b779f9fc6 | ||
|
|
15b0ca7ab2 | ||
|
|
fd6f61fd2a | ||
|
|
8f95546535 | ||
|
|
8b370d4f7b | ||
|
|
1ed941b17c | ||
|
|
18d980c3ff | ||
|
|
5ddcdd843c | ||
|
|
fdf88b1ff3 | ||
|
|
13b64e45ec | ||
|
|
4383e46686 | ||
|
|
60d69d2915 | ||
|
|
a2b16d4be8 | ||
|
|
831a1815cf | ||
|
|
6b9bcbc539 | ||
|
|
6ca6ff3530 | ||
|
|
7583d5f860 | ||
|
|
7921f754fd | ||
|
|
0c0944d221 | ||
|
|
d490111a58 | ||
|
|
167daccee0 | ||
|
|
11af6a5eb9 | ||
|
|
85424badcf | ||
|
|
ccfd7f5189 | ||
|
|
6d94da1dee | ||
|
|
10c0de9d5f | ||
|
|
2b0ae65f71 | ||
|
|
2acaaede37 | ||
|
|
f303962319 | ||
|
|
edc8efe816 | ||
|
|
4e0cb2a9c7 | ||
|
|
4001f1d067 | ||
|
|
d894b2a3bf | ||
|
|
14d359dd14 | ||
|
|
1e11f603de | ||
|
|
d12f029e2b | ||
|
|
0c62bc0f29 | ||
|
|
b19d3e94eb | ||
|
|
5005f9198b | ||
|
|
fe5efd7651 | ||
|
|
8db7a421dc | ||
|
|
068deecb61 | ||
|
|
9aa03efd13 | ||
|
|
016aa0248a | ||
|
|
eb9d140c5d | ||
|
|
2eb73b988b | ||
|
|
d2ce587494 | ||
|
|
13ad8cb846 | ||
|
|
0897417d7c | ||
|
|
eb14a68bdd | ||
|
|
01c0b461b5 | ||
|
|
9498fbeff3 | ||
|
|
d2aa60ddf7 | ||
|
|
58b75205af | ||
|
|
9e03625586 | ||
|
|
260efdc2bb | ||
|
|
1b5bfe051d | ||
|
|
e4384075f2 | ||
|
|
b355d44605 | ||
|
|
f39aa23803 | ||
|
|
3abc4cdc3b | ||
|
|
ec56062f17 | ||
|
|
10c4f882a5 | ||
|
|
f1dfa9c6a2 | ||
|
|
6010643d9e | ||
|
|
1ccb205495 | ||
|
|
b2be5bc09f | ||
|
|
babd30a110 | ||
|
|
e77f276785 | ||
|
|
78c9a047b0 | ||
|
|
84e0f5856b | ||
|
|
2bfa4643fc | ||
|
|
8c7bc82712 | ||
|
|
44645a6fbe | ||
|
|
771d0dd8ab | ||
|
|
67725759e6 | ||
|
|
2065372d4f | ||
|
|
67d5e1a350 | ||
|
|
93fa19213e | ||
|
|
1988a14b24 | ||
|
|
3bdf029155 | ||
|
|
e1896c2498 | ||
|
|
a8064afd60 | ||
|
|
3849a206e8 | ||
|
|
69d5c6f0cb | ||
|
|
bb0a53d976 | ||
|
|
0a8753d0a9 | ||
|
|
23b14cf0cf | ||
|
|
53f67c6eb2 | ||
|
|
7c53a3ef75 | ||
|
|
c065c85ee6 | ||
|
|
db97de2a39 | ||
|
|
dc7af1b840 | ||
|
|
97362da2ae | ||
|
|
b476e50ff1 | ||
|
|
1b22384315 | ||
|
|
6685bd618e | ||
|
|
f5d334244a | ||
|
|
fd084c6d37 | ||
|
|
e607220bfa | ||
|
|
d8514b067b | ||
|
|
0590e78854 | ||
|
|
27fa0e881a | ||
|
|
72f2cc6268 | ||
|
|
854bd88e0a | ||
|
|
acf385a1f3 | ||
|
|
d1bc109697 | ||
|
|
38c7e1e996 | ||
|
|
54d5266573 | ||
|
|
3a5ac9d31f | ||
|
|
0ddf6b851f | ||
|
|
ed701df6ac | ||
|
|
dfc15cd621 | ||
|
|
1ac3d1c1b0 | ||
|
|
f6b756e711 | ||
|
|
9f84dd4e0d | ||
|
|
2e32b0a4af | ||
|
|
0f69bbbd20 | ||
|
|
9e79314ef4 | ||
|
|
540b4039ac | ||
|
|
9e89edf167 | ||
|
|
e31d5a723b | ||
|
|
eb4fbff1b2 | ||
|
|
3aeb52810c | ||
|
|
8eaf2ab5c7 | ||
|
|
5ebcbf86ea | ||
|
|
67f4ca2cd9 | ||
|
|
6bb5404f87 | ||
|
|
3e356e6890 | ||
|
|
b65f53d141 | ||
|
|
2b1a3db7b8 | ||
|
|
b66156956a | ||
|
|
669de0f95f | ||
|
|
371cf83e52 | ||
|
|
51abf49458 | ||
|
|
72cc7a2d2c | ||
|
|
ba5283039c | ||
|
|
19a7a80d43 | ||
|
|
5d42737943 | ||
|
|
4c10056394 | ||
|
|
d875e08d48 | ||
|
|
0b45b795e8 | ||
|
|
d187b52e09 | ||
|
|
5f13679a97 | ||
|
|
415327c246 | ||
|
|
12b8f8a4fd | ||
|
|
fea3ec9a6f | ||
|
|
2976bb5cf7 | ||
|
|
092afbe1fa | ||
|
|
a32e7e0041 | ||
|
|
c045c5328f | ||
|
|
ee9edd7ff4 | ||
|
|
3799aeab74 | ||
|
|
4f6eb51c06 | ||
|
|
7cf898dcf6 | ||
|
|
1c83919408 | ||
|
|
b230687c8a | ||
|
|
b499cefebc | ||
|
|
a04a4c05ea | ||
|
|
8c889fc71e | ||
|
|
e7dc05d031 | ||
|
|
9544b2ace3 | ||
|
|
85632fd0c2 | ||
|
|
31cdae1b72 | ||
|
|
702af64444 | ||
|
|
eef27b67c2 | ||
|
|
70f50dd8bc | ||
|
|
3e25b97b99 | ||
|
|
22927c2716 | ||
|
|
8ab4ee8e0e | ||
|
|
99aa34f27e | ||
|
|
48be8544cf | ||
|
|
ee411ac74f | ||
|
|
c233ddb520 | ||
|
|
0cfe87cb72 | ||
|
|
7998b296a2 | ||
|
|
9e20f66bf5 | ||
|
|
1dc943ef5b | ||
|
|
0f63fdac4e | ||
|
|
ec8c516aa3 | ||
|
|
58be8f91c0 | ||
|
|
2036ac3dc8 | ||
|
|
17f83f746a | ||
|
|
bcd1cbe920 | ||
|
|
3993263615 | ||
|
|
97bd4de4f1 | ||
|
|
2fc29ff7c8 | ||
|
|
4a74016b52 | ||
|
|
bd4964f70f | ||
|
|
07bf520e9b | ||
|
|
c42e859215 | ||
|
|
e666cfb374 | ||
|
|
1d9b9ff9b6 | ||
|
|
6c61919202 | ||
|
|
a9a42d2066 | ||
|
|
0f6ac310b5 | ||
|
|
c267faef08 | ||
|
|
d2be0855c1 | ||
|
|
c9aaee149a | ||
|
|
d435553839 | ||
|
|
28f40066a2 | ||
|
|
22e6a06426 | ||
|
|
94faf78f16 | ||
|
|
c4351482fa | ||
|
|
5412c5a873 | ||
|
|
212006ba9e | ||
|
|
18d12d1a6f | ||
|
|
5d5af8f57f | ||
|
|
7f8f97c48f | ||
|
|
67865c5283 | ||
|
|
817264eae4 | ||
|
|
5360df7a53 | ||
|
|
fec4daa59b | ||
|
|
aae7906e77 | ||
|
|
86d14465cb | ||
|
|
d465fb4da1 | ||
|
|
f84c659121 | ||
|
|
89cb9c24c9 | ||
|
|
c7fcea7d6a | ||
|
|
d4555e6985 | ||
|
|
daa54cea8d | ||
|
|
77aff700fd | ||
|
|
cdb0de9a72 | ||
|
|
a5353e5457 | ||
|
|
a9b8beb50b | ||
|
|
6022f2f6a3 | ||
|
|
075e387bb6 | ||
|
|
568293ef3c | ||
|
|
a9ae39dc94 | ||
|
|
55a9640e31 | ||
|
|
32d5959733 | ||
|
|
bccd531457 | ||
|
|
f5de5130f3 | ||
|
|
bd751658be | ||
|
|
9b23aa9c8c | ||
|
|
dbc1396fa6 | ||
|
|
4210eefd37 | ||
|
|
91050ce3a5 | ||
|
|
9394d97163 | ||
|
|
f91a3aab25 | ||
|
|
9fbd0dce9a | ||
|
|
9e405c0728 | ||
|
|
44892404c1 | ||
|
|
1362fdd4b4 | ||
|
|
c7c3b1018b | ||
|
|
0d9b72e00a | ||
|
|
80ed041420 | ||
|
|
ba9c2ef369 | ||
|
|
8bd4f403c4 | ||
|
|
7ea7ee739f | ||
|
|
4873baa975 | ||
|
|
287dfb5402 | ||
|
|
439fba1f4b | ||
|
|
1ba24630a8 | ||
|
|
de6c1a7981 | ||
|
|
7948721f5a | ||
|
|
99cb80757c | ||
|
|
7467ada3a9 | ||
|
|
f0f2188652 | ||
|
|
2c1dfe9377 | ||
|
|
9e8efab909 | ||
|
|
35612b21a0 | ||
|
|
7873af1c39 | ||
|
|
ade727e2ed | ||
|
|
ac1fb6fb86 | ||
|
|
b3168f75d0 | ||
|
|
0e7b550642 | ||
|
|
3e1030edda | ||
|
|
3ea90de4e1 | ||
|
|
bccef0da4c | ||
|
|
698104e7b7 | ||
|
|
dc28ddba2a | ||
|
|
ed312dc1c0 | ||
|
|
6cafb15dbb | ||
|
|
c34fdf7a46 | ||
|
|
e627c9af99 | ||
|
|
18e609313b | ||
|
|
fbf840bf6e | ||
|
|
76613de095 | ||
|
|
5a7f55ea63 | ||
|
|
be3403af0c | ||
|
|
f190cc548c | ||
|
|
4df9b935a8 | ||
|
|
b9becbafd8 | ||
|
|
60be376a4f | ||
|
|
ef9732d5d9 | ||
|
|
e052850b87 | ||
|
|
e06f5979c3 | ||
|
|
6b346d30ee | ||
|
|
9e98f9ce7f | ||
|
|
c8e7aae5c6 | ||
|
|
75a49790ea | ||
|
|
716e8b351f | ||
|
|
e993955f5a | ||
|
|
d35307ead6 | ||
|
|
c98db390dc | ||
|
|
0c7265c9c9 | ||
|
|
f1ef1d8489 | ||
|
|
fbd095334c | ||
|
|
e3832eff07 | ||
|
|
25b7069e31 | ||
|
|
caf0aa6a12 | ||
|
|
21eb185431 | ||
|
|
0fbb063d06 | ||
|
|
bb3f73851a | ||
|
|
56d4e61c1f | ||
|
|
40949f2a8f | ||
|
|
fe7a73baee | ||
|
|
7ce36a50e8 | ||
|
|
b1505651c2 | ||
|
|
689c689487 | ||
|
|
1aac5c1670 | ||
|
|
ea83406f6f | ||
|
|
4f5b557a60 | ||
|
|
d09163a24e | ||
|
|
e1d8505757 | ||
|
|
25aecab062 | ||
|
|
b6de55c4d9 | ||
|
|
e22d503182 | ||
|
|
9e11b802fd | ||
|
|
adfe29e10c | ||
|
|
c1d23b18fb | ||
|
|
272a8dbdb2 | ||
|
|
32631e957a | ||
|
|
79d3c1d7f3 | ||
|
|
dc4e8ecdc9 | ||
|
|
559753eae3 | ||
|
|
2d0669e288 | ||
|
|
3f12f20e4c | ||
|
|
4907a021a4 | ||
|
|
817825e8bd | ||
|
|
0f632e3f55 | ||
|
|
8728d4b600 | ||
|
|
88b4374019 | ||
|
|
b91cb6cb5e | ||
|
|
c8277f6573 | ||
|
|
24c216e61a | ||
|
|
5c630e7ad7 | ||
|
|
c0dec0ed20 | ||
|
|
7d9806a050 | ||
|
|
96e7b39e3c | ||
|
|
ded16f39af | ||
|
|
d8e521e4dc | ||
|
|
67643fe088 | ||
|
|
aab982b431 | ||
|
|
362416afa8 | ||
|
|
035f8835cf | ||
|
|
8cff84ef54 | ||
|
|
742ca00d3d | ||
|
|
3481da9b0e | ||
|
|
15634c9f10 | ||
|
|
704582f6de | ||
|
|
65d962efc8 | ||
|
|
78d2e13dc8 | ||
|
|
28f7fb90c0 | ||
|
|
9213061c26 | ||
|
|
085ef35b46 | ||
|
|
8647e7a6b7 | ||
|
|
cc1620b5fa | ||
|
|
27b605f961 | ||
|
|
a72281c018 | ||
|
|
aa750be036 | ||
|
|
067777f28e | ||
|
|
f77a67ba33 | ||
|
|
30d2f38259 | ||
|
|
b23ba17a41 | ||
|
|
218c077255 | ||
|
|
c9f356e314 | ||
|
|
4f691d27b2 | ||
|
|
3c70db9fc8 | ||
|
|
f94d5b9582 | ||
|
|
b9d05b00a9 | ||
|
|
f61fb3aba0 | ||
|
|
d3b7e68da9 | ||
|
|
061ca6c95c | ||
|
|
e576c1a63f | ||
|
|
5d53cf4090 | ||
|
|
ff27f0828b | ||
|
|
33d4f57611 | ||
|
|
bacadccaa9 | ||
|
|
55748749fd | ||
|
|
45b75fdfde | ||
|
|
ff822481c5 | ||
|
|
783324628f | ||
|
|
e70c476c9f | ||
|
|
891260fe41 | ||
|
|
062037a9e6 | ||
|
|
7da1be877b | ||
|
|
60e6285e8e | ||
|
|
cd8c67bb9b | ||
|
|
4fb3ad3032 | ||
|
|
736a7320d4 | ||
|
|
23b235303c | ||
|
|
eb8c6e4367 | ||
|
|
965f05c7c8 | ||
|
|
e316beaddb | ||
|
|
8aff1e7614 | ||
|
|
dbe1733dcb | ||
|
|
73d87c06e1 | ||
|
|
e136934cbc | ||
|
|
4840abe3a4 | ||
|
|
f046ba427a | ||
|
|
b12e84c645 | ||
|
|
d18fe8390b | ||
|
|
e88a9ce96f | ||
|
|
5890b321b2 | ||
|
|
1c652477fb | ||
|
|
a5abd46386 | ||
|
|
ad0e044740 | ||
|
|
7a0ff72f51 | ||
|
|
2e702dc41f | ||
|
|
766f9244da | ||
|
|
6413fa54e6 | ||
|
|
1c9dcc0c9e | ||
|
|
fee802a57b | ||
|
|
af2b053caa | ||
|
|
42a4cc7fff | ||
|
|
2a7807c2b3 | ||
|
|
153390ff26 | ||
|
|
425b8ec3c2 | ||
|
|
e86caccfd5 | ||
|
|
8a93116ce0 | ||
|
|
daff2adb02 | ||
|
|
052fc5ffe1 | ||
|
|
96dff0c1bb | ||
|
|
f53e1a6543 | ||
|
|
9e2788e764 | ||
|
|
4884ee3352 | ||
|
|
82cfe06fa4 | ||
|
|
a79afe49b4 | ||
|
|
19a01665ae | ||
|
|
48503c96c1 | ||
|
|
398300f729 | ||
|
|
d08fdeb939 | ||
|
|
8ca8839d7e | ||
|
|
605de97805 | ||
|
|
6ba35057ac | ||
|
|
46d1809f84 | ||
|
|
ba5e7e2026 | ||
|
|
8a741e41bb | ||
|
|
1581defc39 | ||
|
|
f5891b8793 | ||
|
|
8b13919d3b | ||
|
|
19244a2dea | ||
|
|
c4c1930195 | ||
|
|
b2264a9148 | ||
|
|
f7ddc715c7 | ||
|
|
3a17c9b9e8 | ||
|
|
201cc65b09 | ||
|
|
3618be65fc | ||
|
|
e9b4245625 | ||
|
|
e60c68dbeb | ||
|
|
f46444e039 | ||
|
|
05e3d241f1 | ||
|
|
5c2bae2f21 | ||
|
|
d854979fe3 | ||
|
|
8016708798 | ||
|
|
09a98a29e0 | ||
|
|
a4caa47e10 | ||
|
|
969147cd59 | ||
|
|
6369012389 | ||
|
|
69b7777db4 | ||
|
|
b9324e6320 | ||
|
|
04a1a84077 | ||
|
|
735b70b7fe | ||
|
|
61d9ae397a | ||
|
|
ea5d86e295 | ||
|
|
dd06c7006d | ||
|
|
4d36741e50 | ||
|
|
a9b9dd4b66 | ||
|
|
fbb1f1f266 | ||
|
|
c35fe0d457 | ||
|
|
ec081b6f2e | ||
|
|
d02913d69e | ||
|
|
4518ea2092 | ||
|
|
d549aa6a62 | ||
|
|
62474c1222 | ||
|
|
26ff4075df | ||
|
|
22f704dd59 | ||
|
|
d22aa0583c | ||
|
|
c459997453 | ||
|
|
1c0673b327 | ||
|
|
334d9c91ef | ||
|
|
70bb32c590 | ||
|
|
843313ddb9 | ||
|
|
b202974a7d | ||
|
|
c56ddf3ec1 | ||
|
|
b814bdc612 | ||
|
|
d8ab7a59ff | ||
|
|
f718ab334e | ||
|
|
668aaf9a91 | ||
|
|
ef10996dd8 | ||
|
|
a05b75fc67 | ||
|
|
f96114ad80 | ||
|
|
5ac32f9f24 | ||
|
|
7b398939f7 | ||
|
|
fd8f0e8f1f | ||
|
|
4f2268e66f | ||
|
|
b99d532582 | ||
|
|
fb2bb99a2c | ||
|
|
785172fa7b | ||
|
|
43701915f1 | ||
|
|
2619733915 | ||
|
|
615d89ee0c | ||
|
|
63568a4887 | ||
|
|
8aa496b773 | ||
|
|
0c24507872 | ||
|
|
d2cd01aff7 | ||
|
|
6349cabf27 | ||
|
|
1ce153371a | ||
|
|
41849654a7 | ||
|
|
a475361b80 | ||
|
|
1dc5bbd9bd | ||
|
|
d55e934978 | ||
|
|
dddb866233 | ||
|
|
0b58092c8a | ||
|
|
759955e05e | ||
|
|
5949005458 | ||
|
|
71b550f7e6 | ||
|
|
832a98734a | ||
|
|
65b3ce831f | ||
|
|
6613cb7587 | ||
|
|
75a43896a2 | ||
|
|
64e48a7bbe | ||
|
|
5434d9730d | ||
|
|
373c78a927 | ||
|
|
53b66e41e2 | ||
|
|
0f100c7bc8 | ||
|
|
856b6ceec6 | ||
|
|
a14cc09933 | ||
|
|
94536ab05a | ||
|
|
94c00312c1 | ||
|
|
dadef000d5 | ||
|
|
2cda9821a5 | ||
|
|
a0868ad57c | ||
|
|
d4f574aa3f | ||
|
|
07368ff8c6 | ||
|
|
102a7a00b8 | ||
|
|
25a6a5bec6 | ||
|
|
011792e26b | ||
|
|
a527bafad8 | ||
|
|
14e154bece | ||
|
|
e5aeff6106 | ||
|
|
f6ff90eed9 | ||
|
|
f34a65cf14 | ||
|
|
8c0db75e1e | ||
|
|
b3c6645b35 | ||
|
|
0ff0695b7f | ||
|
|
6a4ef1153f | ||
|
|
a65262b45e | ||
|
|
75a66826f2 | ||
|
|
a5eeb74831 | ||
|
|
3dad8b4a54 | ||
|
|
fd94a14d85 | ||
|
|
d7e0413ed9 | ||
|
|
f4748bdd11 | ||
|
|
5a50d4bfc7 | ||
|
|
d1130c4554 | ||
|
|
fd2775e32a | ||
|
|
51003276bc | ||
|
|
6fb3584283 | ||
|
|
997dd784a5 | ||
|
|
60d1bc4a6d | ||
|
|
daa8184c30 | ||
|
|
68be333b04 | ||
|
|
2723703f60 | ||
|
|
d83783c620 | ||
|
|
ea9c76c1df | ||
|
|
32c302e9ce | ||
|
|
9f99185628 | ||
|
|
05b20193c2 | ||
|
|
88a8c060db | ||
|
|
71c01ff30f | ||
|
|
babc1c033e | ||
|
|
b34334701b | ||
|
|
0b1e79a8e1 | ||
|
|
66e0bcc4c6 | ||
|
|
962d405436 | ||
|
|
6b547dbc32 | ||
|
|
ba7a325e8f | ||
|
|
24df8e79fa | ||
|
|
6c3f72858b | ||
|
|
ba4626c7da | ||
|
|
166b58b70e | ||
|
|
74e17b4de6 | ||
|
|
8e5be8dbcb | ||
|
|
046606e496 | ||
|
|
7bddc6f46b | ||
|
|
036eaa3c2d | ||
|
|
be80148310 | ||
|
|
e9cf1f4caa | ||
|
|
b662629075 | ||
|
|
0077954c78 | ||
|
|
622bb3ff4e | ||
|
|
7817a3c2fb | ||
|
|
71152b664b | ||
|
|
63f3bb8cf2 | ||
|
|
8510bcbd40 | ||
|
|
7fe59ba51b | ||
|
|
d858acbaaa | ||
|
|
9925470663 | ||
|
|
342b1d676e | ||
|
|
6d52ab13a6 | ||
|
|
819bb6ca14 | ||
|
|
8338b27ab8 | ||
|
|
901013ccd1 | ||
|
|
ceb4cc453e | ||
|
|
2b19632cdc | ||
|
|
a99ac01eba | ||
|
|
8b82832204 | ||
|
|
5fd39843f7 | ||
|
|
557923c011 | ||
|
|
3aaef9cc3e | ||
|
|
7a777550a0 | ||
|
|
b3cec533f9 | ||
|
|
78a9fe9dc5 | ||
|
|
68be6f451f | ||
|
|
b6e6705de8 | ||
|
|
d0fd8e7c72 | ||
|
|
b200ed6a73 | ||
|
|
8537b6fbbf | ||
|
|
883e9f0fd1 | ||
|
|
7988de64c8 | ||
|
|
fd5fa32964 | ||
|
|
ca6a93fdf6 | ||
|
|
ee0a299343 | ||
|
|
0c37d7b3ee | ||
|
|
8de5001471 | ||
|
|
9b81d15b0c | ||
|
|
a0b550ace9 | ||
|
|
7943c90d5d | ||
|
|
fc3fceb858 | ||
|
|
1804a7c301 | ||
|
|
e97046c267 | ||
|
|
080233a7cd | ||
|
|
be5d65a8e3 | ||
|
|
e934d4f4ce | ||
|
|
586195b5c8 | ||
|
|
c8320da716 | ||
|
|
8a9a0e49ce | ||
|
|
aadb278e5f | ||
|
|
47a9bd9c86 | ||
|
|
739dc21bc0 | ||
|
|
fa4724d94e | ||
|
|
32454bab61 | ||
|
|
beb6f38204 | ||
|
|
3a0549bbd8 | ||
|
|
4112ba9b10 | ||
|
|
fbf57739b3 | ||
|
|
e4f5a1d828 | ||
|
|
3e09644877 | ||
|
|
1ab576d260 | ||
|
|
0b0f507b49 | ||
|
|
fa8722f6c8 | ||
|
|
fb0ed494fc | ||
|
|
6d2728f5f0 | ||
|
|
8efc8b573c | ||
|
|
644189064b | ||
|
|
23c891d6fc | ||
|
|
a3f9f9b7a1 | ||
|
|
83a7b8dce5 | ||
|
|
e9b5699f8e | ||
|
|
f952f53fca | ||
|
|
60db2972c7 | ||
|
|
143e4be9e6 | ||
|
|
18e553f239 | ||
|
|
c41f447269 | ||
|
|
dbc4f4e4c5 | ||
|
|
8594ad8ece | ||
|
|
9edd69b10d | ||
|
|
4a9684bbe4 | ||
|
|
4f835c6c5e | ||
|
|
54853098a7 | ||
|
|
1b77c8029b | ||
|
|
2cc9855ed2 | ||
|
|
571e97f247 | ||
|
|
cdca2ea6d2 | ||
|
|
c4519256cf | ||
|
|
e4aefe7f9d | ||
|
|
15c81a0982 | ||
|
|
48e4fd3ddf | ||
|
|
276f870e74 | ||
|
|
9f5c2dbe92 | ||
|
|
6e86fafa5e | ||
|
|
0f9505327f | ||
|
|
dd2902a57c | ||
|
|
008788a38a | ||
|
|
0138a7c011 | ||
|
|
845d2a3ac5 | ||
|
|
4033bb84b2 | ||
|
|
43e96edcdd | ||
|
|
2db388536f | ||
|
|
43876efc79 | ||
|
|
e7c7545c02 | ||
|
|
77705381cd | ||
|
|
5fdf82a27f | ||
|
|
6bd5b1f71f | ||
|
|
17d6830b66 | ||
|
|
a845eba320 | ||
|
|
2f4ec9f35f | ||
|
|
b725861b55 | ||
|
|
6fa8f63277 | ||
|
|
ac6bdf60ec | ||
|
|
db292e6949 | ||
|
|
b4a14e6e76 | ||
|
|
e64ee98d99 | ||
|
|
95d0da25a0 | ||
|
|
0cc8c02359 | ||
|
|
7f3fe52b53 | ||
|
|
b5bc384664 | ||
|
|
39d0b9649f | ||
|
|
1ce880bd6d | ||
|
|
8a8ed58fef | ||
|
|
95714c1749 | ||
|
|
a181b7b8b8 | ||
|
|
0e2f1e2832 | ||
|
|
2ec495b2f2 | ||
|
|
085f6bbbb7 | ||
|
|
4b44bc86b4 | ||
|
|
cbdc4e4a20 | ||
|
|
ee3ff18feb | ||
|
|
598ecb8c6e | ||
|
|
1d5a523b9e | ||
|
|
4bced9ede0 | ||
|
|
e35aeef4e2 | ||
|
|
5e89ffbf4f | ||
|
|
21de6bf167 | ||
|
|
291edce62f | ||
|
|
59be1c5941 | ||
|
|
2141e4b174 | ||
|
|
df0fb340ad | ||
|
|
190ccfa91f | ||
|
|
f5084dd5fb | ||
|
|
cd06b55a0c | ||
|
|
b4a3cbdff4 | ||
|
|
1b603d84d7 | ||
|
|
cf2c89d136 | ||
|
|
95de98e94d | ||
|
|
4416ca9cd2 | ||
|
|
65c5974b4f | ||
|
|
bdf0a932fe | ||
|
|
c355eafc95 | ||
|
|
30b28afbac | ||
|
|
1a940580ae | ||
|
|
b7e2df6d6a | ||
|
|
85e3a92877 | ||
|
|
c2eaa78724 | ||
|
|
270b4d4edc | ||
|
|
da9df3e239 | ||
|
|
6da122eab7 | ||
|
|
178ccb3f45 | ||
|
|
a47a5f3b9e | ||
|
|
95bf60ac75 | ||
|
|
544408886e | ||
|
|
e645b31b32 | ||
|
|
8ea64f9de1 | ||
|
|
825a1fc495 | ||
|
|
7b76bb93b3 | ||
|
|
64290fcbf6 | ||
|
|
4f2b270f1d | ||
|
|
e22489926b | ||
|
|
b4a5221caf |
6
.github/pull_request_template.md
vendored
6
.github/pull_request_template.md
vendored
@@ -6,9 +6,9 @@ Please describe in a short paragraph what this PR is about.
|
||||
|
||||
Before submitting this PR, please make sure that:
|
||||
|
||||
- [] You created a dedicated branch based on the `canary` branch.
|
||||
- [] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
|
||||
- [] You have tested this PR in your local instance.
|
||||
- [ ] You created a dedicated branch based on the `canary` branch.
|
||||
- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
|
||||
- [ ] You have tested this PR in your local instance.
|
||||
|
||||
## Issues related (if applicable)
|
||||
|
||||
|
||||
BIN
.github/sponsors/awesome.png
vendored
Normal file
BIN
.github/sponsors/awesome.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
26
.github/workflows/pull-request.yml
vendored
26
.github/workflows/pull-request.yml
vendored
@@ -20,6 +20,32 @@ jobs:
|
||||
with:
|
||||
node-version: 20.16.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Nixpacks
|
||||
if: matrix.job == 'test'
|
||||
run: |
|
||||
export NIXPACKS_VERSION=1.41.0
|
||||
curl -sSL https://nixpacks.com/install.sh | bash
|
||||
echo "Nixpacks installed $NIXPACKS_VERSION"
|
||||
|
||||
- name: Install Railpack
|
||||
if: matrix.job == 'test'
|
||||
run: |
|
||||
export RAILPACK_VERSION=0.15.4
|
||||
curl -sSL https://railpack.com/install.sh | bash
|
||||
echo "Railpack installed $RAILPACK_VERSION"
|
||||
|
||||
- name: Add build tools to PATH
|
||||
if: matrix.job == 'test'
|
||||
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Initialize Docker Swarm
|
||||
if: matrix.job == 'test'
|
||||
run: |
|
||||
docker swarm init
|
||||
docker network create --driver overlay dokploy-network || true
|
||||
echo "✅ Docker Swarm initialized"
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm server:build
|
||||
- run: pnpm ${{ matrix.job }}
|
||||
|
||||
70
.github/workflows/sync-openapi-docs.yml
vendored
Normal file
70
.github/workflows/sync-openapi-docs.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Generate and Sync OpenAPI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
- main
|
||||
paths:
|
||||
- 'apps/dokploy/server/api/routers/**'
|
||||
- 'packages/server/src/services/**'
|
||||
- 'packages/server/src/db/schema/**'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
generate-and-commit:
|
||||
name: Generate OpenAPI and commit to Dokploy repo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Dokploy repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.16.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate OpenAPI specification
|
||||
run: |
|
||||
pnpm generate:openapi
|
||||
|
||||
# Verifica que se generó correctamente
|
||||
if [ ! -f openapi.json ]; then
|
||||
echo "❌ openapi.json not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ OpenAPI specification generated successfully"
|
||||
|
||||
- name: Sync to website repository
|
||||
run: |
|
||||
# Clona el repositorio de website
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/website.git website-repo
|
||||
|
||||
cd website-repo
|
||||
|
||||
# Copia el openapi.json al website (sobrescribe)
|
||||
mkdir -p apps/docs/public
|
||||
cp -f ../openapi.json apps/docs/public/openapi.json
|
||||
|
||||
# Configura git
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
# Agrega y commitea siempre
|
||||
git add apps/docs/public/openapi.json
|
||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
echo "✅ OpenAPI synced to website successfully"
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -13,6 +13,8 @@ node_modules
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
openapi.json
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
|
||||
@@ -41,4 +43,7 @@ yarn-error.log*
|
||||
*.pem
|
||||
|
||||
|
||||
.db
|
||||
.db
|
||||
|
||||
# Development environment
|
||||
.devcontainer
|
||||
@@ -148,7 +148,7 @@ curl -sSL https://railpack.com/install.sh | sh
|
||||
|
||||
```bash
|
||||
# Install Buildpacks
|
||||
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
|
||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.39.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||
```
|
||||
|
||||
## Pull Request
|
||||
|
||||
@@ -46,23 +46,23 @@ COPY --from=build /prod/dokploy/node_modules ./node_modules
|
||||
|
||||
|
||||
# Install docker
|
||||
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm get-docker.sh && curl https://rclone.org/install.sh | bash
|
||||
RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --version 28.5.2 && rm get-docker.sh && curl https://rclone.org/install.sh | bash
|
||||
|
||||
# Install Nixpacks and tsx
|
||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||
|
||||
ARG NIXPACKS_VERSION=1.39.0
|
||||
ARG NIXPACKS_VERSION=1.41.0
|
||||
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
||||
&& chmod +x install.sh \
|
||||
&& ./install.sh \
|
||||
&& pnpm install -g tsx
|
||||
|
||||
# Install Railpack
|
||||
ARG RAILPACK_VERSION=0.2.2
|
||||
ARG RAILPACK_VERSION=0.15.4
|
||||
RUN curl -sSL https://railpack.com/install.sh | bash
|
||||
|
||||
# Install buildpacks
|
||||
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
|
||||
COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
||||
|
||||
EXPOSE 3000
|
||||
CMD [ "pnpm", "start" ]
|
||||
|
||||
@@ -16,11 +16,11 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server
|
||||
|
||||
|
||||
# Deploy only the dokploy app
|
||||
ARG NEXT_PUBLIC_UMAMI_HOST
|
||||
ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
|
||||
# ARG NEXT_PUBLIC_UMAMI_HOST
|
||||
# ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
|
||||
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
# ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
# ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
|
||||
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||
@@ -60,4 +60,4 @@ RUN curl https://rclone.org/install.sh | bash
|
||||
RUN pnpm install -g tsx
|
||||
|
||||
EXPOSE 3000
|
||||
CMD [ "pnpm", "start" ]
|
||||
CMD [ "pnpm", "start" ]
|
||||
|
||||
@@ -35,4 +35,4 @@ COPY --from=build /prod/schedules/dist ./dist
|
||||
COPY --from=build /prod/schedules/package.json ./package.json
|
||||
COPY --from=build /prod/schedules/node_modules ./node_modules
|
||||
|
||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
||||
|
||||
@@ -35,4 +35,4 @@ COPY --from=build /prod/api/dist ./dist
|
||||
COPY --from=build /prod/api/package.json ./package.json
|
||||
COPY --from=build /prod/api/node_modules ./node_modules
|
||||
|
||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
||||
|
||||
@@ -77,6 +77,12 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||
<div>
|
||||
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
|
||||
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
|
||||
<a href="https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor" target="_blank">
|
||||
<img src="https://www.lambdatest.com/blue-logo.png" width="450" height="100" />
|
||||
</a>
|
||||
<a href="https://awesome.tools/" target="_blank">
|
||||
<img src=".github/sponsors/awesome.png" width="200" height="150" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Premium Supporters 🥇 -->
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/zod-validator": "0.3.0",
|
||||
"@nerimity/mimiqueue": "1.2.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"hono": "^4.7.10",
|
||||
"pino": "9.4.0",
|
||||
|
||||
@@ -25,7 +25,7 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
||||
titleLog: z.string().optional(),
|
||||
descriptionLog: z.string().optional(),
|
||||
server: z.boolean().optional(),
|
||||
type: z.enum(["deploy"]),
|
||||
type: z.enum(["deploy", "redeploy"]),
|
||||
applicationType: z.literal("application-preview"),
|
||||
serverId: z.string().min(1),
|
||||
}),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
deployRemoteApplication,
|
||||
deployRemoteCompose,
|
||||
deployRemotePreviewApplication,
|
||||
rebuildRemoteApplication,
|
||||
rebuildRemoteCompose,
|
||||
deployApplication,
|
||||
deployCompose,
|
||||
deployPreviewApplication,
|
||||
rebuildApplication,
|
||||
rebuildCompose,
|
||||
rebuildPreviewApplication,
|
||||
updateApplicationStatus,
|
||||
updateCompose,
|
||||
updatePreviewDeployment,
|
||||
@@ -16,13 +17,13 @@ export const deploy = async (job: DeployJob) => {
|
||||
await updateApplicationStatus(job.applicationId, "running");
|
||||
if (job.server) {
|
||||
if (job.type === "redeploy") {
|
||||
await rebuildRemoteApplication({
|
||||
await rebuildApplication({
|
||||
applicationId: job.applicationId,
|
||||
titleLog: job.titleLog || "Rebuild deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
});
|
||||
} else if (job.type === "deploy") {
|
||||
await deployRemoteApplication({
|
||||
await deployApplication({
|
||||
applicationId: job.applicationId,
|
||||
titleLog: job.titleLog || "Manual deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
@@ -36,13 +37,13 @@ export const deploy = async (job: DeployJob) => {
|
||||
|
||||
if (job.server) {
|
||||
if (job.type === "redeploy") {
|
||||
await rebuildRemoteCompose({
|
||||
await rebuildCompose({
|
||||
composeId: job.composeId,
|
||||
titleLog: job.titleLog || "Rebuild deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
});
|
||||
} else if (job.type === "deploy") {
|
||||
await deployRemoteCompose({
|
||||
await deployCompose({
|
||||
composeId: job.composeId,
|
||||
titleLog: job.titleLog || "Manual deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
@@ -54,8 +55,15 @@ export const deploy = async (job: DeployJob) => {
|
||||
previewStatus: "running",
|
||||
});
|
||||
if (job.server) {
|
||||
if (job.type === "deploy") {
|
||||
await deployRemotePreviewApplication({
|
||||
if (job.type === "redeploy") {
|
||||
await rebuildPreviewApplication({
|
||||
applicationId: job.applicationId,
|
||||
titleLog: job.titleLog || "Rebuild Preview Deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
previewDeploymentId: job.previewDeploymentId,
|
||||
});
|
||||
} else if (job.type === "deploy") {
|
||||
await deployPreviewApplication({
|
||||
applicationId: job.applicationId,
|
||||
titleLog: job.titleLog || "Preview Deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
NODE_ENV=development
|
||||
|
||||
243
apps/dokploy/__test__/cluster/upload.test.ts
Normal file
243
apps/dokploy/__test__/cluster/upload.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import type { Registry } from "@dokploy/server";
|
||||
import { getRegistryTag } from "@dokploy/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("getRegistryTag", () => {
|
||||
// Helper to create a mock registry
|
||||
const createMockRegistry = (overrides: Partial<Registry> = {}): Registry => {
|
||||
return {
|
||||
registryId: "test-registry-id",
|
||||
registryName: "Test Registry",
|
||||
username: "myuser",
|
||||
password: "test-password",
|
||||
registryUrl: "docker.io",
|
||||
registryType: "cloud",
|
||||
imagePrefix: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
organizationId: "test-org-id",
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
describe("with username (no imagePrefix)", () => {
|
||||
it("should handle simple image name without tag", () => {
|
||||
const registry = createMockRegistry({ username: "myuser" });
|
||||
const result = getRegistryTag(registry, "nginx");
|
||||
expect(result).toBe("docker.io/myuser/nginx");
|
||||
});
|
||||
|
||||
it("should handle image name with tag", () => {
|
||||
const registry = createMockRegistry({ username: "myuser" });
|
||||
const result = getRegistryTag(registry, "nginx:latest");
|
||||
expect(result).toBe("docker.io/myuser/nginx:latest");
|
||||
});
|
||||
|
||||
it("should handle image name with username already present (no duplication)", () => {
|
||||
const registry = createMockRegistry({ username: "myuser" });
|
||||
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||
// Should not duplicate username
|
||||
expect(result).toBe("docker.io/myuser/myprivaterepo");
|
||||
});
|
||||
|
||||
it("should handle image name with username and tag already present", () => {
|
||||
const registry = createMockRegistry({ username: "myuser" });
|
||||
const result = getRegistryTag(registry, "myuser/myprivaterepo:latest");
|
||||
// Should not duplicate username
|
||||
expect(result).toBe("docker.io/myuser/myprivaterepo:latest");
|
||||
});
|
||||
|
||||
it("should handle complex image name with username", () => {
|
||||
const registry = createMockRegistry({ username: "siumauricio" });
|
||||
const result = getRegistryTag(
|
||||
registry,
|
||||
"siumauricio/app-parse-multi-byte-port-e32uh7",
|
||||
);
|
||||
// Should not duplicate username
|
||||
expect(result).toBe(
|
||||
"docker.io/siumauricio/app-parse-multi-byte-port-e32uh7",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle image name with different username (should not duplicate)", () => {
|
||||
const registry = createMockRegistry({ username: "myuser" });
|
||||
const result = getRegistryTag(registry, "otheruser/myprivaterepo");
|
||||
expect(result).toBe("docker.io/myuser/myprivaterepo");
|
||||
});
|
||||
|
||||
it("should handle image name with full registry URL (no username)", () => {
|
||||
const registry = createMockRegistry({ username: "myuser" });
|
||||
const result = getRegistryTag(registry, "docker.io/nginx");
|
||||
// Should add username since imageName doesn't have one
|
||||
expect(result).toBe("docker.io/myuser/nginx");
|
||||
});
|
||||
|
||||
it("should handle image name with custom registry URL and username", () => {
|
||||
const registry = createMockRegistry({ username: "myuser" });
|
||||
const result = getRegistryTag(registry, "ghcr.io/myuser/repo");
|
||||
// Should not duplicate username even if registry URL is different
|
||||
expect(result).toBe("docker.io/myuser/repo");
|
||||
});
|
||||
|
||||
it("should handle image name with custom registry URL (different username)", () => {
|
||||
const registry = createMockRegistry({ username: "myuser" });
|
||||
const result = getRegistryTag(registry, "ghcr.io/otheruser/repo");
|
||||
// Should use registry username, not the one in imageName
|
||||
expect(result).toBe("docker.io/myuser/repo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with imagePrefix", () => {
|
||||
it("should use imagePrefix instead of username", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "myuser",
|
||||
imagePrefix: "myorg",
|
||||
});
|
||||
const result = getRegistryTag(registry, "nginx");
|
||||
expect(result).toBe("docker.io/myorg/nginx");
|
||||
});
|
||||
|
||||
it("should use imagePrefix with image tag", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "myuser",
|
||||
imagePrefix: "myorg",
|
||||
});
|
||||
const result = getRegistryTag(registry, "nginx:latest");
|
||||
expect(result).toBe("docker.io/myorg/nginx:latest");
|
||||
});
|
||||
|
||||
it("should handle imagePrefix with username already in image name", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "myuser",
|
||||
imagePrefix: "myorg",
|
||||
});
|
||||
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||
expect(result).toBe("docker.io/myorg/myprivaterepo");
|
||||
});
|
||||
|
||||
it("should handle imagePrefix matching image name prefix", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "myuser",
|
||||
imagePrefix: "myorg",
|
||||
});
|
||||
const result = getRegistryTag(registry, "myorg/myprivaterepo");
|
||||
// Should not duplicate prefix
|
||||
expect(result).toBe("docker.io/myorg/myprivaterepo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("without registryUrl", () => {
|
||||
it("should work without registryUrl", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "myuser",
|
||||
registryUrl: "",
|
||||
});
|
||||
const result = getRegistryTag(registry, "nginx");
|
||||
expect(result).toBe("myuser/nginx");
|
||||
});
|
||||
|
||||
it("should work without registryUrl with imagePrefix", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "myuser",
|
||||
imagePrefix: "myorg",
|
||||
registryUrl: "",
|
||||
});
|
||||
const result = getRegistryTag(registry, "nginx");
|
||||
expect(result).toBe("myorg/nginx");
|
||||
});
|
||||
|
||||
it("should handle username already present without registryUrl", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "myuser",
|
||||
registryUrl: "",
|
||||
});
|
||||
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||
// Should not duplicate username
|
||||
expect(result).toBe("myuser/myprivaterepo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with custom registryUrl", () => {
|
||||
it("should handle custom registry URL", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "myuser",
|
||||
registryUrl: "ghcr.io",
|
||||
});
|
||||
const result = getRegistryTag(registry, "nginx");
|
||||
expect(result).toBe("ghcr.io/myuser/nginx");
|
||||
});
|
||||
|
||||
it("should handle custom registry URL with imagePrefix", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "myuser",
|
||||
imagePrefix: "myorg",
|
||||
registryUrl: "ghcr.io",
|
||||
});
|
||||
const result = getRegistryTag(registry, "nginx");
|
||||
expect(result).toBe("ghcr.io/myorg/nginx");
|
||||
});
|
||||
|
||||
it("should handle custom registry URL with username already present", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "myuser",
|
||||
registryUrl: "ghcr.io",
|
||||
});
|
||||
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||
// Should not duplicate username
|
||||
expect(result).toBe("ghcr.io/myuser/myprivaterepo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty image name", () => {
|
||||
const registry = createMockRegistry({ username: "myuser" });
|
||||
const result = getRegistryTag(registry, "");
|
||||
expect(result).toBe("docker.io/myuser/");
|
||||
});
|
||||
|
||||
it("should handle image name with multiple slashes", () => {
|
||||
const registry = createMockRegistry({ username: "myuser" });
|
||||
const result = getRegistryTag(registry, "org/suborg/repo");
|
||||
expect(result).toBe("docker.io/myuser/repo");
|
||||
});
|
||||
|
||||
it("should handle image name with username at different position", () => {
|
||||
const registry = createMockRegistry({ username: "myuser" });
|
||||
const result = getRegistryTag(registry, "org/myuser/repo");
|
||||
expect(result).toBe("docker.io/myuser/repo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("special characters in username", () => {
|
||||
it("should handle Harbor robot account username with $ (e.g. robot$library+dokploy)", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "robot$library+dokploy",
|
||||
});
|
||||
const result = getRegistryTag(registry, "nginx");
|
||||
expect(result).toBe("docker.io/robot$library+dokploy/nginx");
|
||||
});
|
||||
|
||||
it("should handle username with $ and other special characters", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "robot$test+app",
|
||||
});
|
||||
const result = getRegistryTag(registry, "myapp:latest");
|
||||
expect(result).toBe("docker.io/robot$test+app/myapp:latest");
|
||||
});
|
||||
|
||||
it("should handle username with multiple $ symbols", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "user$name$test",
|
||||
});
|
||||
const result = getRegistryTag(registry, "app");
|
||||
expect(result).toBe("docker.io/user$name$test/app");
|
||||
});
|
||||
|
||||
it("should handle username with + and - symbols", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "robot+test-user",
|
||||
});
|
||||
const result = getRegistryTag(registry, "nginx:latest");
|
||||
expect(result).toBe("docker.io/robot+test-user/nginx:latest");
|
||||
});
|
||||
});
|
||||
});
|
||||
215
apps/dokploy/__test__/compose/domain/host-rule-format.test.ts
Normal file
215
apps/dokploy/__test__/compose/domain/host-rule-format.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import type { Domain } from "@dokploy/server";
|
||||
import { createDomainLabels } from "@dokploy/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
/**
|
||||
* Regression tests for Traefik Host rule label format.
|
||||
*
|
||||
* These tests verify that the Host rule is generated with the correct format:
|
||||
* - Host(`domain.com`) - with opening and closing parentheses
|
||||
* - Host(`domain.com`) && PathPrefix(`/path`) - for path-based routing
|
||||
*
|
||||
* Issue: https://github.com/Dokploy/dokploy/issues/3161
|
||||
* The bug caused Host rules to be malformed as Host`domain.com`)
|
||||
* (missing opening parenthesis) which broke all domain routing.
|
||||
*/
|
||||
describe("Host rule format regression tests", () => {
|
||||
const baseDomain: Domain = {
|
||||
host: "example.com",
|
||||
port: 8080,
|
||||
https: false,
|
||||
uniqueConfigKey: 1,
|
||||
customCertResolver: null,
|
||||
certificateType: "none",
|
||||
applicationId: "",
|
||||
composeId: "",
|
||||
domainType: "compose",
|
||||
serviceName: "test-app",
|
||||
domainId: "",
|
||||
path: "/",
|
||||
createdAt: "",
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
it("should generate Host rule with correct parentheses format", async () => {
|
||||
const labels = await createDomainLabels("test-app", baseDomain, "web");
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
// Verify exact format: Host(`domain`)
|
||||
expect(ruleLabel).toMatch(/Host\(`[^`]+`\)/);
|
||||
// Ensure opening parenthesis is present after Host
|
||||
expect(ruleLabel).toContain("Host(`example.com`)");
|
||||
// Ensure it does NOT have the malformed format
|
||||
expect(ruleLabel).not.toMatch(/Host`[^`]+`\)/);
|
||||
});
|
||||
|
||||
it("should generate PathPrefix with correct parentheses format", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path: "/api" },
|
||||
"web",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
// Verify PathPrefix format
|
||||
expect(ruleLabel).toMatch(/PathPrefix\(`[^`]+`\)/);
|
||||
expect(ruleLabel).toContain("PathPrefix(`/api`)");
|
||||
// Ensure opening parenthesis is present
|
||||
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
|
||||
});
|
||||
|
||||
it("should generate combined Host and PathPrefix with correct format", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path: "/api/v1" },
|
||||
"websecure",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
expect(ruleLabel).toBe(
|
||||
"traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`) && PathPrefix(`/api/v1`)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("YAML serialization preserves Host rule format", () => {
|
||||
it("should preserve Host rule format through YAML stringify/parse", async () => {
|
||||
const labels = await createDomainLabels("test-app", baseDomain, "web");
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
// Simulate compose file structure
|
||||
const composeSpec = {
|
||||
services: {
|
||||
myapp: {
|
||||
image: "nginx",
|
||||
labels: labels,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Stringify to YAML
|
||||
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
|
||||
|
||||
// Parse back
|
||||
const parsed = parse(yamlOutput) as typeof composeSpec;
|
||||
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
|
||||
l.includes(".rule="),
|
||||
);
|
||||
|
||||
// Verify format is preserved
|
||||
expect(parsedRuleLabel).toBe(ruleLabel);
|
||||
expect(parsedRuleLabel).toContain("Host(`example.com`)");
|
||||
expect(parsedRuleLabel).not.toMatch(/Host`[^`]+`\)/);
|
||||
});
|
||||
|
||||
it("should preserve complex rule format through YAML serialization", async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path: "/api", https: true },
|
||||
"websecure",
|
||||
);
|
||||
|
||||
const composeSpec = {
|
||||
services: {
|
||||
myapp: {
|
||||
labels: labels,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
|
||||
const parsed = parse(yamlOutput) as typeof composeSpec;
|
||||
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
|
||||
l.includes(".rule="),
|
||||
);
|
||||
|
||||
expect(parsedRuleLabel).toContain(
|
||||
"Host(`example.com`) && PathPrefix(`/api`)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases for domain names", () => {
|
||||
const domainCases = [
|
||||
{ name: "simple domain", host: "example.com" },
|
||||
{ name: "subdomain", host: "app.example.com" },
|
||||
{ name: "deep subdomain", host: "api.v1.app.example.com" },
|
||||
{ name: "numeric domain", host: "123.example.com" },
|
||||
{ name: "hyphenated domain", host: "my-app.example-host.com" },
|
||||
{ name: "localhost", host: "localhost" },
|
||||
{ name: "IP address style", host: "192.168.1.100" },
|
||||
];
|
||||
|
||||
for (const { name, host } of domainCases) {
|
||||
it(`should generate correct Host rule for ${name}: ${host}`, async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, host },
|
||||
"web",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
expect(ruleLabel).toContain(`Host(\`${host}\`)`);
|
||||
// Verify parenthesis is present
|
||||
expect(ruleLabel).toMatch(
|
||||
new RegExp(`Host\\(\\\`${host.replace(/\./g, "\\.")}\\\`\\)`),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("Multiple domains scenario", () => {
|
||||
it("should generate correct format for both web and websecure entrypoints", async () => {
|
||||
const webLabels = await createDomainLabels("test-app", baseDomain, "web");
|
||||
const websecureLabels = await createDomainLabels(
|
||||
"test-app",
|
||||
baseDomain,
|
||||
"websecure",
|
||||
);
|
||||
|
||||
const webRule = webLabels.find((l) => l.includes(".rule="));
|
||||
const websecureRule = websecureLabels.find((l) => l.includes(".rule="));
|
||||
|
||||
// Both should have correct format
|
||||
expect(webRule).toContain("Host(`example.com`)");
|
||||
expect(websecureRule).toContain("Host(`example.com`)");
|
||||
|
||||
// Neither should have malformed format
|
||||
expect(webRule).not.toMatch(/Host`[^`]+`\)/);
|
||||
expect(websecureRule).not.toMatch(/Host`[^`]+`\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Special characters in paths", () => {
|
||||
const pathCases = [
|
||||
{ name: "simple path", path: "/api" },
|
||||
{ name: "nested path", path: "/api/v1/users" },
|
||||
{ name: "path with hyphen", path: "/api-v1" },
|
||||
{ name: "path with underscore", path: "/api_v1" },
|
||||
];
|
||||
|
||||
for (const { name, path } of pathCases) {
|
||||
it(`should generate correct PathPrefix for ${name}: ${path}`, async () => {
|
||||
const labels = await createDomainLabels(
|
||||
"test-app",
|
||||
{ ...baseDomain, path },
|
||||
"web",
|
||||
);
|
||||
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||
|
||||
expect(ruleLabel).toBeDefined();
|
||||
expect(ruleLabel).toContain(`PathPrefix(\`${path}\`)`);
|
||||
// Verify parenthesis is present
|
||||
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
276
apps/dokploy/__test__/deploy/application.command.test.ts
Normal file
276
apps/dokploy/__test__/deploy/application.command.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import * as adminService from "@dokploy/server/services/admin";
|
||||
import * as applicationService from "@dokploy/server/services/application";
|
||||
import { deployApplication } from "@dokploy/server/services/application";
|
||||
import * as deploymentService from "@dokploy/server/services/deployment";
|
||||
import * as builders from "@dokploy/server/utils/builders";
|
||||
import * as notifications from "@dokploy/server/utils/notifications/build-success";
|
||||
import * as execProcess from "@dokploy/server/utils/process/execAsync";
|
||||
import * as gitProvider from "@dokploy/server/utils/providers/git";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const createChainableMock = (): any => {
|
||||
const chain = {
|
||||
set: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
returning: vi.fn().mockResolvedValue([{}] as any),
|
||||
} as any;
|
||||
return chain;
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(() => createChainableMock()),
|
||||
delete: vi.fn(),
|
||||
query: {
|
||||
applications: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/application", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@dokploy/server/services/application")
|
||||
>("@dokploy/server/services/application");
|
||||
return {
|
||||
...actual,
|
||||
findApplicationById: vi.fn(),
|
||||
updateApplicationStatus: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/admin", () => ({
|
||||
getDokployUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/deployment", () => ({
|
||||
createDeployment: vi.fn(),
|
||||
updateDeploymentStatus: vi.fn(),
|
||||
updateDeployment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/providers/git", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@dokploy/server/utils/providers/git")
|
||||
>("@dokploy/server/utils/providers/git");
|
||||
return {
|
||||
...actual,
|
||||
getGitCommitInfo: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsync: vi.fn(),
|
||||
ExecError: class ExecError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/builders", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@dokploy/server/utils/builders")
|
||||
>("@dokploy/server/utils/builders");
|
||||
return {
|
||||
...actual,
|
||||
mechanizeDockerContainer: vi.fn(),
|
||||
getBuildCommand: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
|
||||
sendBuildSuccessNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
|
||||
sendBuildErrorNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/rollbacks", () => ({
|
||||
createRollback: vi.fn(),
|
||||
}));
|
||||
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
|
||||
|
||||
const createMockApplication = (overrides = {}) => ({
|
||||
applicationId: "test-app-id",
|
||||
name: "Test App",
|
||||
appName: "test-app",
|
||||
sourceType: "git" as const,
|
||||
customGitUrl: "https://github.com/Dokploy/examples.git",
|
||||
customGitBranch: "main",
|
||||
customGitSSHKeyId: null,
|
||||
buildType: "nixpacks" as const,
|
||||
buildPath: "/astro",
|
||||
env: "NODE_ENV=production",
|
||||
serverId: null,
|
||||
rollbackActive: false,
|
||||
enableSubmodules: false,
|
||||
environmentId: "env-id",
|
||||
environment: {
|
||||
projectId: "project-id",
|
||||
env: "",
|
||||
name: "production",
|
||||
project: {
|
||||
name: "Test Project",
|
||||
organizationId: "org-id",
|
||||
env: "",
|
||||
},
|
||||
},
|
||||
domains: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockDeployment = () => ({
|
||||
deploymentId: "deployment-id",
|
||||
logPath: "/tmp/test-deployment.log",
|
||||
});
|
||||
|
||||
describe("deployApplication - Command Generation Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
createMockApplication() as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
createMockApplication() as any,
|
||||
);
|
||||
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
|
||||
"http://localhost:3000",
|
||||
);
|
||||
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
|
||||
createMockDeployment() as any,
|
||||
);
|
||||
vi.mocked(execProcess.execAsync).mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
} as any);
|
||||
vi.mocked(builders.mechanizeDockerContainer).mockResolvedValue(
|
||||
undefined as any,
|
||||
);
|
||||
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
|
||||
undefined as any,
|
||||
);
|
||||
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
|
||||
{} as any,
|
||||
);
|
||||
vi.mocked(notifications.sendBuildSuccessNotifications).mockResolvedValue(
|
||||
undefined as any,
|
||||
);
|
||||
vi.mocked(gitProvider.getGitCommitInfo).mockResolvedValue({
|
||||
message: "test commit",
|
||||
hash: "abc123",
|
||||
});
|
||||
vi.mocked(deploymentService.updateDeployment).mockResolvedValue({} as any);
|
||||
});
|
||||
|
||||
it("should generate correct git clone command for astro example", async () => {
|
||||
const app = createMockApplication();
|
||||
const command = await cloneGitRepository(app);
|
||||
console.log(command);
|
||||
|
||||
expect(command).toContain("https://github.com/Dokploy/examples.git");
|
||||
expect(command).not.toContain("--recurse-submodules");
|
||||
expect(command).toContain("--branch main");
|
||||
expect(command).toContain("--depth 1");
|
||||
expect(command).toContain("git clone");
|
||||
});
|
||||
|
||||
it("should generate git clone with submodules when enabled", async () => {
|
||||
const app = createMockApplication({ enableSubmodules: true });
|
||||
const command = await cloneGitRepository(app);
|
||||
|
||||
expect(command).toContain("--recurse-submodules");
|
||||
expect(command).toContain("https://github.com/Dokploy/examples.git");
|
||||
});
|
||||
|
||||
it("should verify nixpacks command is called with correct app", async () => {
|
||||
const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app";
|
||||
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Test deployment",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(builders.getBuildCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildType: "nixpacks",
|
||||
customGitUrl: "https://github.com/Dokploy/examples.git",
|
||||
buildPath: "/astro",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(execProcess.execAsync).toHaveBeenCalledWith(
|
||||
expect.stringContaining("nixpacks build"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should verify railpack command includes correct parameters", async () => {
|
||||
const mockApp = createMockApplication({ buildType: "railpack" });
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
mockApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
mockApp as any,
|
||||
);
|
||||
|
||||
const mockRailpackCommand = "railpack prepare /path/to/app";
|
||||
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockRailpackCommand);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Railpack test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(builders.getBuildCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
buildType: "railpack",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(execProcess.execAsync).toHaveBeenCalledWith(
|
||||
expect.stringContaining("railpack prepare"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should execute commands in correct order", async () => {
|
||||
const mockNixpacksCommand = "nixpacks build";
|
||||
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
|
||||
expect(execCalls.length).toBeGreaterThan(0);
|
||||
|
||||
const fullCommand = execCalls[0]?.[0];
|
||||
expect(fullCommand).toContain("set -e");
|
||||
expect(fullCommand).toContain("git clone");
|
||||
expect(fullCommand).toContain("nixpacks build");
|
||||
});
|
||||
|
||||
it("should include log redirection in command", async () => {
|
||||
const mockCommand = "nixpacks build";
|
||||
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockCommand);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
|
||||
const fullCommand = execCalls[0]?.[0];
|
||||
|
||||
expect(fullCommand).toContain(">> /tmp/test-deployment.log 2>&1");
|
||||
});
|
||||
});
|
||||
479
apps/dokploy/__test__/deploy/application.real.test.ts
Normal file
479
apps/dokploy/__test__/deploy/application.real.test.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ApplicationNested } from "@dokploy/server";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { execAsync } from "@dokploy/server/utils/process/execAsync";
|
||||
import { format } from "date-fns";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
|
||||
|
||||
// Mock ONLY database and notifications
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const createChainableMock = (): any => {
|
||||
const chain: any = {
|
||||
set: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
returning: vi.fn().mockResolvedValue([{}]),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(() => createChainableMock()),
|
||||
delete: vi.fn(),
|
||||
query: {
|
||||
applications: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/application", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@dokploy/server/services/application")
|
||||
>("@dokploy/server/services/application");
|
||||
return {
|
||||
...actual,
|
||||
findApplicationById: vi.fn(),
|
||||
updateApplicationStatus: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/services/admin", () => ({
|
||||
getDokployUrl: vi.fn().mockResolvedValue("http://localhost:3000"),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/deployment", () => ({
|
||||
createDeployment: vi.fn(),
|
||||
updateDeploymentStatus: vi.fn(),
|
||||
updateDeployment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
|
||||
sendBuildSuccessNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
|
||||
sendBuildErrorNotifications: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/rollbacks", () => ({
|
||||
createRollback: vi.fn(),
|
||||
}));
|
||||
|
||||
// NOT mocked (executed for real):
|
||||
// - execAsync
|
||||
// - cloneGitRepository
|
||||
// - getBuildCommand
|
||||
// - mechanizeDockerContainer (requires Docker Swarm)
|
||||
|
||||
import { db } from "@dokploy/server/db";
|
||||
import * as adminService from "@dokploy/server/services/admin";
|
||||
import * as applicationService from "@dokploy/server/services/application";
|
||||
import { deployApplication } from "@dokploy/server/services/application";
|
||||
import * as deploymentService from "@dokploy/server/services/deployment";
|
||||
|
||||
const createMockApplication = (
|
||||
overrides: Partial<ApplicationNested> = {},
|
||||
): ApplicationNested =>
|
||||
({
|
||||
applicationId: "test-app-id",
|
||||
name: "Real Test App",
|
||||
appName: `real-test-${Date.now()}`,
|
||||
sourceType: "git" as const,
|
||||
customGitUrl: "https://github.com/Dokploy/examples.git",
|
||||
customGitBranch: "main",
|
||||
customGitSSHKeyId: null,
|
||||
customGitBuildPath: "/astro",
|
||||
buildType: "nixpacks" as const,
|
||||
env: "NODE_ENV=production",
|
||||
serverId: null,
|
||||
rollbackActive: false,
|
||||
enableSubmodules: false,
|
||||
environmentId: "env-id",
|
||||
environment: {
|
||||
projectId: "project-id",
|
||||
env: "",
|
||||
name: "production",
|
||||
project: {
|
||||
name: "Test Project",
|
||||
organizationId: "org-id",
|
||||
env: "",
|
||||
},
|
||||
},
|
||||
domains: [],
|
||||
mounts: [],
|
||||
security: [],
|
||||
redirects: [],
|
||||
ports: [],
|
||||
registry: null,
|
||||
...overrides,
|
||||
}) as ApplicationNested;
|
||||
|
||||
const createMockDeployment = async (appName: string) => {
|
||||
const { LOGS_PATH } = paths(false); // false = local, no remote server
|
||||
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
|
||||
const fileName = `${appName}-${formattedDateTime}.log`;
|
||||
const logFilePath = path.join(LOGS_PATH, appName, fileName);
|
||||
|
||||
// Actually create the log directory
|
||||
await execAsync(`mkdir -p ${path.dirname(logFilePath)}`);
|
||||
await execAsync(`echo "Initializing deployment" > ${logFilePath}`);
|
||||
|
||||
return {
|
||||
deploymentId: "deployment-id",
|
||||
logPath: logFilePath,
|
||||
};
|
||||
};
|
||||
|
||||
async function cleanupDocker(appName: string) {
|
||||
try {
|
||||
await execAsync(`docker stop ${appName} 2>/dev/null || true`);
|
||||
await execAsync(`docker rm ${appName} 2>/dev/null || true`);
|
||||
await execAsync(`docker rmi ${appName} 2>/dev/null || true`);
|
||||
} catch (error) {
|
||||
console.log("Docker cleanup completed");
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupFiles(appName: string) {
|
||||
try {
|
||||
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
|
||||
|
||||
// Clean cloned code directories
|
||||
const appPath = path.join(APPLICATIONS_PATH, appName);
|
||||
await execAsync(`rm -rf ${appPath} 2>/dev/null || true`);
|
||||
|
||||
// Clean logs for appName - removes entire folder
|
||||
const logPath = path.join(LOGS_PATH, appName);
|
||||
await execAsync(`rm -rf ${logPath} 2>/dev/null || true`);
|
||||
|
||||
console.log(`✅ Cleaned up files and logs for ${appName}`);
|
||||
} catch (error) {
|
||||
console.error(`⚠️ Error during cleanup for ${appName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
describe(
|
||||
"deployApplication - REAL Execution Tests",
|
||||
() => {
|
||||
let currentAppName: string;
|
||||
let currentDeployment: any;
|
||||
const allTestAppNames: string[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
currentAppName = `real-test-${Date.now()}`;
|
||||
currentDeployment = await createMockDeployment(currentAppName);
|
||||
allTestAppNames.push(currentAppName);
|
||||
|
||||
const mockApp = createMockApplication({ appName: currentAppName });
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
mockApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
mockApp as any,
|
||||
);
|
||||
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
|
||||
"http://localhost:3000",
|
||||
);
|
||||
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
|
||||
currentDeployment as any,
|
||||
);
|
||||
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
|
||||
undefined as any,
|
||||
);
|
||||
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
|
||||
{} as any,
|
||||
);
|
||||
vi.mocked(deploymentService.updateDeployment).mockResolvedValue(
|
||||
{} as any,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// ALWAYS cleanup, even if test failed or passed
|
||||
console.log(`\n🧹 Cleaning up test: ${currentAppName}`);
|
||||
|
||||
// Clean current appName
|
||||
try {
|
||||
await cleanupDocker(currentAppName);
|
||||
await cleanupFiles(currentAppName);
|
||||
} catch (error) {
|
||||
console.error("⚠️ Error cleaning current app:", error);
|
||||
}
|
||||
|
||||
// Clean ALL test folders just in case
|
||||
try {
|
||||
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
|
||||
await execAsync(`rm -rf ${LOGS_PATH}/real-* 2>/dev/null || true`);
|
||||
await execAsync(
|
||||
`rm -rf ${APPLICATIONS_PATH}/real-* 2>/dev/null || true`,
|
||||
);
|
||||
console.log("✅ Cleaned up all test artifacts");
|
||||
} catch (error) {
|
||||
console.error("⚠️ Error cleaning all artifacts:", error);
|
||||
}
|
||||
|
||||
console.log("✅ Cleanup completed\n");
|
||||
});
|
||||
|
||||
it(
|
||||
"should REALLY clone git repo and build with nixpacks",
|
||||
async () => {
|
||||
console.log(`\n🚀 Testing real deployment with app: ${currentAppName}`);
|
||||
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Nixpacks Test",
|
||||
descriptionLog: "Testing real execution",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify that Docker image was actually created
|
||||
const { stdout: dockerImages } = await execAsync(
|
||||
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||
);
|
||||
console.log("dockerImages", dockerImages);
|
||||
expect(dockerImages.trim()).toBe(currentAppName);
|
||||
console.log(`✅ Docker image created: ${currentAppName}`);
|
||||
|
||||
// Verify log exists and has content
|
||||
expect(existsSync(currentDeployment.logPath)).toBe(true);
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent).toContain("Cloning");
|
||||
expect(logContent).toContain("nixpacks");
|
||||
console.log(`✅ Build log created with ${logContent.length} chars`);
|
||||
|
||||
// Verify update functions were called
|
||||
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
|
||||
"deployment-id",
|
||||
"done",
|
||||
);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it.skip(
|
||||
"should REALLY build with railpack (SKIPPED: requires special permissions)",
|
||||
async () => {
|
||||
const railpackAppName = `real-railpack-${Date.now()}`;
|
||||
const railpackApp = createMockApplication({
|
||||
appName: railpackAppName,
|
||||
buildType: "railpack",
|
||||
railpackVersion: "3",
|
||||
});
|
||||
currentAppName = railpackAppName;
|
||||
allTestAppNames.push(railpackAppName);
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
railpackApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
railpackApp as any,
|
||||
);
|
||||
|
||||
console.log(`\n🚀 Testing real railpack deployment: ${currentAppName}`);
|
||||
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Railpack Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
const { stdout: dockerImages } = await execAsync(
|
||||
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||
);
|
||||
expect(dockerImages.trim()).toBe(currentAppName);
|
||||
console.log(`✅ Railpack image created: ${currentAppName}`);
|
||||
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent).toContain("railpack");
|
||||
console.log("✅ Railpack build completed");
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"should handle REAL git clone errors",
|
||||
async () => {
|
||||
const errorAppName = `real-error-${Date.now()}`;
|
||||
const errorApp = createMockApplication({
|
||||
appName: errorAppName,
|
||||
customGitUrl:
|
||||
"https://github.com/invalid/nonexistent-repo-123456.git",
|
||||
});
|
||||
currentAppName = errorAppName;
|
||||
allTestAppNames.push(errorAppName);
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
errorApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
errorApp as any,
|
||||
);
|
||||
|
||||
console.log(`\n🚀 Testing real error handling: ${currentAppName}`);
|
||||
|
||||
await expect(
|
||||
deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Error Test",
|
||||
descriptionLog: "",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
// Verify error status was called
|
||||
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
|
||||
"deployment-id",
|
||||
"error",
|
||||
);
|
||||
|
||||
// Verify log contains error
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent.toLowerCase()).toContain("error");
|
||||
console.log("✅ Error handling verified");
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"should REALLY clone with submodules when enabled",
|
||||
async () => {
|
||||
const submodulesAppName = `real-submodules-${Date.now()}`;
|
||||
const submodulesApp = createMockApplication({
|
||||
appName: submodulesAppName,
|
||||
enableSubmodules: true,
|
||||
});
|
||||
currentAppName = submodulesAppName;
|
||||
allTestAppNames.push(submodulesAppName);
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
submodulesApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
submodulesApp as any,
|
||||
);
|
||||
|
||||
console.log(`\n🚀 Testing real submodules support: ${currentAppName}`);
|
||||
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Submodules Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify deployment completed successfully
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent).toContain("Cloning");
|
||||
expect(logContent.length).toBeGreaterThan(100);
|
||||
console.log("✅ Submodules deployment completed");
|
||||
|
||||
// Verify image
|
||||
const { stdout: dockerImages } = await execAsync(
|
||||
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||
);
|
||||
expect(dockerImages.trim()).toBe(currentAppName);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"should verify REAL commit info extraction",
|
||||
async () => {
|
||||
console.log(`\n🚀 Testing real commit info: ${currentAppName}`);
|
||||
|
||||
await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Commit Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
// Verify updateDeployment was called with commit info
|
||||
expect(deploymentService.updateDeployment).toHaveBeenCalled();
|
||||
const updateCall = vi.mocked(deploymentService.updateDeployment).mock
|
||||
.calls[0];
|
||||
|
||||
// Real commit info should have title and hash
|
||||
expect(updateCall?.[1]).toHaveProperty("title");
|
||||
expect(updateCall?.[1]).toHaveProperty("description");
|
||||
expect(updateCall?.[1]?.description).toContain("Commit:");
|
||||
|
||||
console.log(
|
||||
`✅ Real commit extracted: ${updateCall?.[1]?.title?.substring(0, 50)}...`,
|
||||
);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
"should REALLY build with Dockerfile",
|
||||
async () => {
|
||||
const dockerfileAppName = `real-dockerfile-${Date.now()}`;
|
||||
const dockerfileApp = createMockApplication({
|
||||
appName: dockerfileAppName,
|
||||
buildType: "dockerfile",
|
||||
customGitBuildPath: "/deno",
|
||||
dockerfile: "Dockerfile",
|
||||
});
|
||||
currentAppName = dockerfileAppName;
|
||||
allTestAppNames.push(dockerfileAppName);
|
||||
|
||||
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
|
||||
dockerfileApp as any,
|
||||
);
|
||||
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
|
||||
dockerfileApp as any,
|
||||
);
|
||||
|
||||
console.log(`\n🚀 Testing real Dockerfile build: ${currentAppName}`);
|
||||
|
||||
const result = await deployApplication({
|
||||
applicationId: "test-app-id",
|
||||
titleLog: "Real Dockerfile Test",
|
||||
descriptionLog: "",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
|
||||
// Verify log
|
||||
const { stdout: logContent } = await execAsync(
|
||||
`cat ${currentDeployment.logPath}`,
|
||||
);
|
||||
expect(logContent).toContain("Building");
|
||||
expect(logContent).toContain(dockerfileAppName);
|
||||
console.log("✅ Dockerfile build log verified");
|
||||
|
||||
// Verify image
|
||||
const { stdout: dockerImages } = await execAsync(
|
||||
`docker images ${currentAppName} --format "{{.Repository}}"`,
|
||||
);
|
||||
console.log("dockerImages", dockerImages);
|
||||
expect(dockerImages.trim()).toBe(currentAppName);
|
||||
console.log(`✅ Docker image created: ${currentAppName}`);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
},
|
||||
REAL_TEST_TIMEOUT,
|
||||
);
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]";
|
||||
import {
|
||||
extractCommitMessage,
|
||||
extractImageName,
|
||||
extractImageTag,
|
||||
extractImageTagFromRequest,
|
||||
} from "@/pages/api/deploy/[refreshToken]";
|
||||
|
||||
describe("GitHub Webhook Skip CI", () => {
|
||||
const mockGithubHeaders = {
|
||||
@@ -96,3 +101,308 @@ describe("GitHub Webhook Skip CI", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitHub Packages Docker Image Tag Extraction", () => {
|
||||
it("should extract tag from container_metadata", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
container_metadata: {
|
||||
tag: {
|
||||
name: "v1.0.0",
|
||||
digest: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
package_url: "ghcr.io/owner/repo:v1.0.0",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBe("v1.0.0");
|
||||
});
|
||||
|
||||
it("should extract tag from package_url when container_metadata tag matches version", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
container_metadata: {
|
||||
tag: {
|
||||
name: "sha256:abc123...",
|
||||
digest: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
package_url: "ghcr.io/owner/repo:latest",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBe("latest");
|
||||
});
|
||||
|
||||
it("should extract tag from package_url when container_metadata is missing", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
package_url: "ghcr.io/owner/repo:1.2.3",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBe("1.2.3");
|
||||
});
|
||||
|
||||
it("should handle different tag formats in package_url", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const testCases = [
|
||||
{ url: "ghcr.io/owner/repo:latest", expected: "latest" },
|
||||
{ url: "ghcr.io/owner/repo:v1.0.0", expected: "v1.0.0" },
|
||||
{ url: "ghcr.io/owner/repo:1.2.3", expected: "1.2.3" },
|
||||
{ url: "ghcr.io/owner/repo:dev", expected: "dev" },
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
package_url: testCase.url,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return null for non-registry_package events", () => {
|
||||
const headers = { "x-github-event": "push" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
package_url: "ghcr.io/owner/repo:latest",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when package_version is missing", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when package_url has no tag", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
package_url: "ghcr.io/owner/repo",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when package_url ends with colon (no tag)", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
package_url: "ghcr.io/owner/repo:",
|
||||
container_metadata: {
|
||||
tag: {
|
||||
name: "",
|
||||
digest: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when tag name is empty string", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
container_metadata: {
|
||||
tag: {
|
||||
name: "",
|
||||
digest: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
package_url: "ghcr.io/owner/repo:",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBeNull();
|
||||
});
|
||||
|
||||
it("should ignore tag if it matches the version (digest)", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
container_metadata: {
|
||||
tag: {
|
||||
name: "sha256:abc123...",
|
||||
digest: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
package_url: "ghcr.io/owner/repo:latest",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBe("latest");
|
||||
});
|
||||
|
||||
it("should handle registry_package commit message with package_url", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
package_url: "ghcr.io/owner/repo:latest",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const message = extractCommitMessage(headers, body);
|
||||
expect(message).toBe("Docker GHCR image pushed: ghcr.io/owner/repo:latest");
|
||||
});
|
||||
|
||||
it("should handle registry_package commit message when package_url is missing", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const message = extractCommitMessage(headers, body);
|
||||
expect(message).toBe("Docker GHCR image pushed");
|
||||
});
|
||||
|
||||
it("should handle registry_package commit message when package_version is missing", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {},
|
||||
};
|
||||
|
||||
const message = extractCommitMessage(headers, body);
|
||||
expect(message).toBe("NEW COMMIT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Docker Image Name and Tag Extraction", () => {
|
||||
describe("extractImageName", () => {
|
||||
it("should return image name without tag", () => {
|
||||
expect(extractImageName("my-image:latest")).toBe("my-image");
|
||||
expect(extractImageName("my-image:1.0.0")).toBe("my-image");
|
||||
expect(extractImageName("ghcr.io/owner/repo:latest")).toBe(
|
||||
"ghcr.io/owner/repo",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return full image name when no tag is present", () => {
|
||||
expect(extractImageName("my-image")).toBe("my-image");
|
||||
expect(extractImageName("ghcr.io/owner/repo")).toBe("ghcr.io/owner/repo");
|
||||
});
|
||||
|
||||
it("should handle images with port numbers correctly", () => {
|
||||
expect(extractImageName("registry:5000/image:tag")).toBe(
|
||||
"registry:5000/image",
|
||||
);
|
||||
expect(extractImageName("localhost:5000/my-app:latest")).toBe(
|
||||
"localhost:5000/my-app",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle complex image paths", () => {
|
||||
expect(
|
||||
extractImageName("myregistryhost:5000/fedora/httpd:version1.0"),
|
||||
).toBe("myregistryhost:5000/fedora/httpd");
|
||||
expect(extractImageName("registry.example.com:8080/ns/app:v1.2.3")).toBe(
|
||||
"registry.example.com:8080/ns/app",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null for invalid inputs", () => {
|
||||
expect(extractImageName(null)).toBeNull();
|
||||
expect(extractImageName("")).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle edge cases with multiple colons", () => {
|
||||
expect(extractImageName("image:tag:extra")).toBe("image:tag");
|
||||
expect(extractImageName("registry:5000:invalid")).toBe("registry:5000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractImageTag", () => {
|
||||
it("should extract tag from image with tag", () => {
|
||||
expect(extractImageTag("my-image:latest")).toBe("latest");
|
||||
expect(extractImageTag("my-image:1.0.0")).toBe("1.0.0");
|
||||
expect(extractImageTag("ghcr.io/owner/repo:v1.2.3")).toBe("v1.2.3");
|
||||
});
|
||||
|
||||
it("should return 'latest' when no tag is present", () => {
|
||||
expect(extractImageTag("my-image")).toBe("latest");
|
||||
expect(extractImageTag("ghcr.io/owner/repo")).toBe("latest");
|
||||
});
|
||||
|
||||
it("should handle complex image paths with tags", () => {
|
||||
expect(
|
||||
extractImageTag("myregistryhost:5000/fedora/httpd:version1.0"),
|
||||
).toBe("version1.0");
|
||||
expect(extractImageTag("registry.example.com:8080/ns/app:v1.2.3")).toBe(
|
||||
"v1.2.3",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null for invalid inputs", () => {
|
||||
expect(extractImageTag(null)).toBeNull();
|
||||
expect(extractImageTag("")).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle edge cases with multiple colons", () => {
|
||||
expect(extractImageTag("image:tag:extra")).toBe("extra");
|
||||
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
|
||||
});
|
||||
|
||||
it("should handle numeric tags", () => {
|
||||
expect(extractImageTag("my-image:123")).toBe("123");
|
||||
expect(extractImageTag("my-image:1")).toBe("1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,11 +25,16 @@ if (typeof window === "undefined") {
|
||||
}
|
||||
|
||||
const baseApp: ApplicationNested = {
|
||||
railpackVersion: "0.2.2",
|
||||
railpackVersion: "0.15.4",
|
||||
applicationId: "",
|
||||
previewLabels: [],
|
||||
createEnvFile: true,
|
||||
herokuVersion: "",
|
||||
giteaBranch: "",
|
||||
buildServerId: "",
|
||||
buildRegistryId: "",
|
||||
buildRegistry: null,
|
||||
args: [],
|
||||
giteaBuildPath: "",
|
||||
previewRequireCollaboratorPermissions: false,
|
||||
giteaId: "",
|
||||
@@ -37,17 +42,22 @@ const baseApp: ApplicationNested = {
|
||||
giteaRepository: "",
|
||||
cleanCache: false,
|
||||
watchPaths: [],
|
||||
rollbackRegistryId: "",
|
||||
rollbackRegistry: null,
|
||||
deployments: [],
|
||||
enableSubmodules: false,
|
||||
applicationStatus: "done",
|
||||
triggerType: "push",
|
||||
appName: "",
|
||||
autoDeploy: true,
|
||||
endpointSpecSwarm: null,
|
||||
serverId: "",
|
||||
registryUrl: "",
|
||||
branch: null,
|
||||
dockerBuildStage: "",
|
||||
isPreviewDeploymentsActive: false,
|
||||
previewBuildArgs: null,
|
||||
previewBuildSecrets: null,
|
||||
previewCertificateType: "none",
|
||||
previewCustomCertResolver: null,
|
||||
previewEnv: null,
|
||||
@@ -58,6 +68,7 @@ const baseApp: ApplicationNested = {
|
||||
previewWildcard: "",
|
||||
environment: {
|
||||
env: "",
|
||||
isDefault: false,
|
||||
environmentId: "",
|
||||
name: "",
|
||||
createdAt: "",
|
||||
@@ -73,6 +84,7 @@ const baseApp: ApplicationNested = {
|
||||
},
|
||||
},
|
||||
buildArgs: null,
|
||||
buildSecrets: null,
|
||||
buildPath: "/",
|
||||
gitlabPathNamespace: "",
|
||||
buildType: "nixpacks",
|
||||
@@ -133,6 +145,7 @@ const baseApp: ApplicationNested = {
|
||||
username: null,
|
||||
dockerContextPath: null,
|
||||
rollbackActive: false,
|
||||
stopGracePeriodSwarm: null,
|
||||
};
|
||||
|
||||
describe("unzipDrop using real zip files", () => {
|
||||
|
||||
311
apps/dokploy/__test__/env/environment.test.ts
vendored
311
apps/dokploy/__test__/env/environment.test.ts
vendored
@@ -1,4 +1,7 @@
|
||||
import { prepareEnvironmentVariables } from "@dokploy/server/index";
|
||||
import {
|
||||
prepareEnvironmentVariables,
|
||||
prepareEnvironmentVariablesForShell,
|
||||
} from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const projectEnv = `
|
||||
@@ -332,4 +335,310 @@ IS_DEV=\${{environment.DEVELOPMENT}}
|
||||
"IS_DEV=0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles environment variables with single quotes in values", () => {
|
||||
const envWithSingleQuotes = `
|
||||
ENV_VARIABLE='ENVITONME'NT'
|
||||
ANOTHER_VAR='value with 'quotes' inside'
|
||||
SIMPLE_VAR=no-quotes
|
||||
`;
|
||||
|
||||
const serviceWithSingleQuotes = `
|
||||
TEST_VAR=\${{environment.ENV_VARIABLE}}
|
||||
ANOTHER_TEST=\${{environment.ANOTHER_VAR}}
|
||||
SIMPLE=\${{environment.SIMPLE_VAR}}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(
|
||||
serviceWithSingleQuotes,
|
||||
"",
|
||||
envWithSingleQuotes,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"TEST_VAR=ENVITONME'NT",
|
||||
"ANOTHER_TEST=value with 'quotes' inside",
|
||||
"SIMPLE=no-quotes",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareEnvironmentVariablesForShell (shell escaping)", () => {
|
||||
it("escapes single quotes in environment variable values", () => {
|
||||
const serviceEnv = `
|
||||
ENV_VARIABLE='ENVITONME'NT'
|
||||
ANOTHER_VAR='value with 'quotes' inside'
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// shell-quote should wrap these in double quotes
|
||||
expect(resolved).toEqual([
|
||||
`"ENV_VARIABLE=ENVITONME'NT"`,
|
||||
`"ANOTHER_VAR=value with 'quotes' inside"`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("escapes double quotes in environment variable values", () => {
|
||||
const serviceEnv = `
|
||||
MESSAGE="Hello "World""
|
||||
QUOTED_PATH="/path/to/"file""
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// shell-quote wraps in single quotes when there are double quotes inside
|
||||
expect(resolved).toEqual([
|
||||
`'MESSAGE=Hello "World"'`,
|
||||
`'QUOTED_PATH=/path/to/"file"'`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("escapes dollar signs in environment variable values", () => {
|
||||
const serviceEnv = `
|
||||
PRICE=$100
|
||||
VARIABLE=$HOME/path
|
||||
TEMPLATE=Hello $USER
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// Dollar signs should be escaped to prevent variable expansion
|
||||
for (const env of resolved) {
|
||||
expect(env).toContain("$");
|
||||
}
|
||||
});
|
||||
|
||||
it("escapes backticks in environment variable values", () => {
|
||||
const serviceEnv = `
|
||||
COMMAND=\`echo "test"\`
|
||||
NESTED=value with \`backticks\` inside
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// Backticks are escaped/removed by dotenv parsing, but values should be safely quoted
|
||||
expect(resolved.length).toBe(2);
|
||||
expect(resolved[0]).toContain("COMMAND");
|
||||
expect(resolved[1]).toContain("NESTED");
|
||||
});
|
||||
|
||||
it("handles environment variables with spaces", () => {
|
||||
const serviceEnv = `
|
||||
FULL_NAME="John Doe"
|
||||
MESSAGE='Hello World'
|
||||
SENTENCE=This is a test
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// shell-quote uses single quotes for strings with spaces
|
||||
expect(resolved).toEqual([
|
||||
`'FULL_NAME=John Doe'`,
|
||||
`'MESSAGE=Hello World'`,
|
||||
`'SENTENCE=This is a test'`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles environment variables with backslashes", () => {
|
||||
const serviceEnv = `
|
||||
WINDOWS_PATH=C:\\Users\\Documents
|
||||
ESCAPED=value\\with\\backslashes
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// Backslashes should be properly escaped
|
||||
expect(resolved.length).toBe(2);
|
||||
for (const env of resolved) {
|
||||
expect(env).toContain("\\");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles simple environment variables without special characters", () => {
|
||||
const serviceEnv = `
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
DEBUG=true
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// shell-quote escapes the = sign in some cases
|
||||
expect(resolved).toEqual([
|
||||
"NODE_ENV\\=production",
|
||||
"PORT\\=3000",
|
||||
"DEBUG\\=true",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles environment variables with mixed special characters", () => {
|
||||
const serviceEnv = `
|
||||
COMPLEX='value with "double" and 'single' quotes'
|
||||
BASH_COMMAND=echo "$HOME" && echo 'test'
|
||||
WEIRD=\`echo "$VAR"\` with 'quotes' and "more"
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// All should be escaped, none should throw errors
|
||||
expect(resolved.length).toBe(3);
|
||||
// Verify each can be safely used in shell
|
||||
for (const env of resolved) {
|
||||
expect(typeof env).toBe("string");
|
||||
expect(env.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles environment variables with newlines", () => {
|
||||
const serviceEnv = `
|
||||
MULTILINE="line1
|
||||
line2
|
||||
line3"
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(1);
|
||||
expect(resolved[0]).toContain("MULTILINE");
|
||||
});
|
||||
|
||||
it("handles empty environment variable values", () => {
|
||||
const serviceEnv = `
|
||||
EMPTY=
|
||||
EMPTY_QUOTED=""
|
||||
EMPTY_SINGLE=''
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// shell-quote escapes the = sign for empty values
|
||||
expect(resolved).toEqual([
|
||||
"EMPTY\\=",
|
||||
"EMPTY_QUOTED\\=",
|
||||
"EMPTY_SINGLE\\=",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles environment variables with equals signs in values", () => {
|
||||
const serviceEnv = `
|
||||
EQUATION=a=b+c
|
||||
CONNECTION_STRING=user=admin;password=test
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(2);
|
||||
expect(resolved[0]).toContain("EQUATION");
|
||||
expect(resolved[1]).toContain("CONNECTION_STRING");
|
||||
});
|
||||
|
||||
it("resolves and escapes environment variables together", () => {
|
||||
const projectEnv = `
|
||||
BASE_URL=https://example.com
|
||||
API_KEY='secret-key-with-quotes'
|
||||
`;
|
||||
|
||||
const environmentEnv = `
|
||||
ENV_NAME=production
|
||||
DB_PASS='pa$$word'
|
||||
`;
|
||||
|
||||
const serviceEnv = `
|
||||
FULL_URL=\${{project.BASE_URL}}/api
|
||||
AUTH_KEY=\${{project.API_KEY}}
|
||||
ENVIRONMENT=\${{environment.ENV_NAME}}
|
||||
DB_PASSWORD=\${{environment.DB_PASS}}
|
||||
CUSTOM='value with 'quotes' inside'
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(
|
||||
serviceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
expect(resolved.length).toBe(5);
|
||||
// All resolved values should be properly escaped
|
||||
for (const env of resolved) {
|
||||
expect(typeof env).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles environment variables with semicolons and ampersands", () => {
|
||||
const serviceEnv = `
|
||||
COMMAND=echo "test" && echo "test2"
|
||||
MULTIPLE=cmd1; cmd2; cmd3
|
||||
URL_WITH_PARAMS=https://example.com?a=1&b=2&c=3
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(3);
|
||||
// These should be safely escaped to prevent command injection
|
||||
for (const env of resolved) {
|
||||
expect(typeof env).toBe("string");
|
||||
expect(env.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles environment variables with pipes and redirects", () => {
|
||||
const serviceEnv = `
|
||||
PIPE_COMMAND=cat file | grep test
|
||||
REDIRECT=echo "test" > output.txt
|
||||
BOTH=cat input.txt | grep pattern > output.txt
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(3);
|
||||
// Pipes and redirects should be safely quoted
|
||||
expect(resolved[0]).toContain("PIPE_COMMAND");
|
||||
expect(resolved[1]).toContain("REDIRECT");
|
||||
expect(resolved[2]).toContain("BOTH");
|
||||
// At least one should contain a pipe
|
||||
const hasPipe = resolved.some((env) => env.includes("|"));
|
||||
expect(hasPipe).toBe(true);
|
||||
});
|
||||
|
||||
it("handles environment variables with parentheses and brackets", () => {
|
||||
const serviceEnv = `
|
||||
MATH=(a+b)*c
|
||||
ARRAY=[1,2,3]
|
||||
JSON={"key":"value"}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(3);
|
||||
expect(resolved[0]).toContain("(");
|
||||
expect(resolved[1]).toContain("[");
|
||||
expect(resolved[2]).toContain("{");
|
||||
});
|
||||
|
||||
it("handles very long environment variable values", () => {
|
||||
const longValue = "a".repeat(10000);
|
||||
const serviceEnv = `LONG_VAR=${longValue}`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(1);
|
||||
expect(resolved[0]).toContain("LONG_VAR");
|
||||
expect(resolved[0]?.length).toBeGreaterThan(10000);
|
||||
});
|
||||
|
||||
it("handles special unicode characters in environment variables", () => {
|
||||
const serviceEnv = `
|
||||
EMOJI=Hello 🌍 World 🚀
|
||||
CHINESE=你好世界
|
||||
SPECIAL=café résumé naïve
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(3);
|
||||
expect(resolved[0]).toContain("🌍");
|
||||
expect(resolved[1]).toContain("你好");
|
||||
expect(resolved[2]).toContain("café");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,4 +54,22 @@ describe("processLogs", () => {
|
||||
const result = parseRawConfig(entryWithWhitespace);
|
||||
expect(result.data).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should filter out Dokploy dashboard requests", () => {
|
||||
const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`;
|
||||
|
||||
// Test with only Dokploy dashboard entry - should be filtered out
|
||||
const resultOnlyDokploy = parseRawConfig(dokployDashboardEntry);
|
||||
expect(resultOnlyDokploy.data).toHaveLength(0);
|
||||
expect(resultOnlyDokploy.totalCount).toBe(0);
|
||||
|
||||
// Test with mixed entries - Dokploy should be filtered, others should remain
|
||||
const mixedEntries = `${dokployDashboardEntry}\n${sampleLogEntry}`;
|
||||
const resultMixed = parseRawConfig(mixedEntries);
|
||||
expect(resultMixed.data).toHaveLength(1);
|
||||
expect(resultMixed.totalCount).toBe(1);
|
||||
expect(resultMixed.data[0]?.ServiceName).not.toBe(
|
||||
"dokploy-service-app@file",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
109
apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts
Normal file
109
apps/dokploy/__test__/server/mechanizeDockerContainer.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
||||
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type MockCreateServiceOptions = {
|
||||
TaskTemplate?: {
|
||||
ContainerSpec?: {
|
||||
StopGracePeriod?: number;
|
||||
};
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
|
||||
vi.hoisted(() => {
|
||||
const inspect = vi.fn<[], Promise<never>>();
|
||||
const getService = vi.fn(() => ({ inspect }));
|
||||
const createService = vi.fn<[MockCreateServiceOptions], Promise<void>>(
|
||||
async () => undefined,
|
||||
);
|
||||
const getRemoteDocker = vi.fn(async () => ({
|
||||
getService,
|
||||
createService,
|
||||
}));
|
||||
return {
|
||||
inspectMock: inspect,
|
||||
getServiceMock: getService,
|
||||
createServiceMock: createService,
|
||||
getRemoteDockerMock: getRemoteDocker,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/utils/servers/remote-docker", () => ({
|
||||
getRemoteDocker: getRemoteDockerMock,
|
||||
}));
|
||||
|
||||
const createApplication = (
|
||||
overrides: Partial<ApplicationNested> = {},
|
||||
): ApplicationNested =>
|
||||
({
|
||||
appName: "test-app",
|
||||
buildType: "dockerfile",
|
||||
env: null,
|
||||
mounts: [],
|
||||
cpuLimit: null,
|
||||
memoryLimit: null,
|
||||
memoryReservation: null,
|
||||
cpuReservation: null,
|
||||
command: null,
|
||||
ports: [],
|
||||
sourceType: "docker",
|
||||
dockerImage: "example:latest",
|
||||
registry: null,
|
||||
environment: {
|
||||
project: { env: null },
|
||||
env: null,
|
||||
},
|
||||
replicas: 1,
|
||||
stopGracePeriodSwarm: 0n,
|
||||
serverId: "server-id",
|
||||
...overrides,
|
||||
}) as unknown as ApplicationNested;
|
||||
|
||||
describe("mechanizeDockerContainer", () => {
|
||||
beforeEach(() => {
|
||||
inspectMock.mockReset();
|
||||
inspectMock.mockRejectedValue(new Error("service not found"));
|
||||
getServiceMock.mockClear();
|
||||
createServiceMock.mockClear();
|
||||
getRemoteDockerMock.mockClear();
|
||||
getRemoteDockerMock.mockResolvedValue({
|
||||
getService: getServiceMock,
|
||||
createService: createServiceMock,
|
||||
});
|
||||
});
|
||||
|
||||
it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => {
|
||||
const application = createApplication({ stopGracePeriodSwarm: 0n });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0);
|
||||
expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(
|
||||
"number",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
|
||||
const application = createApplication({ stopGracePeriodSwarm: null });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty(
|
||||
"StopGracePeriod",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -161,6 +161,50 @@ describe("helpers functions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empty string variables", () => {
|
||||
it("should replace variables with empty string values correctly", () => {
|
||||
const variables = {
|
||||
smtp_username: "",
|
||||
smtp_password: "",
|
||||
non_empty: "value",
|
||||
};
|
||||
|
||||
const result1 = processValue("${smtp_username}", variables, mockSchema);
|
||||
expect(result1).toBe("");
|
||||
|
||||
const result2 = processValue("${smtp_password}", variables, mockSchema);
|
||||
expect(result2).toBe("");
|
||||
|
||||
const result3 = processValue("${non_empty}", variables, mockSchema);
|
||||
expect(result3).toBe("value");
|
||||
});
|
||||
|
||||
it("should not replace undefined variables", () => {
|
||||
const variables = {
|
||||
defined_var: "",
|
||||
};
|
||||
|
||||
const result = processValue("${undefined_var}", variables, mockSchema);
|
||||
expect(result).toBe("${undefined_var}");
|
||||
});
|
||||
|
||||
it("should handle mixed empty and non-empty variables in template", () => {
|
||||
const variables = {
|
||||
smtp_address: "smtp.example.com",
|
||||
smtp_port: "2525",
|
||||
smtp_username: "",
|
||||
smtp_password: "",
|
||||
};
|
||||
|
||||
const template =
|
||||
"SMTP_ADDRESS=${smtp_address} SMTP_PORT=${smtp_port} SMTP_USERNAME=${smtp_username} SMTP_PASSWORD=${smtp_password}";
|
||||
const result = processValue(template, variables, mockSchema);
|
||||
expect(result).toBe(
|
||||
"SMTP_ADDRESS=smtp.example.com SMTP_PORT=2525 SMTP_USERNAME= SMTP_PASSWORD=",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("${jwt}", () => {
|
||||
it("should generate a JWT string", () => {
|
||||
const jwt = processValue("${jwt}", {}, mockSchema);
|
||||
@@ -228,5 +272,58 @@ describe("helpers functions", () => {
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle JWT payload with newlines and whitespace by trimming them", () => {
|
||||
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
|
||||
const expiry = iat + 3600;
|
||||
const payloadWithNewlines = `{
|
||||
"role": "anon",
|
||||
"iss": "supabase",
|
||||
"exp": ${expiry}
|
||||
}
|
||||
`;
|
||||
const jwt = processValue(
|
||||
"${jwt:secret:payload}",
|
||||
{
|
||||
secret: "mysecret",
|
||||
payload: payloadWithNewlines,
|
||||
},
|
||||
mockSchema,
|
||||
);
|
||||
expect(jwt).toMatch(jwtMatchExp);
|
||||
const parts = jwt.split(".") as JWTParts;
|
||||
jwtCheckHeader(parts[0]);
|
||||
const decodedPayload = jwtBase64Decode(parts[1]);
|
||||
expect(decodedPayload).toHaveProperty("role");
|
||||
expect(decodedPayload.role).toEqual("anon");
|
||||
expect(decodedPayload).toHaveProperty("iss");
|
||||
expect(decodedPayload.iss).toEqual("supabase");
|
||||
expect(decodedPayload).toHaveProperty("exp");
|
||||
expect(decodedPayload.exp).toEqual(expiry);
|
||||
});
|
||||
|
||||
it("should handle JWT payload with leading and trailing whitespace", () => {
|
||||
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
|
||||
const expiry = iat + 3600;
|
||||
const payloadWithWhitespace = ` {"role": "service_role", "iss": "supabase", "exp": ${expiry}} `;
|
||||
const jwt = processValue(
|
||||
"${jwt:secret:payload}",
|
||||
{
|
||||
secret: "mysecret",
|
||||
payload: payloadWithWhitespace,
|
||||
},
|
||||
mockSchema,
|
||||
);
|
||||
expect(jwt).toMatch(jwtMatchExp);
|
||||
const parts = jwt.split(".") as JWTParts;
|
||||
jwtCheckHeader(parts[0]);
|
||||
const decodedPayload = jwtBase64Decode(parts[1]);
|
||||
expect(decodedPayload).toHaveProperty("role");
|
||||
expect(decodedPayload.role).toEqual("service_role");
|
||||
expect(decodedPayload).toHaveProperty("iss");
|
||||
expect(decodedPayload.iss).toEqual("supabase");
|
||||
expect(decodedPayload).toHaveProperty("exp");
|
||||
expect(decodedPayload.exp).toEqual(expiry);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,19 +5,27 @@ vi.mock("node:fs", () => ({
|
||||
default: fs,
|
||||
}));
|
||||
|
||||
import type { FileConfig, User } from "@dokploy/server";
|
||||
import type { FileConfig } from "@dokploy/server";
|
||||
import {
|
||||
createDefaultServerTraefikConfig,
|
||||
loadOrCreateConfig,
|
||||
updateServerTraefik,
|
||||
} from "@dokploy/server";
|
||||
import type { webServerSettings } from "@dokploy/server/db/schema";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
const baseAdmin: User = {
|
||||
type WebServerSettings = typeof webServerSettings.$inferSelect;
|
||||
|
||||
const baseSettings: WebServerSettings = {
|
||||
id: "",
|
||||
https: false,
|
||||
enablePaidFeatures: false,
|
||||
allowImpersonation: false,
|
||||
role: "user",
|
||||
certificateType: "none",
|
||||
host: null,
|
||||
serverIp: null,
|
||||
letsEncryptEmail: null,
|
||||
sshPrivateKey: null,
|
||||
enableDockerCleanup: false,
|
||||
logCleanupCron: null,
|
||||
metricsConfig: {
|
||||
containers: {
|
||||
refreshRate: 20,
|
||||
@@ -43,30 +51,8 @@ const baseAdmin: User = {
|
||||
cleanupCacheApplications: false,
|
||||
cleanupCacheOnCompose: false,
|
||||
cleanupCacheOnPreviews: false,
|
||||
createdAt: new Date(),
|
||||
serverIp: null,
|
||||
certificateType: "none",
|
||||
host: null,
|
||||
letsEncryptEmail: null,
|
||||
sshPrivateKey: null,
|
||||
enableDockerCleanup: false,
|
||||
logCleanupCron: null,
|
||||
serversQuantity: 0,
|
||||
stripeCustomerId: "",
|
||||
stripeSubscriptionId: "",
|
||||
banExpires: new Date(),
|
||||
banned: true,
|
||||
banReason: "",
|
||||
email: "",
|
||||
expirationDate: "",
|
||||
id: "",
|
||||
isRegistered: false,
|
||||
name: "",
|
||||
createdAt2: new Date().toISOString(),
|
||||
emailVerified: false,
|
||||
image: "",
|
||||
createdAt: null,
|
||||
updatedAt: new Date(),
|
||||
twoFactorEnabled: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -84,7 +70,7 @@ test("Should read the configuration file", () => {
|
||||
test("Should apply redirect-to-https", () => {
|
||||
updateServerTraefik(
|
||||
{
|
||||
...baseAdmin,
|
||||
...baseSettings,
|
||||
https: true,
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
@@ -99,7 +85,7 @@ test("Should apply redirect-to-https", () => {
|
||||
});
|
||||
|
||||
test("Should change only host when no certificate", () => {
|
||||
updateServerTraefik(baseAdmin, "example.com");
|
||||
updateServerTraefik(baseSettings, "example.com");
|
||||
|
||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||
|
||||
@@ -109,7 +95,7 @@ test("Should change only host when no certificate", () => {
|
||||
test("Should not touch config without host", () => {
|
||||
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
|
||||
|
||||
updateServerTraefik(baseAdmin, null);
|
||||
updateServerTraefik(baseSettings, null);
|
||||
|
||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||
|
||||
@@ -118,11 +104,14 @@ test("Should not touch config without host", () => {
|
||||
|
||||
test("Should remove websecure if https rollback to http", () => {
|
||||
updateServerTraefik(
|
||||
{ ...baseAdmin, certificateType: "letsencrypt" },
|
||||
{ ...baseSettings, certificateType: "letsencrypt" },
|
||||
"example.com",
|
||||
);
|
||||
|
||||
updateServerTraefik({ ...baseAdmin, certificateType: "none" }, "example.com");
|
||||
updateServerTraefik(
|
||||
{ ...baseSettings, certificateType: "none" },
|
||||
"example.com",
|
||||
);
|
||||
|
||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||
|
||||
|
||||
@@ -3,18 +3,27 @@ import { createRouterConfig } from "@dokploy/server";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
const baseApp: ApplicationNested = {
|
||||
railpackVersion: "0.2.2",
|
||||
railpackVersion: "0.15.4",
|
||||
rollbackActive: false,
|
||||
applicationId: "",
|
||||
previewLabels: [],
|
||||
createEnvFile: true,
|
||||
herokuVersion: "",
|
||||
giteaRepository: "",
|
||||
giteaOwner: "",
|
||||
giteaBranch: "",
|
||||
buildServerId: "",
|
||||
buildRegistryId: "",
|
||||
buildRegistry: null,
|
||||
giteaBuildPath: "",
|
||||
giteaId: "",
|
||||
args: [],
|
||||
rollbackRegistryId: "",
|
||||
rollbackRegistry: null,
|
||||
deployments: [],
|
||||
cleanCache: false,
|
||||
applicationStatus: "done",
|
||||
endpointSpecSwarm: null,
|
||||
appName: "",
|
||||
autoDeploy: true,
|
||||
enableSubmodules: false,
|
||||
@@ -25,8 +34,10 @@ const baseApp: ApplicationNested = {
|
||||
registryUrl: "",
|
||||
watchPaths: [],
|
||||
buildArgs: null,
|
||||
buildSecrets: null,
|
||||
isPreviewDeploymentsActive: false,
|
||||
previewBuildArgs: null,
|
||||
previewBuildSecrets: null,
|
||||
triggerType: "push",
|
||||
previewCertificateType: "none",
|
||||
previewEnv: null,
|
||||
@@ -39,6 +50,7 @@ const baseApp: ApplicationNested = {
|
||||
environmentId: "",
|
||||
environment: {
|
||||
env: "",
|
||||
isDefault: false,
|
||||
environmentId: "",
|
||||
name: "",
|
||||
createdAt: "",
|
||||
@@ -111,6 +123,7 @@ const baseApp: ApplicationNested = {
|
||||
updateConfigSwarm: null,
|
||||
username: null,
|
||||
dockerContextPath: null,
|
||||
stopGracePeriodSwarm: null,
|
||||
};
|
||||
|
||||
const baseDomain: Domain = {
|
||||
|
||||
@@ -13,7 +13,11 @@ export default defineConfig({
|
||||
NODE: "test",
|
||||
},
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
projects: [path.resolve(__dirname, "../tsconfig.json")],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@dokploy/server": path.resolve(
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -121,6 +122,22 @@ const NetworkSwarmSchema = z.array(
|
||||
|
||||
const LabelsSwarmSchema = z.record(z.string());
|
||||
|
||||
const EndpointPortConfigSwarmSchema = z
|
||||
.object({
|
||||
Protocol: z.string().optional(),
|
||||
TargetPort: z.number().optional(),
|
||||
PublishedPort: z.number().optional(),
|
||||
PublishMode: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const EndpointSpecSwarmSchema = z
|
||||
.object({
|
||||
Mode: z.string().optional(),
|
||||
Ports: z.array(EndpointPortConfigSwarmSchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
|
||||
return z
|
||||
.string()
|
||||
@@ -176,10 +193,21 @@ const addSwarmSettings = z.object({
|
||||
modeSwarm: createStringToJSONSchema(ServiceModeSwarmSchema).nullable(),
|
||||
labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
|
||||
networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
|
||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||
endpointSpecSwarm: createStringToJSONSchema(
|
||||
EndpointSpecSwarmSchema,
|
||||
).nullable(),
|
||||
});
|
||||
|
||||
type AddSwarmSettings = z.infer<typeof addSwarmSettings>;
|
||||
|
||||
const hasStopGracePeriodSwarm = (
|
||||
value: unknown,
|
||||
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"stopGracePeriodSwarm" in value;
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
@@ -224,12 +252,23 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
modeSwarm: null,
|
||||
labelsSwarm: null,
|
||||
networkSwarm: null,
|
||||
stopGracePeriodSwarm: null,
|
||||
endpointSpecSwarm: null,
|
||||
},
|
||||
resolver: zodResolver(addSwarmSettings),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const stopGracePeriodValue = hasStopGracePeriodSwarm(data)
|
||||
? data.stopGracePeriodSwarm
|
||||
: null;
|
||||
const normalizedStopGracePeriod =
|
||||
stopGracePeriodValue === null || stopGracePeriodValue === undefined
|
||||
? null
|
||||
: typeof stopGracePeriodValue === "bigint"
|
||||
? stopGracePeriodValue
|
||||
: BigInt(stopGracePeriodValue);
|
||||
form.reset({
|
||||
healthCheckSwarm: data.healthCheckSwarm
|
||||
? JSON.stringify(data.healthCheckSwarm, null, 2)
|
||||
@@ -255,6 +294,10 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
networkSwarm: data.networkSwarm
|
||||
? JSON.stringify(data.networkSwarm, null, 2)
|
||||
: null,
|
||||
stopGracePeriodSwarm: normalizedStopGracePeriod,
|
||||
endpointSpecSwarm: data.endpointSpecSwarm
|
||||
? JSON.stringify(data.endpointSpecSwarm, null, 2)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
@@ -275,6 +318,8 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
modeSwarm: data.modeSwarm,
|
||||
labelsSwarm: data.labelsSwarm,
|
||||
networkSwarm: data.networkSwarm,
|
||||
stopGracePeriodSwarm: data.stopGracePeriodSwarm ?? null,
|
||||
endpointSpecSwarm: data.endpointSpecSwarm,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Swarm settings updated");
|
||||
@@ -352,9 +397,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
language="json"
|
||||
placeholder={`{
|
||||
"Test" : ["CMD-SHELL", "curl -f http://localhost:3000/health"],
|
||||
"Interval" : 10000,
|
||||
"Timeout" : 10000,
|
||||
"StartPeriod" : 10000,
|
||||
"Interval" : 10000000000,
|
||||
"Timeout" : 10000000000,
|
||||
"StartPeriod" : 10000000000,
|
||||
"Retries" : 10
|
||||
}`}
|
||||
className="h-[12rem] font-mono"
|
||||
@@ -407,9 +452,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
language="json"
|
||||
placeholder={`{
|
||||
"Condition" : "on-failure",
|
||||
"Delay" : 10000,
|
||||
"Delay" : 10000000000,
|
||||
"MaxAttempts" : 10,
|
||||
"Window" : 10000
|
||||
"Window" : 10000000000
|
||||
} `}
|
||||
className="h-[12rem] font-mono"
|
||||
{...field}
|
||||
@@ -529,9 +574,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
language="json"
|
||||
placeholder={`{
|
||||
"Parallelism" : 1,
|
||||
"Delay" : 10000,
|
||||
"Delay" : 10000000000,
|
||||
"FailureAction" : "continue",
|
||||
"Monitor" : 10000,
|
||||
"Monitor" : 10000000000,
|
||||
"MaxFailureRatio" : 10,
|
||||
"Order" : "start-first"
|
||||
}`}
|
||||
@@ -587,9 +632,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
language="json"
|
||||
placeholder={`{
|
||||
"Parallelism" : 1,
|
||||
"Delay" : 10000,
|
||||
"Delay" : 10000000000,
|
||||
"FailureAction" : "continue",
|
||||
"Monitor" : 10000,
|
||||
"Monitor" : 10000000000,
|
||||
"MaxFailureRatio" : 10,
|
||||
"Order" : "start-first"
|
||||
}`}
|
||||
@@ -774,7 +819,118 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="stopGracePeriodSwarm"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative max-lg:px-4 lg:pl-6 ">
|
||||
<FormLabel>Stop Grace Period (nanoseconds)</FormLabel>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
|
||||
Duration in nanoseconds
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</FormDescription>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="w-full z-[999]"
|
||||
align="start"
|
||||
side="bottom"
|
||||
>
|
||||
<code>
|
||||
<pre>
|
||||
{`Enter duration in nanoseconds:
|
||||
• 30000000000 - 30 seconds
|
||||
• 120000000000 - 2 minutes
|
||||
• 3600000000000 - 1 hour
|
||||
• 0 - no grace period`}
|
||||
</pre>
|
||||
</code>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="30000000000"
|
||||
className="font-mono"
|
||||
{...field}
|
||||
value={field?.value?.toString() || ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value ? BigInt(e.target.value) : null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<pre>
|
||||
<FormMessage />
|
||||
</pre>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpointSpecSwarm"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative ">
|
||||
<FormLabel>Endpoint Spec</FormLabel>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
|
||||
Check the interface
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</FormDescription>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="w-full z-[999]"
|
||||
align="start"
|
||||
side="bottom"
|
||||
>
|
||||
<code>
|
||||
<pre>
|
||||
{`{
|
||||
Mode?: string | undefined;
|
||||
Ports?: Array<{
|
||||
Protocol?: string | undefined;
|
||||
TargetPort?: number | undefined;
|
||||
PublishedPort?: number | undefined;
|
||||
PublishMode?: string | undefined;
|
||||
}> | undefined;
|
||||
}`}
|
||||
</pre>
|
||||
</code>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
language="json"
|
||||
placeholder={`{
|
||||
"Mode": "dnsrr",
|
||||
"Ports": [
|
||||
{
|
||||
"Protocol": "tcp",
|
||||
"TargetPort": 5432,
|
||||
"PublishedPort": 5432,
|
||||
"PublishMode": "host"
|
||||
}
|
||||
]
|
||||
}`}
|
||||
className="h-[17rem] font-mono"
|
||||
{...field}
|
||||
value={field?.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<pre>
|
||||
<FormMessage />
|
||||
</pre>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2 m-0 sticky bottom-0 right-0 bg-muted border">
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -28,6 +29,13 @@ interface Props {
|
||||
|
||||
const AddRedirectSchema = z.object({
|
||||
command: z.string(),
|
||||
args: z
|
||||
.array(
|
||||
z.object({
|
||||
value: z.string().min(1, "Argument cannot be empty"),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type AddCommand = z.infer<typeof AddRedirectSchema>;
|
||||
@@ -47,22 +55,30 @@ export const AddCommand = ({ applicationId }: Props) => {
|
||||
const form = useForm<AddCommand>({
|
||||
defaultValues: {
|
||||
command: "",
|
||||
args: [],
|
||||
},
|
||||
resolver: zodResolver(AddRedirectSchema),
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "args",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.command) {
|
||||
if (data) {
|
||||
form.reset({
|
||||
command: data?.command || "",
|
||||
args: data?.args?.map((arg) => ({ value: arg })) || [],
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (data: AddCommand) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
command: data?.command,
|
||||
args: data?.args?.map((arg) => arg.value).filter(Boolean),
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Command Updated");
|
||||
@@ -100,13 +116,65 @@ export const AddCommand = ({ applicationId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Command</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Custom command" {...field} />
|
||||
<Input placeholder="/bin/sh" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Arguments (Args)</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Argument
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No arguments added yet. Click "Add Argument" to add one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
control={form.control}
|
||||
name={`args.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
index === 0 ? "-c" : "echo Hello World"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Server } 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";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
buildServerId: z.string().optional(),
|
||||
buildRegistryId: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Both empty/none is valid
|
||||
const buildServerIsNone =
|
||||
!data.buildServerId || data.buildServerId === "none";
|
||||
const buildRegistryIsNone =
|
||||
!data.buildRegistryId || data.buildRegistryId === "none";
|
||||
|
||||
// Both should be either filled or empty
|
||||
if (buildServerIsNone && buildRegistryIsNone) return true;
|
||||
if (!buildServerIsNone && !buildRegistryIsNone) return true;
|
||||
|
||||
return false;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Both Build Server and Build Registry must be selected together, or both set to None",
|
||||
path: ["buildServerId"], // Show error on buildServerId field
|
||||
},
|
||||
);
|
||||
|
||||
type Schema = z.infer<typeof schema>;
|
||||
|
||||
export const ShowBuildServer = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{ applicationId },
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
const { data: buildServers } = api.server.buildServers.useQuery();
|
||||
const { data: registries } = api.registry.all.useQuery();
|
||||
|
||||
const { mutateAsync, isLoading } = api.application.update.useMutation();
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
buildServerId: data?.buildServerId || "",
|
||||
buildRegistryId: data?.buildRegistryId || "",
|
||||
},
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
buildServerId: data?.buildServerId || "",
|
||||
buildRegistryId: data?.buildRegistryId || "",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (formData: Schema) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
buildServerId:
|
||||
formData?.buildServerId === "none" || !formData?.buildServerId
|
||||
? null
|
||||
: formData?.buildServerId,
|
||||
buildRegistryId:
|
||||
formData?.buildRegistryId === "none" || !formData?.buildRegistryId
|
||||
? null
|
||||
: formData?.buildRegistryId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Build Server Settings Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating build server settings");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Server className="size-6 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle className="text-xl">Build Server</CardTitle>
|
||||
<CardDescription>
|
||||
Configure a dedicated server for building your application.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<AlertBlock type="info">
|
||||
Build servers offload the build process from your deployment servers.
|
||||
Select a build server and registry to use for building your
|
||||
application.
|
||||
</AlertBlock>
|
||||
|
||||
<AlertBlock type="info">
|
||||
📊 <strong>Important:</strong> Once the build finishes, you'll need to
|
||||
wait a few seconds for the deployment server to download the image.
|
||||
These download logs will <strong>NOT</strong> appear in the build
|
||||
deployment logs. Check the <strong>Logs</strong> tab to see when the
|
||||
container starts running.
|
||||
</AlertBlock>
|
||||
|
||||
<AlertBlock type="info">
|
||||
<strong>Note:</strong> Build Server and Build Registry must be
|
||||
configured together. You can either select both or set both to None.
|
||||
</AlertBlock>
|
||||
|
||||
{!registries || registries.length === 0 ? (
|
||||
<AlertBlock type="warning">
|
||||
You need to add at least one registry to use build servers. Please
|
||||
go to{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/registry"
|
||||
className="text-primary underline"
|
||||
>
|
||||
Settings
|
||||
</Link>{" "}
|
||||
to add a registry.
|
||||
</AlertBlock>
|
||||
) : null}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="buildServerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Build Server</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
// If setting to "none", also reset build registry to "none"
|
||||
if (value === "none") {
|
||||
form.setValue("buildRegistryId", "none");
|
||||
}
|
||||
}}
|
||||
value={field.value || "none"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a build server" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>None</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
{buildServers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
<span className="flex items-center gap-2 justify-between w-full">
|
||||
<span>{server.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{server.ipAddress}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Build Servers ({buildServers?.length || 0})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select a build server to handle the build process for this
|
||||
application.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="buildRegistryId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Build Registry</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
// If setting to "none", also reset build server to "none"
|
||||
if (value === "none") {
|
||||
form.setValue("buildServerId", "none");
|
||||
}
|
||||
}}
|
||||
value={field.value || "none"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a registry" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>None</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
{registries?.map((registry) => (
|
||||
<SelectItem
|
||||
key={registry.registryId}
|
||||
value={registry.registryId}
|
||||
>
|
||||
{registry.registryName}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Registries ({registries?.length || 0})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Select a registry to store the built images from the build
|
||||
server.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
createConverter,
|
||||
NumberInputWithSteps,
|
||||
} from "@/components/ui/number-input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -30,6 +33,23 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const CPU_STEP = 0.25;
|
||||
const MEMORY_STEP_MB = 256;
|
||||
|
||||
const formatNumber = (value: number, decimals = 2): string =>
|
||||
Number.isInteger(value) ? String(value) : value.toFixed(decimals);
|
||||
|
||||
const cpuConverter = createConverter(1_000_000_000, (cpu) =>
|
||||
cpu <= 0 ? "" : `${formatNumber(cpu)} CPU`,
|
||||
);
|
||||
|
||||
const memoryConverter = createConverter(1024 * 1024, (mb) => {
|
||||
if (mb <= 0) return "";
|
||||
return mb >= 1024
|
||||
? `${formatNumber(mb / 1024)} GB`
|
||||
: `${formatNumber(mb)} MB`;
|
||||
});
|
||||
|
||||
const addResourcesSchema = z.object({
|
||||
memoryReservation: z.string().optional(),
|
||||
cpuLimit: z.string().optional(),
|
||||
@@ -51,6 +71,7 @@ interface Props {
|
||||
}
|
||||
|
||||
type AddResources = z.infer<typeof addResourcesSchema>;
|
||||
|
||||
export const ShowResources = ({ id, type }: Props) => {
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
@@ -150,7 +171,10 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<FormLabel>Memory Limit</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
@@ -160,16 +184,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Memory hard limit in bytes. Example: 1GB =
|
||||
1073741824 bytes
|
||||
1073741824 bytes. Use +/- buttons to adjust by
|
||||
256 MB.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
<NumberInputWithSteps
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="1073741824 (1GB in bytes)"
|
||||
{...field}
|
||||
step={MEMORY_STEP_MB}
|
||||
converter={memoryConverter}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -182,7 +210,10 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
name="memoryReservation"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<FormLabel>Memory Reservation</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
@@ -192,16 +223,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Memory soft limit in bytes. Example: 256MB =
|
||||
268435456 bytes
|
||||
268435456 bytes. Use +/- buttons to adjust by 256
|
||||
MB.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
<NumberInputWithSteps
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="268435456 (256MB in bytes)"
|
||||
{...field}
|
||||
step={MEMORY_STEP_MB}
|
||||
converter={memoryConverter}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -215,7 +250,10 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<FormLabel>CPU Limit</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
@@ -225,17 +263,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<TooltipContent>
|
||||
<p>
|
||||
CPU quota in units of 10^-9 CPUs. Example: 2
|
||||
CPUs = 2000000000
|
||||
CPUs = 2000000000. Use +/- buttons to adjust by
|
||||
0.25 CPU.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
<NumberInputWithSteps
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="2000000000 (2 CPUs)"
|
||||
{...field}
|
||||
value={field.value?.toString() || ""}
|
||||
step={CPU_STEP}
|
||||
converter={cpuConverter}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -249,7 +290,10 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<FormLabel>CPU Reservation</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
@@ -259,14 +303,21 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<TooltipContent>
|
||||
<p>
|
||||
CPU shares (relative weight). Example: 1 CPU =
|
||||
1000000000
|
||||
1000000000. Use +/- buttons to adjust by 0.25
|
||||
CPU.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input placeholder="1000000000 (1 CPU)" {...field} />
|
||||
<NumberInputWithSteps
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="1000000000 (1 CPU)"
|
||||
step={CPU_STEP}
|
||||
converter={cpuConverter}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -59,7 +59,13 @@ const mySchema = z.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("volume"),
|
||||
volumeName: z.string().min(1, "Volume name required"),
|
||||
volumeName: z
|
||||
.string()
|
||||
.min(1, "Volume name required")
|
||||
.regex(
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
|
||||
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
|
||||
),
|
||||
})
|
||||
.merge(mountSchema),
|
||||
z
|
||||
@@ -318,7 +324,7 @@ export const AddVolumes = ({
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormItem className="max-w-full max-w-[45rem]">
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl>
|
||||
@@ -327,7 +333,7 @@ export const AddVolumes = ({
|
||||
placeholder={`NODE_ENV=production
|
||||
PORT=3000
|
||||
`}
|
||||
className="h-96 font-mono"
|
||||
className="h-96 font-mono "
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -41,7 +41,13 @@ const mySchema = z.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("volume"),
|
||||
volumeName: z.string().min(1, "Volume name required"),
|
||||
volumeName: z
|
||||
.string()
|
||||
.min(1, "Volume name required")
|
||||
.regex(
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
|
||||
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
|
||||
),
|
||||
})
|
||||
.merge(mountSchema),
|
||||
z
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Cog } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -20,8 +20,39 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
// Railpack versions from https://github.com/railwayapp/railpack/releases
|
||||
export const RAILPACK_VERSIONS = [
|
||||
"0.15.4",
|
||||
"0.15.3",
|
||||
"0.15.2",
|
||||
"0.15.1",
|
||||
"0.15.0",
|
||||
"0.14.0",
|
||||
"0.13.0",
|
||||
"0.12.0",
|
||||
"0.11.0",
|
||||
"0.10.0",
|
||||
"0.9.2",
|
||||
"0.9.1",
|
||||
"0.9.0",
|
||||
"0.8.0",
|
||||
"0.7.0",
|
||||
"0.6.0",
|
||||
"0.5.0",
|
||||
"0.4.0",
|
||||
"0.3.0",
|
||||
"0.2.2",
|
||||
] as const;
|
||||
|
||||
export enum BuildType {
|
||||
dockerfile = "dockerfile",
|
||||
heroku_buildpacks = "heroku_buildpacks",
|
||||
@@ -65,7 +96,7 @@ const mySchema = z.discriminatedUnion("buildType", [
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal(BuildType.railpack),
|
||||
railpackVersion: z.string().nullable().default("0.2.2"),
|
||||
railpackVersion: z.string().nullable().default("0.15.4"),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal(BuildType.static),
|
||||
@@ -152,6 +183,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
});
|
||||
|
||||
const buildType = form.watch("buildType");
|
||||
const railpackVersion = form.watch("railpackVersion");
|
||||
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -163,6 +196,14 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
};
|
||||
|
||||
form.reset(resetData(typedData));
|
||||
|
||||
// Check if railpack version is manual (not in the predefined list)
|
||||
if (
|
||||
data.railpackVersion &&
|
||||
!RAILPACK_VERSIONS.includes(data.railpackVersion as any)
|
||||
) {
|
||||
setIsManualRailpackVersion(true);
|
||||
}
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
@@ -186,7 +227,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
||||
railpackVersion:
|
||||
data.buildType === BuildType.railpack
|
||||
? data.railpackVersion || "0.2.2"
|
||||
? data.railpackVersion || "0.15.4"
|
||||
: null,
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -403,23 +444,88 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
/>
|
||||
)}
|
||||
{buildType === BuildType.railpack && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="railpackVersion"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Railpack Version</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Railpack Version"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="railpackVersion"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Railpack Version</FormLabel>
|
||||
<FormControl>
|
||||
{isManualRailpackVersion ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="Enter custom version (e.g., 0.15.4)"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsManualRailpackVersion(false);
|
||||
field.onChange("0.15.4");
|
||||
}}
|
||||
>
|
||||
Use predefined versions
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (value === "manual") {
|
||||
setIsManualRailpackVersion(true);
|
||||
field.onChange("");
|
||||
} else {
|
||||
field.onChange(value);
|
||||
}
|
||||
}}
|
||||
value={field.value ?? "0.15.4"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Railpack version" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="manual">
|
||||
<span className="font-medium">
|
||||
✏️ Manual (Custom Version)
|
||||
</span>
|
||||
</SelectItem>
|
||||
{RAILPACK_VERSIONS.map((version) => (
|
||||
<SelectItem key={version} value={version}>
|
||||
v{version}
|
||||
{version === "0.15.4" && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-2 px-1 text-xs"
|
||||
>
|
||||
Latest
|
||||
</Badge>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Select a Railpack version or choose manual to enter a
|
||||
custom version.{" "}
|
||||
<a
|
||||
href="https://github.com/railwayapp/railpack/releases"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
View releases
|
||||
</a>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Scissors } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "application" | "compose";
|
||||
}
|
||||
|
||||
export const KillBuild = ({ id, type }: Props) => {
|
||||
const { mutateAsync, isLoading } =
|
||||
type === "application"
|
||||
? api.application.killBuild.useMutation()
|
||||
: api.compose.killBuild.useMutation();
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="w-fit" isLoading={isLoading}>
|
||||
Kill Build
|
||||
<Scissors className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure to kill the build?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will kill the build process
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
composeId: id || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Build killed successfully");
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { Check, Copy, Loader2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -29,9 +31,10 @@ export const ShowDeployment = ({
|
||||
const [data, setData] = useState("");
|
||||
const [showExtraLogs, setShowExtraLogs] = useState(false);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (autoScroll && scrollRef.current) {
|
||||
@@ -106,6 +109,20 @@ export const ShowDeployment = ({
|
||||
}
|
||||
}, [filteredLogs, autoScroll]);
|
||||
|
||||
const handleCopy = () => {
|
||||
const logContent = filteredLogs
|
||||
.map(({ timestamp, message }: LogLine) =>
|
||||
`${timestamp?.toISOString() || ""} ${message}`.trim(),
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const success = copy(logContent);
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const optionalErrors = parseLogs(errorMessage || "");
|
||||
|
||||
return (
|
||||
@@ -128,13 +145,27 @@ export const ShowDeployment = ({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deployment</DialogTitle>
|
||||
<DialogDescription className="flex items-center gap-2">
|
||||
<span>
|
||||
<span className="flex items-center gap-2">
|
||||
See all the details of this deployment |{" "}
|
||||
<Badge variant="blank" className="text-xs">
|
||||
{filteredLogs.length} lines
|
||||
</Badge>
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleCopy}
|
||||
disabled={filteredLogs.length === 0}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{serverId && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
RocketIcon,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -17,6 +25,7 @@ import {
|
||||
import { api, type RouterOutputs } from "@/utils/api";
|
||||
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
|
||||
import { CancelQueues } from "./cancel-queues";
|
||||
import { KillBuild } from "./kill-build";
|
||||
import { RefreshToken } from "./refresh-token";
|
||||
import { ShowDeployment } from "./show-deployment";
|
||||
|
||||
@@ -80,6 +89,23 @@ export const ShowDeployments = ({
|
||||
} = api.compose.cancelDeployment.useMutation();
|
||||
|
||||
const [url, setUrl] = React.useState("");
|
||||
const [expandedDescriptions, setExpandedDescriptions] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH = 200;
|
||||
|
||||
const truncateDescription = (description: string): string => {
|
||||
if (description.length <= MAX_DESCRIPTION_LENGTH) {
|
||||
return description;
|
||||
}
|
||||
const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH);
|
||||
const lastSpace = truncated.lastIndexOf(" ");
|
||||
if (lastSpace > MAX_DESCRIPTION_LENGTH - 20 && lastSpace > 0) {
|
||||
return `${truncated.slice(0, lastSpace)}...`;
|
||||
}
|
||||
return `${truncated}...`;
|
||||
};
|
||||
|
||||
// Check for stuck deployment (more than 9 minutes) - only for the most recent deployment
|
||||
const stuckDeployment = useMemo(() => {
|
||||
@@ -117,7 +143,10 @@ export const ShowDeployments = ({
|
||||
See the last 10 deployments for this {type}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="flex flex-row items-center flex-wrap gap-2">
|
||||
{(type === "application" || type === "compose") && (
|
||||
<KillBuild id={id} type={type} />
|
||||
)}
|
||||
{(type === "application" || type === "compose") && (
|
||||
<CancelQueues id={id} type={type} />
|
||||
)}
|
||||
@@ -217,122 +246,183 @@ export const ShowDeployments = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{deployments?.map((deployment, index) => (
|
||||
<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">
|
||||
{index + 1}. {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}
|
||||
{deployments?.map((deployment, index) => {
|
||||
const titleText = deployment?.title?.trim() || "";
|
||||
const needsTruncation = titleText.length > MAX_DESCRIPTION_LENGTH;
|
||||
const isExpanded = expandedDescriptions.has(
|
||||
deployment.deploymentId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={deployment.deploymentId}
|
||||
className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||
{index + 1}. {deployment.status}
|
||||
<StatusTooltip
|
||||
status={deployment?.status}
|
||||
className="size-2.5"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
||||
<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 className="flex flex-row items-center gap-2">
|
||||
{deployment.pid && deployment.status === "running" && (
|
||||
<DialogAction
|
||||
title="Kill Process"
|
||||
description="Are you sure you want to kill the process?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await killProcess({
|
||||
deploymentId: deployment.deploymentId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Process killed successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error killing process");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isLoading={isKillingProcess}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="break-words text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{isExpanded || !needsTruncation
|
||||
? titleText
|
||||
: truncateDescription(titleText)}
|
||||
</span>
|
||||
{needsTruncation && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = new Set(expandedDescriptions);
|
||||
if (next.has(deployment.deploymentId)) {
|
||||
next.delete(deployment.deploymentId);
|
||||
} else {
|
||||
next.add(deployment.deploymentId);
|
||||
}
|
||||
setExpandedDescriptions(next);
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit mt-1 cursor-pointer"
|
||||
aria-label={
|
||||
isExpanded
|
||||
? "Collapse commit message"
|
||||
: "Expand commit message"
|
||||
}
|
||||
>
|
||||
Kill Process
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLog(deployment);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="size-3" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="size-3" />
|
||||
Show more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* Hash (from description) - shown in compact form */}
|
||||
{deployment.description?.trim() && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{deployment.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-2 sm:w-auto sm:max-w-[300px] sm:items-end sm:justify-start">
|
||||
<div className="text-sm capitalize text-muted-foreground flex flex-wrap items-center gap-2">
|
||||
<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>
|
||||
|
||||
{deployment?.rollback &&
|
||||
deployment.status === "done" &&
|
||||
type === "application" && (
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-end">
|
||||
{deployment.pid && deployment.status === "running" && (
|
||||
<DialogAction
|
||||
title="Rollback to this deployment"
|
||||
description="Are you sure you want to rollback to this deployment?"
|
||||
title="Kill Process"
|
||||
description="Are you sure you want to kill the process?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await rollback({
|
||||
rollbackId: deployment.rollback.rollbackId,
|
||||
await killProcess({
|
||||
deploymentId: deployment.deploymentId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Rollback initiated successfully",
|
||||
);
|
||||
toast.success("Process killed successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error initiating rollback");
|
||||
toast.error("Error killing process");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isLoading={isRollingBack}
|
||||
isLoading={isKillingProcess}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||
Rollback
|
||||
Kill Process
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLog(deployment);
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
|
||||
{deployment?.rollback &&
|
||||
deployment.status === "done" &&
|
||||
type === "application" && (
|
||||
<DialogAction
|
||||
title="Rollback to this deployment"
|
||||
description={
|
||||
<div className="flex flex-col gap-3">
|
||||
<p>
|
||||
Are you sure you want to rollback to this
|
||||
deployment?
|
||||
</p>
|
||||
<AlertBlock type="info" className="text-sm">
|
||||
Please wait a few seconds while the image is
|
||||
pulled from the registry. Your application
|
||||
should be running shortly.
|
||||
</AlertBlock>
|
||||
</div>
|
||||
}
|
||||
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}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||
Rollback
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<ShowDeployment
|
||||
serverId={serverId}
|
||||
serverId={activeLog?.buildServerId || serverId}
|
||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
||||
onClose={() => setActiveLog(null)}
|
||||
logPath={activeLog?.logPath || ""}
|
||||
|
||||
@@ -46,7 +46,13 @@ export type CacheType = "fetch" | "cache";
|
||||
|
||||
export const domain = z
|
||||
.object({
|
||||
host: z.string().min(1, { message: "Add a hostname" }),
|
||||
host: z
|
||||
.string()
|
||||
.min(1, { message: "Add a hostname" })
|
||||
.refine((val) => val === val.trim(), {
|
||||
message: "Domain name cannot have leading or trailing spaces",
|
||||
})
|
||||
.transform((val) => val.trim()),
|
||||
path: z.string().min(1).optional(),
|
||||
internalPath: z.string().optional(),
|
||||
stripPath: z.boolean().optional(),
|
||||
@@ -202,6 +208,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
const certificateType = form.watch("certificateType");
|
||||
const https = form.watch("https");
|
||||
const domainType = form.watch("domainType");
|
||||
const host = form.watch("host");
|
||||
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -299,6 +307,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
{type === "compose" && (
|
||||
<AlertBlock type="info" className="mb-4">
|
||||
Whenever you make changes to domains, remember to redeploy your
|
||||
compose to apply the changes.
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form"
|
||||
@@ -489,6 +504,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
to make your traefik.me domain work.
|
||||
</AlertBlock>
|
||||
)}
|
||||
{isTraefikMeDomain && (
|
||||
<AlertBlock type="info">
|
||||
<strong>Note:</strong> traefik.me is a public HTTP
|
||||
service and does not support SSL/HTTPS. HTTPS and
|
||||
certificate options will not have any effect.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<FormLabel>Host</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
|
||||
@@ -108,6 +108,21 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
});
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
|
||||
@@ -5,13 +5,23 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form";
|
||||
import { Secrets } from "@/components/ui/secrets";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const addEnvironmentSchema = z.object({
|
||||
env: z.string(),
|
||||
buildArgs: z.string(),
|
||||
buildSecrets: z.string(),
|
||||
createEnvFile: z.boolean(),
|
||||
});
|
||||
|
||||
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
||||
@@ -37,6 +47,8 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
defaultValues: {
|
||||
env: "",
|
||||
buildArgs: "",
|
||||
buildSecrets: "",
|
||||
createEnvFile: true,
|
||||
},
|
||||
resolver: zodResolver(addEnvironmentSchema),
|
||||
});
|
||||
@@ -44,15 +56,21 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
// Watch form values
|
||||
const currentEnv = form.watch("env");
|
||||
const currentBuildArgs = form.watch("buildArgs");
|
||||
const currentBuildSecrets = form.watch("buildSecrets");
|
||||
const currentCreateEnvFile = form.watch("createEnvFile");
|
||||
const hasChanges =
|
||||
currentEnv !== (data?.env || "") ||
|
||||
currentBuildArgs !== (data?.buildArgs || "");
|
||||
currentBuildArgs !== (data?.buildArgs || "") ||
|
||||
currentBuildSecrets !== (data?.buildSecrets || "") ||
|
||||
currentCreateEnvFile !== (data?.createEnvFile ?? true);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
env: data.env || "",
|
||||
buildArgs: data.buildArgs || "",
|
||||
buildSecrets: data.buildSecrets || "",
|
||||
createEnvFile: data.createEnvFile ?? true,
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
@@ -61,6 +79,8 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
mutateAsync({
|
||||
env: formData.env,
|
||||
buildArgs: formData.buildArgs,
|
||||
buildSecrets: formData.buildSecrets,
|
||||
createEnvFile: formData.createEnvFile,
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -76,9 +96,26 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
form.reset({
|
||||
env: data?.env || "",
|
||||
buildArgs: data?.buildArgs || "",
|
||||
buildSecrets: data?.buildSecrets || "",
|
||||
createEnvFile: data?.createEnvFile ?? true,
|
||||
});
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<Card className="bg-background px-6 pb-6">
|
||||
<Form {...form}>
|
||||
@@ -104,13 +141,14 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
{data?.buildType === "dockerfile" && (
|
||||
<Secrets
|
||||
name="buildArgs"
|
||||
title="Build-time Variables"
|
||||
title="Build-time Arguments"
|
||||
description={
|
||||
<span>
|
||||
Available only at build-time. See documentation
|
||||
Arguments are available only at build-time. See
|
||||
documentation
|
||||
<a
|
||||
className="text-primary"
|
||||
href="https://docs.docker.com/build/guide/build-args/"
|
||||
href="https://docs.docker.com/build/building/variables/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -122,6 +160,53 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
placeholder="NPM_TOKEN=xyz"
|
||||
/>
|
||||
)}
|
||||
{data?.buildType === "dockerfile" && (
|
||||
<Secrets
|
||||
name="buildSecrets"
|
||||
title="Build-time Secrets"
|
||||
description={
|
||||
<span>
|
||||
Secrets are specially designed for sensitive information and
|
||||
are only available at build-time. See documentation
|
||||
<a
|
||||
className="text-primary"
|
||||
href="https://docs.docker.com/build/building/secrets/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
}
|
||||
placeholder="NPM_TOKEN=xyz"
|
||||
/>
|
||||
)}
|
||||
{data?.buildType === "dockerfile" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="createEnvFile"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Create Environment File</FormLabel>
|
||||
<FormDescription>
|
||||
When enabled, an .env file will be created in the same
|
||||
directory as your Dockerfile during the build process.
|
||||
Disable this if you don't want to generate an environment
|
||||
file.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
|
||||
@@ -150,7 +150,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
enableSubmodules: data.enableSubmodules || false,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
toast.success("Service Provider Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -59,7 +59,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync, isLoading } =
|
||||
api.application.saveGitProdiver.useMutation();
|
||||
api.application.saveGitProvider.useMutation();
|
||||
|
||||
const form = useForm<GitProvider>({
|
||||
defaultValues: {
|
||||
|
||||
@@ -149,7 +149,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
toast.success("Service Provider Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -167,7 +167,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
toast.success("Service Provider Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -232,9 +232,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<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 && (
|
||||
{field.value.gitlabPathNamespace && (
|
||||
<Link
|
||||
href={`${gitlabUrl}/${field.value.owner}/${field.value.repo}`}
|
||||
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
|
||||
@@ -86,6 +86,9 @@ export const AddPreviewDomain = ({
|
||||
resolver: zodResolver(domain),
|
||||
});
|
||||
|
||||
const host = form.watch("host");
|
||||
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
@@ -157,6 +160,13 @@ export const AddPreviewDomain = ({
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
{isTraefikMeDomain && (
|
||||
<AlertBlock type="info">
|
||||
<strong>Note:</strong> traefik.me is a public HTTP
|
||||
service and does not support SSL/HTTPS. HTTPS and
|
||||
certificate options will not have any effect.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<FormLabel>Host</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ExternalLink,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
Hammer,
|
||||
Loader2,
|
||||
PenSquare,
|
||||
RocketIcon,
|
||||
@@ -22,6 +23,13 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
||||
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
||||
@@ -38,6 +46,9 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
||||
api.previewDeployment.delete.useMutation();
|
||||
|
||||
const { mutateAsync: redeployPreviewDeployment } =
|
||||
api.previewDeployment.redeploy.useMutation();
|
||||
|
||||
const {
|
||||
data: previewDeployments,
|
||||
refetch: refetchPreviewDeployments,
|
||||
@@ -46,6 +57,8 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
{ applicationId },
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
refetchInterval: (data) =>
|
||||
data?.some((d) => d.previewStatus === "running") ? 2000 : false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -182,7 +195,68 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
id={deployment.previewDeploymentId}
|
||||
type="previewDeployment"
|
||||
serverId={data?.serverId || ""}
|
||||
/>
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<RocketIcon className="size-4" />
|
||||
Deployments
|
||||
</Button>
|
||||
</ShowDeploymentsModal>
|
||||
|
||||
<DialogAction
|
||||
title="Rebuild Preview Deployment"
|
||||
description="Are you sure you want to rebuild this preview deployment?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeployPreviewDeployment({
|
||||
previewDeploymentId:
|
||||
deployment.previewDeploymentId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Preview deployment rebuild started",
|
||||
);
|
||||
refetchPreviewDeployments();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error rebuilding preview deployment",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
isLoading={status === "running"}
|
||||
className="gap-2"
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<Hammer className="size-4" />
|
||||
Rebuild
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent
|
||||
sideOffset={5}
|
||||
className="z-[60]"
|
||||
>
|
||||
<p>
|
||||
Rebuild the preview deployment without
|
||||
downloading new code
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
|
||||
<AddPreviewDomain
|
||||
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -46,6 +47,7 @@ const schema = z
|
||||
.object({
|
||||
env: z.string(),
|
||||
buildArgs: z.string(),
|
||||
buildSecrets: z.string(),
|
||||
wildcardDomain: z.string(),
|
||||
port: z.number(),
|
||||
previewLimit: z.number(),
|
||||
@@ -99,6 +101,8 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
});
|
||||
|
||||
const previewHttps = form.watch("previewHttps");
|
||||
const wildcardDomain = form.watch("wildcardDomain");
|
||||
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
|
||||
|
||||
useEffect(() => {
|
||||
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
||||
@@ -109,6 +113,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
form.reset({
|
||||
env: data.previewEnv || "",
|
||||
buildArgs: data.previewBuildArgs || "",
|
||||
buildSecrets: data.previewBuildSecrets || "",
|
||||
wildcardDomain: data.previewWildcard || "*.traefik.me",
|
||||
port: data.previewPort || 3000,
|
||||
previewLabels: data.previewLabels || [],
|
||||
@@ -118,7 +123,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
previewCertificateType: data.previewCertificateType || "none",
|
||||
previewCustomCertResolver: data.previewCustomCertResolver || "",
|
||||
previewRequireCollaboratorPermissions:
|
||||
data.previewRequireCollaboratorPermissions || true,
|
||||
data.previewRequireCollaboratorPermissions ?? true,
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
@@ -127,6 +132,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
updateApplication({
|
||||
previewEnv: formData.env,
|
||||
previewBuildArgs: formData.buildArgs,
|
||||
previewBuildSecrets: formData.buildSecrets,
|
||||
previewWildcard: formData.wildcardDomain,
|
||||
previewPort: formData.port,
|
||||
previewLabels: formData.previewLabels,
|
||||
@@ -165,6 +171,13 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
{isTraefikMeDomain && (
|
||||
<AlertBlock type="info">
|
||||
<strong>Note:</strong> traefik.me is a public HTTP service and
|
||||
does not support SSL/HTTPS. HTTPS and certificate options will
|
||||
not have any effect.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
@@ -467,13 +480,37 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
{data?.buildType === "dockerfile" && (
|
||||
<Secrets
|
||||
name="buildArgs"
|
||||
title="Build-time Variables"
|
||||
title="Build-time Arguments"
|
||||
description={
|
||||
<span>
|
||||
Available only at build-time. See documentation
|
||||
Arguments are available only at build-time. See
|
||||
documentation
|
||||
<a
|
||||
className="text-primary"
|
||||
href="https://docs.docker.com/build/guide/build-args/"
|
||||
href="https://docs.docker.com/build/building/variables/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</span>
|
||||
}
|
||||
placeholder="NPM_TOKEN=xyz"
|
||||
/>
|
||||
)}
|
||||
{data?.buildType === "dockerfile" && (
|
||||
<Secrets
|
||||
name="buildSecrets"
|
||||
title="Build-time Secrets"
|
||||
description={
|
||||
<span>
|
||||
Secrets are specially designed for sensitive information
|
||||
and are only available at build-time. See
|
||||
documentation
|
||||
<a
|
||||
className="text-primary"
|
||||
href="https://docs.docker.com/build/building/secrets/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -20,13 +21,37 @@ import {
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const formSchema = z.object({
|
||||
rollbackActive: z.boolean(),
|
||||
});
|
||||
const formSchema = z
|
||||
.object({
|
||||
rollbackActive: z.boolean(),
|
||||
rollbackRegistryId: z.string().optional(),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
if (
|
||||
values.rollbackActive &&
|
||||
(!values.rollbackRegistryId || values.rollbackRegistryId === "none")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["rollbackRegistryId"],
|
||||
message: "Registry is required when rollbacks are enabled",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
@@ -49,17 +74,33 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
||||
const { mutateAsync: updateApplication, isLoading } =
|
||||
api.application.update.useMutation();
|
||||
|
||||
const { data: registries } = api.registry.all.useQuery();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
rollbackActive: application?.rollbackActive ?? false,
|
||||
rollbackRegistryId: application?.rollbackRegistryId || "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (application) {
|
||||
form.reset({
|
||||
rollbackActive: application.rollbackActive ?? false,
|
||||
rollbackRegistryId: application.rollbackRegistryId || "",
|
||||
});
|
||||
}
|
||||
}, [application, form]);
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
await updateApplication({
|
||||
applicationId,
|
||||
rollbackActive: data.rollbackActive,
|
||||
rollbackRegistryId:
|
||||
data.rollbackRegistryId === "none" || !data.rollbackRegistryId
|
||||
? null
|
||||
: data.rollbackRegistryId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Rollback settings updated");
|
||||
@@ -112,6 +153,65 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("rollbackActive") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rollbackRegistryId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Rollback Registry</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || "none"}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a registry" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">
|
||||
<span className="flex items-center gap-2">
|
||||
<span>None</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
{registries?.map((registry) => (
|
||||
<SelectItem
|
||||
key={registry.registryId}
|
||||
value={registry.registryId}
|
||||
>
|
||||
{registry.registryName}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>
|
||||
Registries ({registries?.length || 0})
|
||||
</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!registries || registries.length === 0 ? (
|
||||
<FormDescription className="text-amber-600 dark:text-amber-500">
|
||||
No registries available. Please{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/registry"
|
||||
className="underline font-medium hover:text-amber-700 dark:hover:text-amber-400"
|
||||
>
|
||||
configure a registry
|
||||
</Link>{" "}
|
||||
first to enable rollbacks.
|
||||
</FormDescription>
|
||||
) : (
|
||||
<FormDescription>
|
||||
Select a registry where rollback images will be stored.
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||
Save Settings
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
DatabaseZap,
|
||||
Info,
|
||||
PenBoxIcon,
|
||||
@@ -7,12 +9,20 @@ import {
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { type Control, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -31,6 +41,12 @@ import {
|
||||
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,
|
||||
@@ -48,6 +64,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import type { CacheType } from "../domains/handle-domain";
|
||||
import { getTimezoneLabel, TIMEZONES } from "./timezones";
|
||||
|
||||
export const commonCronExpressions = [
|
||||
{ label: "Every minute", value: "* * * * *" },
|
||||
@@ -57,6 +74,7 @@ export const commonCronExpressions = [
|
||||
{ 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" },
|
||||
{ label: "Custom", value: "custom" },
|
||||
];
|
||||
|
||||
const formSchema = z
|
||||
@@ -74,6 +92,7 @@ const formSchema = z
|
||||
"dokploy-server",
|
||||
]),
|
||||
script: z.string(),
|
||||
timezone: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.scheduleType === "compose" && !data.serviceName) {
|
||||
@@ -115,10 +134,91 @@ interface Props {
|
||||
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
|
||||
}
|
||||
|
||||
export const ScheduleFormField = ({
|
||||
name,
|
||||
formControl,
|
||||
}: {
|
||||
name: string;
|
||||
formControl: Control<any>;
|
||||
}) => {
|
||||
const [selectedOption, setSelectedOption] = useState("");
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={formControl}
|
||||
name={name}
|
||||
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
|
||||
value={selectedOption}
|
||||
onValueChange={(value) => {
|
||||
setSelectedOption(value);
|
||||
field.onChange(value === "custom" ? "" : 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 !== "custom" && ` (${expr.value})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const commonExpression = commonCronExpressions.find(
|
||||
(expression) => expression.value === value,
|
||||
);
|
||||
if (commonExpression) {
|
||||
setSelectedOption(commonExpression.value);
|
||||
} else {
|
||||
setSelectedOption("custom");
|
||||
}
|
||||
field.onChange(e);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Choose a predefined schedule or enter a custom cron expression
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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),
|
||||
@@ -131,6 +231,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
serviceName: "",
|
||||
scheduleType: scheduleType || "application",
|
||||
script: "",
|
||||
timezone: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -169,6 +270,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
serviceName: schedule.serviceName || "",
|
||||
scheduleType: schedule.scheduleType,
|
||||
script: schedule.script || "",
|
||||
timezone: schedule.timezone || undefined,
|
||||
});
|
||||
}
|
||||
}, [form, schedule, scheduleId]);
|
||||
@@ -377,13 +479,18 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<ScheduleFormField
|
||||
name="cronExpression"
|
||||
formControl={form.control}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cronExpression"
|
||||
name="timezone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
Schedule
|
||||
Timezone
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -391,45 +498,69 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Cron expression format: minute hour day month
|
||||
weekday
|
||||
Select a timezone for the schedule. If not
|
||||
specified, UTC will be used.
|
||||
</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);
|
||||
}}
|
||||
>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a predefined schedule" />
|
||||
</SelectTrigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{getTimezoneLabel(field.value)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</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}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search timezone..."
|
||||
className="h-9"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandEmpty>No timezone found.</CommandEmpty>
|
||||
<ScrollArea className="h-72">
|
||||
{Object.entries(TIMEZONES).map(
|
||||
([region, zones]) => (
|
||||
<CommandGroup key={region} heading={region}>
|
||||
{zones.map((tz) => (
|
||||
<CommandItem
|
||||
key={tz.value}
|
||||
value={`${region} ${tz.label} ${tz.value}`}
|
||||
onSelect={() => {
|
||||
field.onChange(tz.value);
|
||||
}}
|
||||
>
|
||||
{tz.value}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
field.value === tz.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
),
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
Choose a predefined schedule or enter a custom cron
|
||||
expression
|
||||
Optional: Choose a timezone for the schedule execution time
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Terminal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -33,6 +34,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
const [runningSchedules, setRunningSchedules] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const {
|
||||
data: schedules,
|
||||
isLoading: isLoadingSchedules,
|
||||
@@ -46,14 +50,27 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
enabled: !!id,
|
||||
},
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
|
||||
api.schedule.delete.useMutation();
|
||||
const { mutateAsync: runManually } = api.schedule.runManually.useMutation();
|
||||
|
||||
const { mutateAsync: runManually, isLoading } =
|
||||
api.schedule.runManually.useMutation();
|
||||
const handleRunManually = async (scheduleId: string) => {
|
||||
setRunningSchedules((prev) => new Set(prev).add(scheduleId));
|
||||
try {
|
||||
await runManually({ scheduleId });
|
||||
toast.success("Schedule run successfully");
|
||||
await refetchSchedules();
|
||||
} catch {
|
||||
toast.error("Error running schedule");
|
||||
} finally {
|
||||
setRunningSchedules((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(scheduleId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||
@@ -67,7 +84,6 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
Schedule tasks to run automatically at specified intervals.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{schedules && schedules.length > 0 && (
|
||||
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||
)}
|
||||
@@ -75,7 +91,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{isLoadingSchedules ? (
|
||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||
<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...
|
||||
@@ -91,13 +107,13 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
return (
|
||||
<div
|
||||
key={schedule.scheduleId}
|
||||
className="flex items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50"
|
||||
className="flex flex-col sm:flex-row sm:items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50 w-full"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-start gap-3 w-full sm:w-auto">
|
||||
<div className="flex flex-shrink-0 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="space-y-1.5 w-full sm:w-auto">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-sm font-medium leading-none [overflow-wrap:anywhere] line-clamp-3">
|
||||
{schedule.name}
|
||||
@@ -132,16 +148,15 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
)}
|
||||
</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">
|
||||
<div className="flex items-start gap-2 max-w-full">
|
||||
<Terminal className="size-3.5 text-muted-foreground/70 flex-shrink-0 mt-0.5" />
|
||||
<code className="font-mono text-[10px] text-muted-foreground/70 break-all max-w-[calc(100%-20px)]">
|
||||
{schedule.command}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 md:gap-1.5">
|
||||
<ShowDeploymentsModal
|
||||
id={schedule.scheduleId}
|
||||
@@ -149,10 +164,9 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
serverId={serverId || undefined}
|
||||
>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ClipboardList className="size-4 transition-colors " />
|
||||
<ClipboardList className="size-4 transition-colors" />
|
||||
</Button>
|
||||
</ShowDeploymentsModal>
|
||||
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -160,37 +174,26 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
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();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error running schedule");
|
||||
});
|
||||
}}
|
||||
disabled={runningSchedules.has(schedule.scheduleId)}
|
||||
onClick={() =>
|
||||
handleRunManually(schedule.scheduleId)
|
||||
}
|
||||
>
|
||||
<Play className="size-4 transition-colors" />
|
||||
{runningSchedules.has(schedule.scheduleId) ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<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?"
|
||||
@@ -214,8 +217,8 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isDeleting}
|
||||
className="group hover:bg-red-500/10"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
// Complete list of IANA timezones grouped by region
|
||||
export const TIMEZONES: Record<
|
||||
string,
|
||||
Array<{ label: string; value: string }>
|
||||
> = {
|
||||
Common: [{ label: "UTC (Coordinated Universal Time)", value: "UTC" }],
|
||||
Africa: [
|
||||
{ label: "Abidjan", value: "Africa/Abidjan" },
|
||||
{ label: "Accra", value: "Africa/Accra" },
|
||||
{ label: "Addis Ababa", value: "Africa/Addis_Ababa" },
|
||||
{ label: "Algiers", value: "Africa/Algiers" },
|
||||
{ label: "Asmara", value: "Africa/Asmara" },
|
||||
{ label: "Bamako", value: "Africa/Bamako" },
|
||||
{ label: "Bangui", value: "Africa/Bangui" },
|
||||
{ label: "Banjul", value: "Africa/Banjul" },
|
||||
{ label: "Bissau", value: "Africa/Bissau" },
|
||||
{ label: "Blantyre", value: "Africa/Blantyre" },
|
||||
{ label: "Brazzaville", value: "Africa/Brazzaville" },
|
||||
{ label: "Bujumbura", value: "Africa/Bujumbura" },
|
||||
{ label: "Cairo", value: "Africa/Cairo" },
|
||||
{ label: "Casablanca", value: "Africa/Casablanca" },
|
||||
{ label: "Ceuta", value: "Africa/Ceuta" },
|
||||
{ label: "Conakry", value: "Africa/Conakry" },
|
||||
{ label: "Dakar", value: "Africa/Dakar" },
|
||||
{ label: "Dar es Salaam", value: "Africa/Dar_es_Salaam" },
|
||||
{ label: "Djibouti", value: "Africa/Djibouti" },
|
||||
{ label: "Douala", value: "Africa/Douala" },
|
||||
{ label: "El Aaiun", value: "Africa/El_Aaiun" },
|
||||
{ label: "Freetown", value: "Africa/Freetown" },
|
||||
{ label: "Gaborone", value: "Africa/Gaborone" },
|
||||
{ label: "Harare", value: "Africa/Harare" },
|
||||
{ label: "Johannesburg", value: "Africa/Johannesburg" },
|
||||
{ label: "Juba", value: "Africa/Juba" },
|
||||
{ label: "Kampala", value: "Africa/Kampala" },
|
||||
{ label: "Khartoum", value: "Africa/Khartoum" },
|
||||
{ label: "Kigali", value: "Africa/Kigali" },
|
||||
{ label: "Kinshasa", value: "Africa/Kinshasa" },
|
||||
{ label: "Lagos", value: "Africa/Lagos" },
|
||||
{ label: "Libreville", value: "Africa/Libreville" },
|
||||
{ label: "Lome", value: "Africa/Lome" },
|
||||
{ label: "Luanda", value: "Africa/Luanda" },
|
||||
{ label: "Lubumbashi", value: "Africa/Lubumbashi" },
|
||||
{ label: "Lusaka", value: "Africa/Lusaka" },
|
||||
{ label: "Malabo", value: "Africa/Malabo" },
|
||||
{ label: "Maputo", value: "Africa/Maputo" },
|
||||
{ label: "Maseru", value: "Africa/Maseru" },
|
||||
{ label: "Mbabane", value: "Africa/Mbabane" },
|
||||
{ label: "Mogadishu", value: "Africa/Mogadishu" },
|
||||
{ label: "Monrovia", value: "Africa/Monrovia" },
|
||||
{ label: "Nairobi", value: "Africa/Nairobi" },
|
||||
{ label: "Ndjamena", value: "Africa/Ndjamena" },
|
||||
{ label: "Niamey", value: "Africa/Niamey" },
|
||||
{ label: "Nouakchott", value: "Africa/Nouakchott" },
|
||||
{ label: "Ouagadougou", value: "Africa/Ouagadougou" },
|
||||
{ label: "Porto-Novo", value: "Africa/Porto-Novo" },
|
||||
{ label: "Sao Tome", value: "Africa/Sao_Tome" },
|
||||
{ label: "Tripoli", value: "Africa/Tripoli" },
|
||||
{ label: "Tunis", value: "Africa/Tunis" },
|
||||
{ label: "Windhoek", value: "Africa/Windhoek" },
|
||||
],
|
||||
America: [
|
||||
{ label: "Adak", value: "America/Adak" },
|
||||
{ label: "Anchorage", value: "America/Anchorage" },
|
||||
{ label: "Anguilla", value: "America/Anguilla" },
|
||||
{ label: "Antigua", value: "America/Antigua" },
|
||||
{ label: "Araguaina", value: "America/Araguaina" },
|
||||
{
|
||||
label: "Argentina/Buenos Aires",
|
||||
value: "America/Argentina/Buenos_Aires",
|
||||
},
|
||||
{ label: "Argentina/Catamarca", value: "America/Argentina/Catamarca" },
|
||||
{ label: "Argentina/Cordoba", value: "America/Argentina/Cordoba" },
|
||||
{ label: "Argentina/Jujuy", value: "America/Argentina/Jujuy" },
|
||||
{ label: "Argentina/La Rioja", value: "America/Argentina/La_Rioja" },
|
||||
{ label: "Argentina/Mendoza", value: "America/Argentina/Mendoza" },
|
||||
{
|
||||
label: "Argentina/Rio Gallegos",
|
||||
value: "America/Argentina/Rio_Gallegos",
|
||||
},
|
||||
{ label: "Argentina/Salta", value: "America/Argentina/Salta" },
|
||||
{ label: "Argentina/San Juan", value: "America/Argentina/San_Juan" },
|
||||
{ label: "Argentina/San Luis", value: "America/Argentina/San_Luis" },
|
||||
{ label: "Argentina/Tucuman", value: "America/Argentina/Tucuman" },
|
||||
{ label: "Argentina/Ushuaia", value: "America/Argentina/Ushuaia" },
|
||||
{ label: "Aruba", value: "America/Aruba" },
|
||||
{ label: "Asuncion", value: "America/Asuncion" },
|
||||
{ label: "Atikokan", value: "America/Atikokan" },
|
||||
{ label: "Bahia", value: "America/Bahia" },
|
||||
{ label: "Bahia Banderas", value: "America/Bahia_Banderas" },
|
||||
{ label: "Barbados", value: "America/Barbados" },
|
||||
{ label: "Belem", value: "America/Belem" },
|
||||
{ label: "Belize", value: "America/Belize" },
|
||||
{ label: "Blanc-Sablon", value: "America/Blanc-Sablon" },
|
||||
{ label: "Boa Vista", value: "America/Boa_Vista" },
|
||||
{ label: "Bogota", value: "America/Bogota" },
|
||||
{ label: "Boise", value: "America/Boise" },
|
||||
{ label: "Cambridge Bay", value: "America/Cambridge_Bay" },
|
||||
{ label: "Campo Grande", value: "America/Campo_Grande" },
|
||||
{ label: "Cancun", value: "America/Cancun" },
|
||||
{ label: "Caracas", value: "America/Caracas" },
|
||||
{ label: "Cayenne", value: "America/Cayenne" },
|
||||
{ label: "Cayman", value: "America/Cayman" },
|
||||
{ label: "Chicago (Central Time)", value: "America/Chicago" },
|
||||
{ label: "Chihuahua", value: "America/Chihuahua" },
|
||||
{ label: "Ciudad Juarez", value: "America/Ciudad_Juarez" },
|
||||
{ label: "Costa Rica", value: "America/Costa_Rica" },
|
||||
{ label: "Creston", value: "America/Creston" },
|
||||
{ label: "Cuiaba", value: "America/Cuiaba" },
|
||||
{ label: "Curacao", value: "America/Curacao" },
|
||||
{ label: "Danmarkshavn", value: "America/Danmarkshavn" },
|
||||
{ label: "Dawson", value: "America/Dawson" },
|
||||
{ label: "Dawson Creek", value: "America/Dawson_Creek" },
|
||||
{ label: "Denver (Mountain Time)", value: "America/Denver" },
|
||||
{ label: "Detroit", value: "America/Detroit" },
|
||||
{ label: "Dominica", value: "America/Dominica" },
|
||||
{ label: "Edmonton", value: "America/Edmonton" },
|
||||
{ label: "Eirunepe", value: "America/Eirunepe" },
|
||||
{ label: "El Salvador", value: "America/El_Salvador" },
|
||||
{ label: "Fort Nelson", value: "America/Fort_Nelson" },
|
||||
{ label: "Fortaleza", value: "America/Fortaleza" },
|
||||
{ label: "Glace Bay", value: "America/Glace_Bay" },
|
||||
{ label: "Goose Bay", value: "America/Goose_Bay" },
|
||||
{ label: "Grand Turk", value: "America/Grand_Turk" },
|
||||
{ label: "Grenada", value: "America/Grenada" },
|
||||
{ label: "Guadeloupe", value: "America/Guadeloupe" },
|
||||
{ label: "Guatemala", value: "America/Guatemala" },
|
||||
{ label: "Guayaquil", value: "America/Guayaquil" },
|
||||
{ label: "Guyana", value: "America/Guyana" },
|
||||
{ label: "Halifax", value: "America/Halifax" },
|
||||
{ label: "Havana", value: "America/Havana" },
|
||||
{ label: "Hermosillo", value: "America/Hermosillo" },
|
||||
{ label: "Indiana/Indianapolis", value: "America/Indiana/Indianapolis" },
|
||||
{ label: "Indiana/Knox", value: "America/Indiana/Knox" },
|
||||
{ label: "Indiana/Marengo", value: "America/Indiana/Marengo" },
|
||||
{ label: "Indiana/Petersburg", value: "America/Indiana/Petersburg" },
|
||||
{ label: "Indiana/Tell City", value: "America/Indiana/Tell_City" },
|
||||
{ label: "Indiana/Vevay", value: "America/Indiana/Vevay" },
|
||||
{ label: "Indiana/Vincennes", value: "America/Indiana/Vincennes" },
|
||||
{ label: "Indiana/Winamac", value: "America/Indiana/Winamac" },
|
||||
{ label: "Inuvik", value: "America/Inuvik" },
|
||||
{ label: "Iqaluit", value: "America/Iqaluit" },
|
||||
{ label: "Jamaica", value: "America/Jamaica" },
|
||||
{ label: "Juneau", value: "America/Juneau" },
|
||||
{ label: "Kentucky/Louisville", value: "America/Kentucky/Louisville" },
|
||||
{ label: "Kentucky/Monticello", value: "America/Kentucky/Monticello" },
|
||||
{ label: "Kralendijk", value: "America/Kralendijk" },
|
||||
{ label: "La Paz", value: "America/La_Paz" },
|
||||
{ label: "Lima", value: "America/Lima" },
|
||||
{ label: "Los Angeles (Pacific Time)", value: "America/Los_Angeles" },
|
||||
{ label: "Lower Princes", value: "America/Lower_Princes" },
|
||||
{ label: "Maceio", value: "America/Maceio" },
|
||||
{ label: "Managua", value: "America/Managua" },
|
||||
{ label: "Manaus", value: "America/Manaus" },
|
||||
{ label: "Marigot", value: "America/Marigot" },
|
||||
{ label: "Martinique", value: "America/Martinique" },
|
||||
{ label: "Matamoros", value: "America/Matamoros" },
|
||||
{ label: "Mazatlan", value: "America/Mazatlan" },
|
||||
{ label: "Menominee", value: "America/Menominee" },
|
||||
{ label: "Merida", value: "America/Merida" },
|
||||
{ label: "Metlakatla", value: "America/Metlakatla" },
|
||||
{ label: "Mexico City (Central Mexico)", value: "America/Mexico_City" },
|
||||
{ label: "Miquelon", value: "America/Miquelon" },
|
||||
{ label: "Moncton", value: "America/Moncton" },
|
||||
{ label: "Monterrey", value: "America/Monterrey" },
|
||||
{ label: "Montevideo", value: "America/Montevideo" },
|
||||
{ label: "Montserrat", value: "America/Montserrat" },
|
||||
{ label: "Nassau", value: "America/Nassau" },
|
||||
{ label: "New York (Eastern Time)", value: "America/New_York" },
|
||||
{ label: "Nome", value: "America/Nome" },
|
||||
{ label: "Noronha", value: "America/Noronha" },
|
||||
{ label: "North Dakota/Beulah", value: "America/North_Dakota/Beulah" },
|
||||
{ label: "North Dakota/Center", value: "America/North_Dakota/Center" },
|
||||
{
|
||||
label: "North Dakota/New Salem",
|
||||
value: "America/North_Dakota/New_Salem",
|
||||
},
|
||||
{ label: "Nuuk", value: "America/Nuuk" },
|
||||
{ label: "Ojinaga", value: "America/Ojinaga" },
|
||||
{ label: "Panama", value: "America/Panama" },
|
||||
{ label: "Paramaribo", value: "America/Paramaribo" },
|
||||
{ label: "Phoenix", value: "America/Phoenix" },
|
||||
{ label: "Port-au-Prince", value: "America/Port-au-Prince" },
|
||||
{ label: "Port of Spain", value: "America/Port_of_Spain" },
|
||||
{ label: "Porto Velho", value: "America/Porto_Velho" },
|
||||
{ label: "Puerto Rico", value: "America/Puerto_Rico" },
|
||||
{ label: "Punta Arenas", value: "America/Punta_Arenas" },
|
||||
{ label: "Rankin Inlet", value: "America/Rankin_Inlet" },
|
||||
{ label: "Recife", value: "America/Recife" },
|
||||
{ label: "Regina", value: "America/Regina" },
|
||||
{ label: "Resolute", value: "America/Resolute" },
|
||||
{ label: "Rio Branco", value: "America/Rio_Branco" },
|
||||
{ label: "Santarem", value: "America/Santarem" },
|
||||
{ label: "Santiago", value: "America/Santiago" },
|
||||
{ label: "Santo Domingo", value: "America/Santo_Domingo" },
|
||||
{ label: "Sao Paulo (Brasilia Time)", value: "America/Sao_Paulo" },
|
||||
{ label: "Scoresbysund", value: "America/Scoresbysund" },
|
||||
{ label: "Sitka", value: "America/Sitka" },
|
||||
{ label: "St Barthelemy", value: "America/St_Barthelemy" },
|
||||
{ label: "St Johns", value: "America/St_Johns" },
|
||||
{ label: "St Kitts", value: "America/St_Kitts" },
|
||||
{ label: "St Lucia", value: "America/St_Lucia" },
|
||||
{ label: "St Thomas", value: "America/St_Thomas" },
|
||||
{ label: "St Vincent", value: "America/St_Vincent" },
|
||||
{ label: "Swift Current", value: "America/Swift_Current" },
|
||||
{ label: "Tegucigalpa", value: "America/Tegucigalpa" },
|
||||
{ label: "Thule", value: "America/Thule" },
|
||||
{ label: "Tijuana", value: "America/Tijuana" },
|
||||
{ label: "Toronto", value: "America/Toronto" },
|
||||
{ label: "Tortola", value: "America/Tortola" },
|
||||
{ label: "Vancouver", value: "America/Vancouver" },
|
||||
{ label: "Whitehorse", value: "America/Whitehorse" },
|
||||
{ label: "Winnipeg", value: "America/Winnipeg" },
|
||||
{ label: "Yakutat", value: "America/Yakutat" },
|
||||
],
|
||||
Antarctica: [
|
||||
{ label: "Casey", value: "Antarctica/Casey" },
|
||||
{ label: "Davis", value: "Antarctica/Davis" },
|
||||
{ label: "DumontDUrville", value: "Antarctica/DumontDUrville" },
|
||||
{ label: "Macquarie", value: "Antarctica/Macquarie" },
|
||||
{ label: "Mawson", value: "Antarctica/Mawson" },
|
||||
{ label: "McMurdo", value: "Antarctica/McMurdo" },
|
||||
{ label: "Palmer", value: "Antarctica/Palmer" },
|
||||
{ label: "Rothera", value: "Antarctica/Rothera" },
|
||||
{ label: "Syowa", value: "Antarctica/Syowa" },
|
||||
{ label: "Troll", value: "Antarctica/Troll" },
|
||||
{ label: "Vostok", value: "Antarctica/Vostok" },
|
||||
],
|
||||
Arctic: [{ label: "Longyearbyen", value: "Arctic/Longyearbyen" }],
|
||||
Asia: [
|
||||
{ label: "Aden", value: "Asia/Aden" },
|
||||
{ label: "Almaty", value: "Asia/Almaty" },
|
||||
{ label: "Amman", value: "Asia/Amman" },
|
||||
{ label: "Anadyr", value: "Asia/Anadyr" },
|
||||
{ label: "Aqtau", value: "Asia/Aqtau" },
|
||||
{ label: "Aqtobe", value: "Asia/Aqtobe" },
|
||||
{ label: "Ashgabat", value: "Asia/Ashgabat" },
|
||||
{ label: "Atyrau", value: "Asia/Atyrau" },
|
||||
{ label: "Baghdad", value: "Asia/Baghdad" },
|
||||
{ label: "Bahrain", value: "Asia/Bahrain" },
|
||||
{ label: "Baku", value: "Asia/Baku" },
|
||||
{ label: "Bangkok", value: "Asia/Bangkok" },
|
||||
{ label: "Barnaul", value: "Asia/Barnaul" },
|
||||
{ label: "Beirut", value: "Asia/Beirut" },
|
||||
{ label: "Bishkek", value: "Asia/Bishkek" },
|
||||
{ label: "Brunei", value: "Asia/Brunei" },
|
||||
{ label: "Chita", value: "Asia/Chita" },
|
||||
{ label: "Choibalsan", value: "Asia/Choibalsan" },
|
||||
{ label: "Colombo", value: "Asia/Colombo" },
|
||||
{ label: "Damascus", value: "Asia/Damascus" },
|
||||
{ label: "Dhaka", value: "Asia/Dhaka" },
|
||||
{ label: "Dili", value: "Asia/Dili" },
|
||||
{ label: "Dubai (Gulf Standard Time)", value: "Asia/Dubai" },
|
||||
{ label: "Dushanbe", value: "Asia/Dushanbe" },
|
||||
{ label: "Famagusta", value: "Asia/Famagusta" },
|
||||
{ label: "Gaza", value: "Asia/Gaza" },
|
||||
{ label: "Hebron", value: "Asia/Hebron" },
|
||||
{ label: "Ho Chi Minh", value: "Asia/Ho_Chi_Minh" },
|
||||
{ label: "Hong Kong", value: "Asia/Hong_Kong" },
|
||||
{ label: "Hovd", value: "Asia/Hovd" },
|
||||
{ label: "Irkutsk", value: "Asia/Irkutsk" },
|
||||
{ label: "Jakarta", value: "Asia/Jakarta" },
|
||||
{ label: "Jayapura", value: "Asia/Jayapura" },
|
||||
{ label: "Jerusalem", value: "Asia/Jerusalem" },
|
||||
{ label: "Kabul", value: "Asia/Kabul" },
|
||||
{ label: "Kamchatka", value: "Asia/Kamchatka" },
|
||||
{ label: "Karachi", value: "Asia/Karachi" },
|
||||
{ label: "Kathmandu", value: "Asia/Kathmandu" },
|
||||
{ label: "Khandyga", value: "Asia/Khandyga" },
|
||||
{ label: "Kolkata (India Standard Time)", value: "Asia/Kolkata" },
|
||||
{ label: "Krasnoyarsk", value: "Asia/Krasnoyarsk" },
|
||||
{ label: "Kuala Lumpur", value: "Asia/Kuala_Lumpur" },
|
||||
{ label: "Kuching", value: "Asia/Kuching" },
|
||||
{ label: "Kuwait", value: "Asia/Kuwait" },
|
||||
{ label: "Macau", value: "Asia/Macau" },
|
||||
{ label: "Magadan", value: "Asia/Magadan" },
|
||||
{ label: "Makassar", value: "Asia/Makassar" },
|
||||
{ label: "Manila", value: "Asia/Manila" },
|
||||
{ label: "Muscat", value: "Asia/Muscat" },
|
||||
{ label: "Nicosia", value: "Asia/Nicosia" },
|
||||
{ label: "Novokuznetsk", value: "Asia/Novokuznetsk" },
|
||||
{ label: "Novosibirsk", value: "Asia/Novosibirsk" },
|
||||
{ label: "Omsk", value: "Asia/Omsk" },
|
||||
{ label: "Oral", value: "Asia/Oral" },
|
||||
{ label: "Phnom Penh", value: "Asia/Phnom_Penh" },
|
||||
{ label: "Pontianak", value: "Asia/Pontianak" },
|
||||
{ label: "Pyongyang", value: "Asia/Pyongyang" },
|
||||
{ label: "Qatar", value: "Asia/Qatar" },
|
||||
{ label: "Qostanay", value: "Asia/Qostanay" },
|
||||
{ label: "Qyzylorda", value: "Asia/Qyzylorda" },
|
||||
{ label: "Riyadh", value: "Asia/Riyadh" },
|
||||
{ label: "Sakhalin", value: "Asia/Sakhalin" },
|
||||
{ label: "Samarkand", value: "Asia/Samarkand" },
|
||||
{ label: "Seoul", value: "Asia/Seoul" },
|
||||
{ label: "Shanghai (China Standard Time)", value: "Asia/Shanghai" },
|
||||
{ label: "Singapore", value: "Asia/Singapore" },
|
||||
{ label: "Srednekolymsk", value: "Asia/Srednekolymsk" },
|
||||
{ label: "Taipei", value: "Asia/Taipei" },
|
||||
{ label: "Tashkent", value: "Asia/Tashkent" },
|
||||
{ label: "Tbilisi", value: "Asia/Tbilisi" },
|
||||
{ label: "Tehran", value: "Asia/Tehran" },
|
||||
{ label: "Thimphu", value: "Asia/Thimphu" },
|
||||
{ label: "Tokyo (Japan Standard Time)", value: "Asia/Tokyo" },
|
||||
{ label: "Tomsk", value: "Asia/Tomsk" },
|
||||
{ label: "Ulaanbaatar", value: "Asia/Ulaanbaatar" },
|
||||
{ label: "Urumqi", value: "Asia/Urumqi" },
|
||||
{ label: "Ust-Nera", value: "Asia/Ust-Nera" },
|
||||
{ label: "Vientiane", value: "Asia/Vientiane" },
|
||||
{ label: "Vladivostok", value: "Asia/Vladivostok" },
|
||||
{ label: "Yakutsk", value: "Asia/Yakutsk" },
|
||||
{ label: "Yangon", value: "Asia/Yangon" },
|
||||
{ label: "Yekaterinburg", value: "Asia/Yekaterinburg" },
|
||||
{ label: "Yerevan", value: "Asia/Yerevan" },
|
||||
],
|
||||
Atlantic: [
|
||||
{ label: "Azores", value: "Atlantic/Azores" },
|
||||
{ label: "Bermuda", value: "Atlantic/Bermuda" },
|
||||
{ label: "Canary", value: "Atlantic/Canary" },
|
||||
{ label: "Cape Verde", value: "Atlantic/Cape_Verde" },
|
||||
{ label: "Faroe", value: "Atlantic/Faroe" },
|
||||
{ label: "Madeira", value: "Atlantic/Madeira" },
|
||||
{ label: "Reykjavik", value: "Atlantic/Reykjavik" },
|
||||
{ label: "South Georgia", value: "Atlantic/South_Georgia" },
|
||||
{ label: "St Helena", value: "Atlantic/St_Helena" },
|
||||
{ label: "Stanley", value: "Atlantic/Stanley" },
|
||||
],
|
||||
Australia: [
|
||||
{ label: "Adelaide", value: "Australia/Adelaide" },
|
||||
{ label: "Brisbane", value: "Australia/Brisbane" },
|
||||
{ label: "Broken Hill", value: "Australia/Broken_Hill" },
|
||||
{ label: "Darwin", value: "Australia/Darwin" },
|
||||
{ label: "Eucla", value: "Australia/Eucla" },
|
||||
{ label: "Hobart", value: "Australia/Hobart" },
|
||||
{ label: "Lindeman", value: "Australia/Lindeman" },
|
||||
{ label: "Lord Howe", value: "Australia/Lord_Howe" },
|
||||
{ label: "Melbourne", value: "Australia/Melbourne" },
|
||||
{ label: "Perth", value: "Australia/Perth" },
|
||||
{ label: "Sydney (Australian Eastern Time)", value: "Australia/Sydney" },
|
||||
],
|
||||
Europe: [
|
||||
{ label: "Amsterdam", value: "Europe/Amsterdam" },
|
||||
{ label: "Andorra", value: "Europe/Andorra" },
|
||||
{ label: "Astrakhan", value: "Europe/Astrakhan" },
|
||||
{ label: "Athens", value: "Europe/Athens" },
|
||||
{ label: "Belgrade", value: "Europe/Belgrade" },
|
||||
{ label: "Berlin (Central European Time)", value: "Europe/Berlin" },
|
||||
{ label: "Bratislava", value: "Europe/Bratislava" },
|
||||
{ label: "Brussels", value: "Europe/Brussels" },
|
||||
{ label: "Bucharest", value: "Europe/Bucharest" },
|
||||
{ label: "Budapest", value: "Europe/Budapest" },
|
||||
{ label: "Busingen", value: "Europe/Busingen" },
|
||||
{ label: "Chisinau", value: "Europe/Chisinau" },
|
||||
{ label: "Copenhagen", value: "Europe/Copenhagen" },
|
||||
{ label: "Dublin", value: "Europe/Dublin" },
|
||||
{ label: "Gibraltar", value: "Europe/Gibraltar" },
|
||||
{ label: "Guernsey", value: "Europe/Guernsey" },
|
||||
{ label: "Helsinki", value: "Europe/Helsinki" },
|
||||
{ label: "Isle of Man", value: "Europe/Isle_of_Man" },
|
||||
{ label: "Istanbul", value: "Europe/Istanbul" },
|
||||
{ label: "Jersey", value: "Europe/Jersey" },
|
||||
{ label: "Kaliningrad", value: "Europe/Kaliningrad" },
|
||||
{ label: "Kirov", value: "Europe/Kirov" },
|
||||
{ label: "Kyiv", value: "Europe/Kyiv" },
|
||||
{ label: "Lisbon", value: "Europe/Lisbon" },
|
||||
{ label: "Ljubljana", value: "Europe/Ljubljana" },
|
||||
{ label: "London (Greenwich Mean Time)", value: "Europe/London" },
|
||||
{ label: "Luxembourg", value: "Europe/Luxembourg" },
|
||||
{ label: "Madrid", value: "Europe/Madrid" },
|
||||
{ label: "Malta", value: "Europe/Malta" },
|
||||
{ label: "Mariehamn", value: "Europe/Mariehamn" },
|
||||
{ label: "Minsk", value: "Europe/Minsk" },
|
||||
{ label: "Monaco", value: "Europe/Monaco" },
|
||||
{ label: "Moscow", value: "Europe/Moscow" },
|
||||
{ label: "Oslo", value: "Europe/Oslo" },
|
||||
{ label: "Paris (Central European Time)", value: "Europe/Paris" },
|
||||
{ label: "Podgorica", value: "Europe/Podgorica" },
|
||||
{ label: "Prague", value: "Europe/Prague" },
|
||||
{ label: "Riga", value: "Europe/Riga" },
|
||||
{ label: "Rome", value: "Europe/Rome" },
|
||||
{ label: "Samara", value: "Europe/Samara" },
|
||||
{ label: "San Marino", value: "Europe/San_Marino" },
|
||||
{ label: "Sarajevo", value: "Europe/Sarajevo" },
|
||||
{ label: "Saratov", value: "Europe/Saratov" },
|
||||
{ label: "Simferopol", value: "Europe/Simferopol" },
|
||||
{ label: "Skopje", value: "Europe/Skopje" },
|
||||
{ label: "Sofia", value: "Europe/Sofia" },
|
||||
{ label: "Stockholm", value: "Europe/Stockholm" },
|
||||
{ label: "Tallinn", value: "Europe/Tallinn" },
|
||||
{ label: "Tirane", value: "Europe/Tirane" },
|
||||
{ label: "Ulyanovsk", value: "Europe/Ulyanovsk" },
|
||||
{ label: "Vaduz", value: "Europe/Vaduz" },
|
||||
{ label: "Vatican", value: "Europe/Vatican" },
|
||||
{ label: "Vienna", value: "Europe/Vienna" },
|
||||
{ label: "Vilnius", value: "Europe/Vilnius" },
|
||||
{ label: "Volgograd", value: "Europe/Volgograd" },
|
||||
{ label: "Warsaw", value: "Europe/Warsaw" },
|
||||
{ label: "Zagreb", value: "Europe/Zagreb" },
|
||||
{ label: "Zurich", value: "Europe/Zurich" },
|
||||
],
|
||||
Indian: [
|
||||
{ label: "Antananarivo", value: "Indian/Antananarivo" },
|
||||
{ label: "Chagos", value: "Indian/Chagos" },
|
||||
{ label: "Christmas", value: "Indian/Christmas" },
|
||||
{ label: "Cocos", value: "Indian/Cocos" },
|
||||
{ label: "Comoro", value: "Indian/Comoro" },
|
||||
{ label: "Kerguelen", value: "Indian/Kerguelen" },
|
||||
{ label: "Mahe", value: "Indian/Mahe" },
|
||||
{ label: "Maldives", value: "Indian/Maldives" },
|
||||
{ label: "Mauritius", value: "Indian/Mauritius" },
|
||||
{ label: "Mayotte", value: "Indian/Mayotte" },
|
||||
{ label: "Reunion", value: "Indian/Reunion" },
|
||||
],
|
||||
Pacific: [
|
||||
{ label: "Apia", value: "Pacific/Apia" },
|
||||
{ label: "Auckland", value: "Pacific/Auckland" },
|
||||
{ label: "Bougainville", value: "Pacific/Bougainville" },
|
||||
{ label: "Chatham", value: "Pacific/Chatham" },
|
||||
{ label: "Chuuk", value: "Pacific/Chuuk" },
|
||||
{ label: "Easter", value: "Pacific/Easter" },
|
||||
{ label: "Efate", value: "Pacific/Efate" },
|
||||
{ label: "Fakaofo", value: "Pacific/Fakaofo" },
|
||||
{ label: "Fiji", value: "Pacific/Fiji" },
|
||||
{ label: "Funafuti", value: "Pacific/Funafuti" },
|
||||
{ label: "Galapagos", value: "Pacific/Galapagos" },
|
||||
{ label: "Gambier", value: "Pacific/Gambier" },
|
||||
{ label: "Guadalcanal", value: "Pacific/Guadalcanal" },
|
||||
{ label: "Guam", value: "Pacific/Guam" },
|
||||
{ label: "Honolulu", value: "Pacific/Honolulu" },
|
||||
{ label: "Kanton", value: "Pacific/Kanton" },
|
||||
{ label: "Kiritimati", value: "Pacific/Kiritimati" },
|
||||
{ label: "Kosrae", value: "Pacific/Kosrae" },
|
||||
{ label: "Kwajalein", value: "Pacific/Kwajalein" },
|
||||
{ label: "Majuro", value: "Pacific/Majuro" },
|
||||
{ label: "Marquesas", value: "Pacific/Marquesas" },
|
||||
{ label: "Midway", value: "Pacific/Midway" },
|
||||
{ label: "Nauru", value: "Pacific/Nauru" },
|
||||
{ label: "Niue", value: "Pacific/Niue" },
|
||||
{ label: "Norfolk", value: "Pacific/Norfolk" },
|
||||
{ label: "Noumea", value: "Pacific/Noumea" },
|
||||
{ label: "Pago Pago", value: "Pacific/Pago_Pago" },
|
||||
{ label: "Palau", value: "Pacific/Palau" },
|
||||
{ label: "Pitcairn", value: "Pacific/Pitcairn" },
|
||||
{ label: "Pohnpei", value: "Pacific/Pohnpei" },
|
||||
{ label: "Port Moresby", value: "Pacific/Port_Moresby" },
|
||||
{ label: "Rarotonga", value: "Pacific/Rarotonga" },
|
||||
{ label: "Saipan", value: "Pacific/Saipan" },
|
||||
{ label: "Tahiti", value: "Pacific/Tahiti" },
|
||||
{ label: "Tarawa", value: "Pacific/Tarawa" },
|
||||
{ label: "Tongatapu", value: "Pacific/Tongatapu" },
|
||||
{ label: "Wake", value: "Pacific/Wake" },
|
||||
{ label: "Wallis", value: "Pacific/Wallis" },
|
||||
],
|
||||
};
|
||||
|
||||
// Helper to get display label for a timezone value
|
||||
export function getTimezoneLabel(value: string | undefined): string {
|
||||
if (!value) return "UTC (default)";
|
||||
return value;
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
DatabaseZap,
|
||||
Info,
|
||||
PenBoxIcon,
|
||||
PlusCircle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { DatabaseZap, PenBoxIcon, PlusCircle, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -47,13 +41,19 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import type { CacheType } from "../domains/handle-domain";
|
||||
import { commonCronExpressions } from "../schedules/handle-schedules";
|
||||
import { ScheduleFormField } from "../schedules/handle-schedules";
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
cronExpression: z.string().min(1, "Cron expression is required"),
|
||||
volumeName: z.string().min(1, "Volume name is required"),
|
||||
volumeName: z
|
||||
.string()
|
||||
.min(1, "Volume name is required")
|
||||
.regex(
|
||||
/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/,
|
||||
"Invalid volume name. Use letters, numbers, '._-' and start with a letter/number.",
|
||||
),
|
||||
prefix: z.string(),
|
||||
keepLatestCount: z.coerce
|
||||
.number()
|
||||
@@ -306,64 +306,9 @@ export const HandleVolumeBackups = ({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
<ScheduleFormField
|
||||
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>
|
||||
)}
|
||||
formControl={form.control}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Play,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -38,6 +39,7 @@ export const ShowVolumeBackups = ({
|
||||
type = "application",
|
||||
serverId,
|
||||
}: Props) => {
|
||||
const [runningBackups, setRunningBackups] = useState<Set<string>>(new Set());
|
||||
const {
|
||||
data: volumeBackups,
|
||||
isLoading: isLoadingVolumeBackups,
|
||||
@@ -51,34 +53,46 @@ export const ShowVolumeBackups = ({
|
||||
enabled: !!id,
|
||||
},
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } =
|
||||
api.volumeBackups.delete.useMutation();
|
||||
|
||||
const { mutateAsync: runManually, isLoading } =
|
||||
const { mutateAsync: runManually } =
|
||||
api.volumeBackups.runManually.useMutation();
|
||||
|
||||
const handleRunManually = async (volumeBackupId: string) => {
|
||||
setRunningBackups((prev) => new Set(prev).add(volumeBackupId));
|
||||
try {
|
||||
await runManually({ volumeBackupId });
|
||||
toast.success("Volume backup run successfully");
|
||||
await refetchVolumeBackups();
|
||||
} catch {
|
||||
toast.error("Error running volume backup");
|
||||
} finally {
|
||||
setRunningBackups((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(volumeBackupId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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 justify-between items-center flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||
Volume Backups
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Schedule volume backups to run automatically at specified
|
||||
intervals.
|
||||
intervals
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{volumeBackups && volumeBackups.length > 0 && (
|
||||
<>
|
||||
<HandleVolumeBackups id={id} volumeBackupType={type} />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<RestoreVolumeBackups
|
||||
id={id}
|
||||
@@ -93,7 +107,7 @@ export const ShowVolumeBackups = ({
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{isLoadingVolumeBackups ? (
|
||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||
<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 volume backups...
|
||||
@@ -113,13 +127,13 @@ export const ShowVolumeBackups = ({
|
||||
return (
|
||||
<div
|
||||
key={volumeBackup.volumeBackupId}
|
||||
className="flex items-center justify-between rounded-lg border p-3 transition-colors bg-muted/50"
|
||||
className="flex flex-col sm:flex-row sm:items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50 w-full"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-start gap-3 w-full sm:w-auto">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
||||
<DatabaseBackup className="size-4 text-primary/70" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="space-y-1.5 w-full sm:w-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium leading-none">
|
||||
{volumeBackup.name}
|
||||
@@ -143,18 +157,16 @@ export const ShowVolumeBackups = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5 mt-2 sm:mt-0 sm:ml-3">
|
||||
<ShowDeploymentsModal
|
||||
id={volumeBackup.volumeBackupId}
|
||||
type="volumeBackup"
|
||||
serverId={serverId || undefined}
|
||||
>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ClipboardList className="size-4 transition-colors " />
|
||||
<ClipboardList className="size-4 transition-colors" />
|
||||
</Button>
|
||||
</ShowDeploymentsModal>
|
||||
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -162,25 +174,18 @@ export const ShowVolumeBackups = ({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
isLoading={isLoading}
|
||||
onClick={async () => {
|
||||
toast.success("Volume backup run successfully");
|
||||
|
||||
await runManually({
|
||||
volumeBackupId: volumeBackup.volumeBackupId,
|
||||
})
|
||||
.then(async () => {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 1500),
|
||||
);
|
||||
refetchVolumeBackups();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error running volume backup");
|
||||
});
|
||||
}}
|
||||
disabled={runningBackups.has(
|
||||
volumeBackup.volumeBackupId,
|
||||
)}
|
||||
onClick={() =>
|
||||
handleRunManually(volumeBackup.volumeBackupId)
|
||||
}
|
||||
>
|
||||
<Play className="size-4 transition-colors" />
|
||||
{runningBackups.has(volumeBackup.volumeBackupId) ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="size-4 transition-colors" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -188,13 +193,11 @@ export const ShowVolumeBackups = ({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<HandleVolumeBackups
|
||||
volumeBackupId={volumeBackup.volumeBackupId}
|
||||
id={id}
|
||||
volumeBackupType={type}
|
||||
/>
|
||||
|
||||
<DialogAction
|
||||
title="Delete Volume Backup"
|
||||
description="Are you sure you want to delete this volume backup?"
|
||||
@@ -218,7 +221,7 @@ export const ShowVolumeBackups = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
@@ -230,7 +233,7 @@ export const ShowVolumeBackups = ({
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
|
||||
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
|
||||
<DatabaseBackup className="size-8 mb-4 text-muted-foreground" />
|
||||
<p className="text-lg font-medium text-muted-foreground">
|
||||
No volume backups
|
||||
|
||||
@@ -104,7 +104,7 @@ export const DeleteService = ({ id, type }: Props) => {
|
||||
push(
|
||||
`/dashboard/project/${result?.environment?.projectId}/environment/${result?.environment?.environmentId}`,
|
||||
);
|
||||
toast.success("deleted successfully");
|
||||
toast.success("Service deleted successfully");
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -195,6 +195,7 @@ export const ComposeActions = ({ composeId }: Props) => {
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
serverId={data?.serverId || ""}
|
||||
appType={data?.composeType || "docker-compose"}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -35,6 +35,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading } = api.compose.update.useMutation();
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
const form = useForm<AddComposeFile>({
|
||||
defaultValues: {
|
||||
@@ -53,6 +54,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.composeFile !== undefined) {
|
||||
setHasUnsavedChanges(composeFile !== data.composeFile);
|
||||
}
|
||||
}, [composeFile, data?.composeFile]);
|
||||
|
||||
const onSubmit = async (data: AddComposeFile) => {
|
||||
const { valid, error } = validateAndFormatYAML(data.composeFile);
|
||||
if (!valid) {
|
||||
@@ -67,10 +74,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
composeFile: data.composeFile,
|
||||
composePath: "./docker-compose.yml",
|
||||
sourceType: "raw",
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Compose config Updated");
|
||||
setHasUnsavedChanges(false);
|
||||
refetch();
|
||||
await utils.compose.getConvertedCompose.invalidate({
|
||||
composeId,
|
||||
@@ -99,6 +108,19 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-col gap-4 ">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Compose File</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure your Docker Compose file for this service.
|
||||
{hasUnsavedChanges && (
|
||||
<span className="text-yellow-500 ml-2">
|
||||
(You have unsaved changes)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-save-compose-file"
|
||||
|
||||
@@ -152,7 +152,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
toast.success("Service Provider Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -151,7 +151,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
triggerType: data.triggerType,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
toast.success("Service Provider Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -97,6 +97,16 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
const repository = form.watch("repository");
|
||||
const gitlabId = form.watch("gitlabId");
|
||||
|
||||
const gitlabUrl = useMemo(() => {
|
||||
const url = gitlabProviders?.find(
|
||||
(provider) => provider.gitlabId === gitlabId,
|
||||
)?.gitlabUrl;
|
||||
|
||||
const gitlabUrl = url?.replace(/\/$/, "");
|
||||
|
||||
return gitlabUrl || "https://gitlab.com";
|
||||
}, [gitlabId, gitlabProviders]);
|
||||
|
||||
const {
|
||||
data: repositories,
|
||||
isLoading: isLoadingRepositories,
|
||||
@@ -160,7 +170,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
toast.success("Service Provider Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -224,9 +234,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<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 && (
|
||||
{field.value.gitlabPathNamespace && (
|
||||
<Link
|
||||
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
|
||||
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
|
||||
@@ -37,8 +37,6 @@ interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
badgeStateColor;
|
||||
|
||||
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
const [option, setOption] = useState<"swarm" | "native">("native");
|
||||
const [containerId, setContainerId] = useState<string | undefined>();
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
DatabaseZap,
|
||||
Info,
|
||||
PenBoxIcon,
|
||||
PlusIcon,
|
||||
RefreshCw,
|
||||
@@ -62,7 +61,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { commonCronExpressions } from "../../application/schedules/handle-schedules";
|
||||
import { ScheduleFormField } from "../../application/schedules/handle-schedules";
|
||||
|
||||
type CacheType = "cache" | "fetch";
|
||||
|
||||
@@ -579,66 +578,9 @@ export const HandleBackup = ({
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScheduleFormField name="schedule" formControl={form.control} />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="prefix"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { debounce } from "lodash";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
@@ -236,7 +236,7 @@ export const RestoreBackup = ({
|
||||
const currentDatabaseType = form.watch("databaseType");
|
||||
const metadata = form.watch("metadata");
|
||||
|
||||
const debouncedSetSearch = debounce((value: string) => {
|
||||
const debouncedSetSearch = _.debounce((value: string) => {
|
||||
setDebouncedSearchTerm(value);
|
||||
}, 350);
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Download as DownloadIcon, Loader2, Pause, Play } from "lucide-react";
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Download as DownloadIcon,
|
||||
Loader2,
|
||||
Pause,
|
||||
Play,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -67,6 +75,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
const isPausedRef = useRef(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (autoScroll && scrollRef.current) {
|
||||
@@ -237,6 +246,29 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
const logContent = filteredLogs
|
||||
.map(
|
||||
({
|
||||
timestamp,
|
||||
message,
|
||||
}: {
|
||||
timestamp: Date | null;
|
||||
message: string;
|
||||
}) =>
|
||||
showTimestamp
|
||||
? `${timestamp?.toISOString() || "No timestamp"} ${message}`
|
||||
: message,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const success = copy(logContent);
|
||||
if (success) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilter = (logs: LogLine[]) => {
|
||||
return logs.filter((log) => {
|
||||
const logType = getLogType(log.message).type;
|
||||
@@ -320,6 +352,21 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
)}
|
||||
{isPaused ? "Resume" : "Pause"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={handleCopy}
|
||||
disabled={filteredLogs.length === 0}
|
||||
title="Copy logs to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FancyAnsi } from "fancy-ansi";
|
||||
import { escapeRegExp } from "lodash";
|
||||
import _ from "lodash";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -47,7 +47,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
||||
}
|
||||
|
||||
const htmlContent = fancyAnsi.toHtml(text);
|
||||
const searchRegex = new RegExp(`(${escapeRegExp(term)})`, "gi");
|
||||
const searchRegex = new RegExp(`(${_.escapeRegExp(term)})`, "gi");
|
||||
|
||||
const modifiedContent = htmlContent.replace(
|
||||
searchRegex,
|
||||
|
||||
@@ -49,7 +49,7 @@ export function parseLogs(logString: string): LogLine[] {
|
||||
// { timestamp: new Date("2024-12-10T10:00:00.000Z"),
|
||||
// message: "The server is running on port 8080" }
|
||||
const logRegex =
|
||||
/^(?:(\d+)\s+)?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC)?\s*(.*)$/;
|
||||
/^(?:(?<lineNumber>\d+)\s+)?(?<timestamp>(?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC))?\s*(?<message>[\s\S]*)$/;
|
||||
|
||||
return logString
|
||||
.split("\n")
|
||||
@@ -59,7 +59,7 @@ export function parseLogs(logString: string): LogLine[] {
|
||||
const match = line.match(logRegex);
|
||||
if (!match) return null;
|
||||
|
||||
const [, , timestamp, message] = match;
|
||||
const { timestamp, message } = match.groups ?? {};
|
||||
|
||||
if (!message?.trim()) return null;
|
||||
|
||||
@@ -108,7 +108,8 @@ export const getLogType = (message: string): LogStyle => {
|
||||
/(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) ||
|
||||
/(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) ||
|
||||
/\b(?:deprecated|obsolete)\b/i.test(lowerMessage) ||
|
||||
/\b(?:unstable|experimental)\b/i.test(lowerMessage)
|
||||
/\b(?:unstable|experimental)\b/i.test(lowerMessage) ||
|
||||
/⚠|⚠️/i.test(lowerMessage)
|
||||
) {
|
||||
return LOG_STYLES.warning;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
containerId: string;
|
||||
containerId?: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ export const DockerTerminal: React.FC<Props> = ({
|
||||
},
|
||||
});
|
||||
const addonFit = new FitAddon();
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`;
|
||||
@@ -57,7 +56,7 @@ export const DockerTerminal: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<span>
|
||||
Select way to connect to <b>{containerId}</b>
|
||||
</span>
|
||||
|
||||
@@ -103,7 +103,7 @@ export const ImpersonationBar = () => {
|
||||
setOpen(false);
|
||||
|
||||
toast.success("Successfully impersonating user", {
|
||||
description: `You are now viewing as ${selectedUser.name || selectedUser.email}`,
|
||||
description: `You are now viewing as ${`${selectedUser.name} ${selectedUser.lastName}`.trim() || selectedUser.email}`,
|
||||
});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
@@ -195,7 +195,8 @@ export const ImpersonationBar = () => {
|
||||
<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 || ""}
|
||||
{`${selectedUser.name} ${selectedUser.lastName}`.trim() ||
|
||||
""}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedUser.email}
|
||||
@@ -242,7 +243,8 @@ export const ImpersonationBar = () => {
|
||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium">
|
||||
{user.name || ""}
|
||||
{`${user.name} ${user.lastName}`.trim() ||
|
||||
""}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.email} • {user.role}
|
||||
@@ -281,11 +283,16 @@ export const ImpersonationBar = () => {
|
||||
<div className="flex items-center gap-4 flex-1 flex-wrap">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage
|
||||
className="object-cover"
|
||||
src={data?.user?.image || ""}
|
||||
alt={data?.user?.name || ""}
|
||||
alt={
|
||||
`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
|
||||
""
|
||||
}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{data?.user?.name?.slice(0, 2).toUpperCase() || "U"}
|
||||
{`${data?.user?.firstName?.[0] || ""}${data?.user?.lastName?.[0] || ""}`.toUpperCase() ||
|
||||
"U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -298,7 +305,8 @@ export const ImpersonationBar = () => {
|
||||
Impersonating
|
||||
</Badge>
|
||||
<span className="font-medium">
|
||||
{data?.user?.name || ""}
|
||||
{`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
|
||||
""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DockerNetworkChart } from "./docker-network-chart";
|
||||
|
||||
const defaultData = {
|
||||
cpu: {
|
||||
value: 0,
|
||||
value: "0%",
|
||||
time: "",
|
||||
},
|
||||
memory: {
|
||||
@@ -46,7 +46,7 @@ interface Props {
|
||||
}
|
||||
export interface DockerStats {
|
||||
cpu: {
|
||||
value: number;
|
||||
value: string;
|
||||
time: string;
|
||||
};
|
||||
memory: {
|
||||
@@ -220,7 +220,13 @@ export const ContainerFreeMonitoring = ({
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Used: {currentData.cpu.value}
|
||||
</span>
|
||||
<Progress value={currentData.cpu.value} className="w-[100%]" />
|
||||
<Progress
|
||||
value={Number.parseInt(
|
||||
currentData.cpu.value.replace("%", ""),
|
||||
10,
|
||||
)}
|
||||
className="w-[100%]"
|
||||
/>
|
||||
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -20,6 +21,13 @@ import type { ServiceType } from "../../application/advanced/show-resources";
|
||||
const addDockerImage = z.object({
|
||||
dockerImage: z.string().min(1, "Docker image is required"),
|
||||
command: z.string(),
|
||||
args: z
|
||||
.array(
|
||||
z.object({
|
||||
value: z.string().min(1, "Argument cannot be empty"),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface Props {
|
||||
@@ -61,18 +69,25 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
defaultValues: {
|
||||
dockerImage: "",
|
||||
command: "",
|
||||
args: [],
|
||||
},
|
||||
resolver: zodResolver(addDockerImage),
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "args",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
dockerImage: data.dockerImage,
|
||||
command: data.command || "",
|
||||
args: data.args?.map((arg) => ({ value: arg })) || [],
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (formData: AddDockerImage) => {
|
||||
await mutateAsync({
|
||||
@@ -83,6 +98,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
mariadbId: id || "",
|
||||
dockerImage: formData?.dockerImage,
|
||||
command: formData?.command,
|
||||
args: formData?.args?.map((arg) => arg.value).filter(Boolean),
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Custom Command Updated");
|
||||
@@ -128,13 +144,68 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Command</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Custom command" {...field} />
|
||||
<Input placeholder="/bin/sh" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Arguments (Args)</FormLabel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ value: "" })}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Argument
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No arguments added yet. Click "Add Argument" to add one.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{fields.map((field, index) => (
|
||||
<FormField
|
||||
key={field.id}
|
||||
control={form.control}
|
||||
name={`args.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
index === 0
|
||||
? "-c"
|
||||
: "redis-server --port 6379"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={form.formState.isSubmitting} type="submit">
|
||||
Save
|
||||
|
||||
@@ -150,8 +150,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
placeholder="Frontend"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value?.trim() || "";
|
||||
const serviceName = slugify(val);
|
||||
const val = e.target.value || "";
|
||||
const serviceName = slugify(val.trim());
|
||||
form.setValue("appName", `${slug}-${serviceName}`);
|
||||
field.onChange(val);
|
||||
}}
|
||||
|
||||
@@ -161,8 +161,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
placeholder="Frontend"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value?.trim() || "";
|
||||
const serviceName = slugify(val);
|
||||
const val = e.target.value || "";
|
||||
const serviceName = slugify(val.trim());
|
||||
form.setValue("appName", `${slug}-${serviceName}`);
|
||||
field.onChange(val);
|
||||
}}
|
||||
|
||||
@@ -55,7 +55,7 @@ import { api } from "@/utils/api";
|
||||
type DbType = typeof mySchema._type.type;
|
||||
|
||||
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
||||
mongo: "mongo:6",
|
||||
mongo: "mongo:7",
|
||||
mariadb: "mariadb:11",
|
||||
mysql: "mysql:8",
|
||||
postgres: "postgres:15",
|
||||
@@ -395,8 +395,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
placeholder="Name"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value?.trim() || "";
|
||||
const serviceName = slugify(val);
|
||||
const val = e.target.value || "";
|
||||
const serviceName = slugify(val.trim());
|
||||
form.setValue("appName", `${slug}-${serviceName}`);
|
||||
field.onChange(val);
|
||||
}}
|
||||
@@ -559,6 +559,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
type="password"
|
||||
placeholder="******************"
|
||||
autoComplete="one-time-code"
|
||||
enablePasswordGenerator={true}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -578,6 +579,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="******************"
|
||||
enablePasswordGenerator={true}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import type { findEnvironmentsByProjectId } from "@dokploy/server";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
Terminal,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { ChevronDownIcon, PencilIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -63,13 +56,20 @@ export const AdvancedEnvironmentSelector = ({
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// API mutations
|
||||
const { data: environment } = api.environment.one.useQuery(
|
||||
{ environmentId: currentEnvironmentId || "" },
|
||||
{
|
||||
enabled: !!currentEnvironmentId,
|
||||
},
|
||||
);
|
||||
// Get current user's permissions
|
||||
const { data: currentUser } = api.user.get.useQuery();
|
||||
|
||||
// Check if user can create environments
|
||||
const canCreateEnvironments =
|
||||
currentUser?.role === "owner" ||
|
||||
currentUser?.role === "admin" ||
|
||||
currentUser?.canCreateEnvironments === true;
|
||||
|
||||
// Check if user can delete environments
|
||||
const canDeleteEnvironments =
|
||||
currentUser?.role === "owner" ||
|
||||
currentUser?.role === "admin" ||
|
||||
currentUser?.canDeleteEnvironments === true;
|
||||
|
||||
const haveServices =
|
||||
selectedEnvironment &&
|
||||
@@ -102,7 +102,9 @@ export const AdvancedEnvironmentSelector = ({
|
||||
setName("");
|
||||
setDescription("");
|
||||
} catch (error) {
|
||||
toast.error("Failed to create environment");
|
||||
toast.error(
|
||||
`Failed to create environment: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -123,7 +125,9 @@ export const AdvancedEnvironmentSelector = ({
|
||||
setName("");
|
||||
setDescription("");
|
||||
} catch (error) {
|
||||
toast.error("Failed to update environment");
|
||||
toast.error(
|
||||
`Failed to update environment: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -140,15 +144,18 @@ export const AdvancedEnvironmentSelector = ({
|
||||
setIsDeleteDialogOpen(false);
|
||||
setSelectedEnvironment(null);
|
||||
|
||||
// Redirect to production if we deleted the current environment
|
||||
// Redirect to first available environment if we deleted the current environment
|
||||
if (selectedEnvironment.environmentId === currentEnvironmentId) {
|
||||
const productionEnv = environments?.find(
|
||||
(env) => env.name === "production",
|
||||
const firstEnv = environments?.find(
|
||||
(env) => env.environmentId !== selectedEnvironment.environmentId,
|
||||
);
|
||||
if (productionEnv) {
|
||||
if (firstEnv) {
|
||||
router.push(
|
||||
`/dashboard/project/${projectId}/environment/${productionEnv.environmentId}`,
|
||||
`/dashboard/project/${projectId}/environment/${firstEnv.environmentId}`,
|
||||
);
|
||||
} else {
|
||||
// No other environments, redirect to project page
|
||||
router.push(`/dashboard/project/${projectId}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -239,22 +246,8 @@ export const AdvancedEnvironmentSelector = ({
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Action buttons for non-production environments */}
|
||||
<EnvironmentVariables environmentId={environment.environmentId}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Terminal className="h-3 w-3" />
|
||||
</Button>
|
||||
</EnvironmentVariables>
|
||||
{environment.name !== "production" && (
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
{!environment.isDefault && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -266,7 +259,8 @@ export const AdvancedEnvironmentSelector = ({
|
||||
>
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
)}
|
||||
{canDeleteEnvironments && !environment.isDefault && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -278,20 +272,22 @@ export const AdvancedEnvironmentSelector = ({
|
||||
>
|
||||
<TrashIcon className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Create Environment
|
||||
</DropdownMenuItem>
|
||||
{canCreateEnvironments && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsCreateDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Create Environment
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
|
||||
@@ -82,6 +82,21 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
|
||||
.finally(() => {});
|
||||
};
|
||||
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [form, onSubmit, isLoading, isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
|
||||
@@ -81,6 +81,21 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
|
||||
.finally(() => {});
|
||||
};
|
||||
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [form, onSubmit, isLoading, isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -44,7 +46,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -52,16 +53,25 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TimeBadge } from "@/components/ui/time-badge";
|
||||
import { api } from "@/utils/api";
|
||||
import { useDebounce } from "@/utils/hooks/use-debounce";
|
||||
import { HandleProject } from "./handle-project";
|
||||
import { ProjectEnvironment } from "./project-environment";
|
||||
|
||||
export const ShowProjects = () => {
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data, isLoading } = api.project.all.useQuery();
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { mutateAsync } = api.project.remove.useMutation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(
|
||||
router.isReady && typeof router.query.q === "string" ? router.query.q : "",
|
||||
);
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 500);
|
||||
|
||||
const [sortBy, setSortBy] = useState<string>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("projectsSort") || "createdAt-desc";
|
||||
@@ -73,14 +83,41 @@ export const ShowProjects = () => {
|
||||
localStorage.setItem("projectsSort", sortBy);
|
||||
}, [sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
|
||||
if (urlQuery !== searchQuery) {
|
||||
setSearchQuery(urlQuery);
|
||||
}
|
||||
}, [router.isReady, router.query.q]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
|
||||
if (debouncedSearchQuery === urlQuery) return;
|
||||
|
||||
const newQuery = { ...router.query };
|
||||
if (debouncedSearchQuery) {
|
||||
newQuery.q = debouncedSearchQuery;
|
||||
} else {
|
||||
delete newQuery.q;
|
||||
}
|
||||
router.replace({ pathname: router.pathname, query: newQuery }, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
}, [debouncedSearchQuery]);
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!data) return [];
|
||||
|
||||
// First filter by search query
|
||||
const filtered = data.filter(
|
||||
(project) =>
|
||||
project.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
project.description?.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
project.name
|
||||
.toLowerCase()
|
||||
.includes(debouncedSearchQuery.toLowerCase()) ||
|
||||
project.description
|
||||
?.toLowerCase()
|
||||
.includes(debouncedSearchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
// Then sort the filtered results
|
||||
@@ -96,8 +133,30 @@ export const ShowProjects = () => {
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
break;
|
||||
case "services": {
|
||||
const aTotalServices = a.environments.length;
|
||||
const bTotalServices = b.environments.length;
|
||||
const aTotalServices = a.environments.reduce((total, env) => {
|
||||
return (
|
||||
total +
|
||||
(env.applications?.length || 0) +
|
||||
(env.mariadb?.length || 0) +
|
||||
(env.mongo?.length || 0) +
|
||||
(env.mysql?.length || 0) +
|
||||
(env.postgres?.length || 0) +
|
||||
(env.redis?.length || 0) +
|
||||
(env.compose?.length || 0)
|
||||
);
|
||||
}, 0);
|
||||
const bTotalServices = b.environments.reduce((total, env) => {
|
||||
return (
|
||||
total +
|
||||
(env.applications?.length || 0) +
|
||||
(env.mariadb?.length || 0) +
|
||||
(env.mongo?.length || 0) +
|
||||
(env.mysql?.length || 0) +
|
||||
(env.postgres?.length || 0) +
|
||||
(env.redis?.length || 0) +
|
||||
(env.compose?.length || 0)
|
||||
);
|
||||
}, 0);
|
||||
comparison = aTotalServices - bTotalServices;
|
||||
break;
|
||||
}
|
||||
@@ -106,13 +165,18 @@ export const ShowProjects = () => {
|
||||
}
|
||||
return direction === "asc" ? comparison : -comparison;
|
||||
});
|
||||
}, [data, searchQuery, sortBy]);
|
||||
}, [data, debouncedSearchQuery, sortBy]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BreadcrumbSidebar
|
||||
list={[{ name: "Projects", href: "/dashboard/projects" }]}
|
||||
/>
|
||||
{!isCloud && (
|
||||
<div className="absolute top-4 right-4">
|
||||
<TimeBadge />
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl ">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
@@ -126,8 +190,9 @@ export const ShowProjects = () => {
|
||||
Create and manage your projects
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{(auth?.role === "owner" || auth?.canCreateProjects) && (
|
||||
{(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canCreateProjects) && (
|
||||
<div className="">
|
||||
<HandleProject />
|
||||
</div>
|
||||
@@ -223,13 +288,17 @@ export const ShowProjects = () => {
|
||||
)
|
||||
.some(Boolean);
|
||||
|
||||
const productionEnvironment = project?.environments.find(
|
||||
(env) => env.isDefault,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.projectId}
|
||||
className="w-full lg:max-w-md"
|
||||
>
|
||||
<Link
|
||||
href={`/dashboard/project/${project.projectId}/environment/${project?.environments?.[0]?.environmentId}`}
|
||||
href={`/dashboard/project/${project.projectId}/environment/${productionEnvironment?.environmentId}`}
|
||||
>
|
||||
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
|
||||
{haveServicesWithDomains ? (
|
||||
@@ -276,7 +345,13 @@ export const ShowProjects = () => {
|
||||
<Link
|
||||
className="space-x-4 text-xs cursor-pointer justify-between"
|
||||
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}
|
||||
@@ -318,7 +393,13 @@ export const ShowProjects = () => {
|
||||
<Link
|
||||
className="space-x-4 text-xs cursor-pointer justify-between"
|
||||
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}
|
||||
|
||||
@@ -49,51 +49,65 @@ export const RequestDistributionChart = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={stats || []}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="hour"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) =>
|
||||
new Date(value).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="line" />}
|
||||
labelFormatter={(value) =>
|
||||
new Date(value).toLocaleString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="count"
|
||||
type="natural"
|
||||
fill="hsl(var(--chart-1))"
|
||||
fillOpacity={0.4}
|
||||
stroke="hsl(var(--chart-1))"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</ResponsiveContainer>
|
||||
<div className="w-full h-[200px] overflow-hidden">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<ChartContainer config={chartConfig}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={stats || []}
|
||||
margin={{
|
||||
top: 10,
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="hour"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) =>
|
||||
new Date(value).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
allowDataOverflow={false}
|
||||
domain={[0, "auto"]}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={<ChartTooltipContent indicator="line" />}
|
||||
labelFormatter={(value) =>
|
||||
new Date(value).toLocaleString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="count"
|
||||
type="monotone"
|
||||
fill="hsl(var(--chart-1))"
|
||||
fillOpacity={0.4}
|
||||
stroke="hsl(var(--chart-1))"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -51,13 +51,38 @@ export const ShowRequests = () => {
|
||||
const { mutateAsync: updateLogCleanup } =
|
||||
api.settings.updateLogCleanup.useMutation();
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null);
|
||||
|
||||
// Set default date range to last 3 days
|
||||
const getDefaultDateRange = () => {
|
||||
const to = new Date();
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - 3);
|
||||
return { from, to };
|
||||
};
|
||||
|
||||
const [dateRange, setDateRange] = useState<{
|
||||
from: Date | undefined;
|
||||
to: Date | undefined;
|
||||
}>({
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
});
|
||||
}>(getDefaultDateRange());
|
||||
|
||||
// Check if logs exist to determine if traefik has been reloaded
|
||||
// Only fetch when active to minimize network calls
|
||||
const { data: statsLogsCheck } = api.settings.readStatsLogs.useQuery(
|
||||
{
|
||||
page: {
|
||||
pageIndex: 0,
|
||||
pageSize: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!isActive,
|
||||
refetchInterval: 5000, // Check every 5 seconds when active
|
||||
},
|
||||
);
|
||||
|
||||
// Determine if warning should be shown
|
||||
// Show warning only if active but no logs exist yet
|
||||
const shouldShowWarning = isActive && (statsLogsCheck?.totalCount ?? 0) === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (logCleanupStatus) {
|
||||
@@ -79,16 +104,18 @@ export const ShowRequests = () => {
|
||||
See all the incoming requests that pass trough Traefik
|
||||
</CardDescription>
|
||||
|
||||
<AlertBlock type="warning">
|
||||
When you activate, you need to reload traefik to apply the
|
||||
changes, you can reload traefik in{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
</AlertBlock>
|
||||
{shouldShowWarning && (
|
||||
<AlertBlock type="warning">
|
||||
When you activate, you need to reload traefik to apply the
|
||||
changes, you can reload traefik in{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
</AlertBlock>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
<div className="flex w-full gap-4 justify-end items-center">
|
||||
@@ -169,17 +196,13 @@ export const ShowRequests = () => {
|
||||
{isActive ? (
|
||||
<>
|
||||
<div className="flex justify-end mb-4 gap-2">
|
||||
{(dateRange.from || dateRange.to) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setDateRange({ from: undefined, to: undefined })
|
||||
}
|
||||
className="px-3"
|
||||
>
|
||||
Clear dates
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDateRange(getDefaultDateRange())}
|
||||
className="px-3"
|
||||
>
|
||||
Reset to Last 3 Days
|
||||
</Button>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -89,24 +89,26 @@ export const SearchCommand = () => {
|
||||
<CommandGroup heading={"Projects"}>
|
||||
<CommandList>
|
||||
{data?.map((project) => {
|
||||
const productionEnvironment = project.environments.find(
|
||||
(environment) => environment.name === "production",
|
||||
);
|
||||
// Find default environment, or fall back to first environment
|
||||
const defaultEnvironment =
|
||||
project.environments.find(
|
||||
(environment) => environment.isDefault,
|
||||
) || project?.environments?.[0];
|
||||
|
||||
if (!productionEnvironment) return null;
|
||||
if (!defaultEnvironment) return null;
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={project.projectId}
|
||||
onSelect={() => {
|
||||
router.push(
|
||||
`/dashboard/project/${project.projectId}/environment/${productionEnvironment!.environmentId}`,
|
||||
`/dashboard/project/${project.projectId}/environment/${defaultEnvironment.environmentId}`,
|
||||
);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<BookIcon className="size-4 text-muted-foreground mr-2" />
|
||||
{project.name} / {productionEnvironment!.name}
|
||||
{project.name} / {defaultEnvironment.name}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { CreditCard, FileText } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ShowInvoices } from "./show-invoices";
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
name: "Subscription",
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
export const ShowBillingInvoices = () => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your subscription and invoices
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 py-4 border-t">
|
||||
<nav className="flex space-x-2 border-b">
|
||||
{navigationItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = router.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||
isActive
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-6">
|
||||
<ShowInvoices />
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
AlertTriangle,
|
||||
CheckIcon,
|
||||
CreditCard,
|
||||
FileText,
|
||||
Loader2,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -37,7 +39,22 @@ export const calculatePrice = (count: number, isAnnual = false) => {
|
||||
if (count <= 1) return 4.5;
|
||||
return count * 3.5;
|
||||
};
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
name: "Subscription",
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
export const ShowBilling = () => {
|
||||
const router = useRouter();
|
||||
const { data: servers } = api.server.count.useQuery();
|
||||
const { data: admin } = api.user.get.useQuery();
|
||||
const { data, isLoading } = api.stripe.getProducts.useQuery();
|
||||
@@ -76,17 +93,41 @@ export const ShowBilling = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>Manage your subscription</CardDescription>
|
||||
<CardDescription>
|
||||
Manage your subscription and invoices
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<CardContent className="space-y-4 py-4 border-t">
|
||||
<nav className="flex space-x-2 border-b">
|
||||
{navigationItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = router.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||
isActive
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full mt-6">
|
||||
<Tabs
|
||||
defaultValue="monthly"
|
||||
value={isAnnual ? "annual" : "monthly"}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Download, ExternalLink, FileText, Loader2 } from "lucide-react";
|
||||
import type Stripe from "stripe";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const formatDate = (timestamp: number | null) => {
|
||||
if (!timestamp) return "-";
|
||||
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatAmount = (amount: number, currency: string) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency.toUpperCase(),
|
||||
}).format(amount / 100);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: Stripe.Invoice.Status | null) => {
|
||||
const statusConfig: Record<
|
||||
Stripe.Invoice.Status,
|
||||
{ label: string; variant: "default" | "secondary" | "destructive" }
|
||||
> = {
|
||||
paid: { label: "Paid", variant: "default" },
|
||||
open: { label: "Open", variant: "secondary" },
|
||||
draft: { label: "Draft", variant: "secondary" },
|
||||
void: { label: "Void", variant: "destructive" },
|
||||
uncollectible: { label: "Uncollectible", variant: "destructive" },
|
||||
};
|
||||
|
||||
if (!status) {
|
||||
return <Badge variant="secondary">Unknown</Badge>;
|
||||
}
|
||||
|
||||
const config = statusConfig[status] || {
|
||||
label: status,
|
||||
variant: "secondary" as const,
|
||||
};
|
||||
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
export const ShowInvoices = () => {
|
||||
const { data: invoices, isLoading } = api.stripe.getInvoices.useQuery();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center min-h-[20vh]">
|
||||
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center">
|
||||
Loading invoices...
|
||||
<Loader2 className="animate-spin" />
|
||||
</span>
|
||||
</div>
|
||||
) : invoices && invoices.length > 0 ? (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Invoice</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Due Date</TableHead>
|
||||
<TableHead>Amount</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invoices.map((invoice) => (
|
||||
<TableRow key={invoice.id}>
|
||||
<TableCell className="font-medium">
|
||||
{invoice.number || invoice.id.slice(0, 12)}
|
||||
</TableCell>
|
||||
<TableCell>{formatDate(invoice.created)}</TableCell>
|
||||
<TableCell>{formatDate(invoice.dueDate)}</TableCell>
|
||||
<TableCell>
|
||||
{formatAmount(invoice.amountDue, invoice.currency)}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(invoice.status)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{invoice.hostedInvoiceUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
invoice.hostedInvoiceUrl || "",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{invoice.invoicePdf && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
window.open(invoice.invoicePdf || "", "_blank")
|
||||
}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center min-h-[20vh] gap-2">
|
||||
<FileText className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No invoices found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your invoices will appear here once you have a subscription
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -42,12 +42,38 @@ const AddRegistrySchema = z.object({
|
||||
username: z.string().min(1, {
|
||||
message: "Username is required",
|
||||
}),
|
||||
password: z.string().min(1, {
|
||||
message: "Password is required",
|
||||
}),
|
||||
registryUrl: z.string(),
|
||||
password: z.string(),
|
||||
registryUrl: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => {
|
||||
// If empty or undefined, skip validation (field is optional)
|
||||
if (!val || val.trim().length === 0) {
|
||||
return true;
|
||||
}
|
||||
// Validate that it's a valid hostname (no protocol, no path, optional port)
|
||||
// Valid formats: example.com, registry.example.com, [::1], example.com:5000
|
||||
// Invalid: https://example.com, example.com/path
|
||||
const trimmed = val.trim();
|
||||
// Check for protocol or path - these are not allowed
|
||||
if (/^https?:\/\//i.test(trimmed) || trimmed.includes("/")) {
|
||||
return false;
|
||||
}
|
||||
// Basic hostname validation: allow alphanumeric, dots, hyphens, underscores, and IPv6 in brackets
|
||||
// Allow optional port at the end
|
||||
const hostnameRegex =
|
||||
/^(?:\[[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,253}[a-zA-Z0-9])?)(?::\d+)?$/;
|
||||
return hostnameRegex.test(trimmed);
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Invalid registry URL. Please enter only the hostname (e.g., example.com or registry.example.com). Do not include protocol (https://) or paths.",
|
||||
},
|
||||
),
|
||||
imagePrefix: z.string(),
|
||||
serverId: z.string().optional(),
|
||||
isEditing: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type AddRegistry = z.infer<typeof AddRegistrySchema>;
|
||||
@@ -74,13 +100,21 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
const { mutateAsync, error, isError } = registryId
|
||||
? api.registry.update.useMutation()
|
||||
: api.registry.create.useMutation();
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
const { data: deployServers } = api.server.withSSHKey.useQuery();
|
||||
const { data: buildServers } = api.server.buildServers.useQuery();
|
||||
const servers = [...(deployServers || []), ...(buildServers || [])];
|
||||
const {
|
||||
mutateAsync: testRegistry,
|
||||
isLoading,
|
||||
error: testRegistryError,
|
||||
isError: testRegistryIsError,
|
||||
} = api.registry.testRegistry.useMutation();
|
||||
const {
|
||||
mutateAsync: testRegistryById,
|
||||
isLoading: isLoadingById,
|
||||
error: testRegistryByIdError,
|
||||
isError: testRegistryByIdIsError,
|
||||
} = api.registry.testRegistryById.useMutation();
|
||||
const form = useForm<AddRegistry>({
|
||||
defaultValues: {
|
||||
username: "",
|
||||
@@ -89,8 +123,26 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
imagePrefix: "",
|
||||
registryName: "",
|
||||
serverId: "",
|
||||
isEditing: !!registryId,
|
||||
},
|
||||
resolver: zodResolver(AddRegistrySchema),
|
||||
resolver: zodResolver(
|
||||
AddRegistrySchema.refine(
|
||||
(data) => {
|
||||
// When creating a new registry, password is required
|
||||
if (
|
||||
!data.isEditing &&
|
||||
(!data.password || data.password.length === 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Password is required",
|
||||
path: ["password"],
|
||||
},
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
const password = form.watch("password");
|
||||
@@ -99,6 +151,9 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
const registryName = form.watch("registryName");
|
||||
const imagePrefix = form.watch("imagePrefix");
|
||||
const serverId = form.watch("serverId");
|
||||
const selectedServer = servers?.find(
|
||||
(server) => server.serverId === serverId,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (registry) {
|
||||
@@ -108,6 +163,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
registryUrl: registry.registryUrl,
|
||||
imagePrefix: registry.imagePrefix || "",
|
||||
registryName: registry.registryName,
|
||||
isEditing: true,
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
@@ -116,21 +172,29 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
registryUrl: "",
|
||||
imagePrefix: "",
|
||||
serverId: "",
|
||||
isEditing: false,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, registry]);
|
||||
|
||||
const onSubmit = async (data: AddRegistry) => {
|
||||
await mutateAsync({
|
||||
password: data.password,
|
||||
const payload: any = {
|
||||
registryName: data.registryName,
|
||||
username: data.username,
|
||||
registryUrl: data.registryUrl,
|
||||
registryUrl: data.registryUrl || "",
|
||||
registryType: "cloud",
|
||||
imagePrefix: data.imagePrefix,
|
||||
serverId: data.serverId,
|
||||
registryId: registryId || "",
|
||||
})
|
||||
};
|
||||
|
||||
// Only include password if it's been provided (not empty)
|
||||
// When editing, empty password means "keep the existing password"
|
||||
if (data.password && data.password.length > 0) {
|
||||
payload.password = data.password;
|
||||
}
|
||||
|
||||
await mutateAsync(payload)
|
||||
.then(async (_data) => {
|
||||
await utils.registry.all.invalidate();
|
||||
toast.success(registryId ? "Registry updated" : "Registry added");
|
||||
@@ -168,11 +232,14 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
Fill the next fields to add a external registry.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{(isError || testRegistryIsError) && (
|
||||
{(isError || testRegistryIsError || testRegistryByIdIsError) && (
|
||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||
<span className="text-sm text-red-600 dark:text-red-400">
|
||||
{testRegistryError?.message || error?.message || ""}
|
||||
{testRegistryError?.message ||
|
||||
testRegistryByIdError?.message ||
|
||||
error?.message ||
|
||||
""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -223,10 +290,20 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel>Password{registryId && " (Optional)"}</FormLabel>
|
||||
{registryId && (
|
||||
<FormDescription>
|
||||
Leave blank to keep existing password. Enter new
|
||||
password to test or update it.
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Password"
|
||||
placeholder={
|
||||
registryId
|
||||
? "Leave blank to keep existing"
|
||||
: "Password"
|
||||
}
|
||||
autoComplete="one-time-code"
|
||||
{...field}
|
||||
type="password"
|
||||
@@ -261,6 +338,10 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Registry URL</FormLabel>
|
||||
<FormDescription>
|
||||
Enter only the hostname (e.g.,
|
||||
aws_account_id.dkr.ecr.us-west-2.amazonaws.com).
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="aws_account_id.dkr.ecr.us-west-2.amazonaws.com"
|
||||
@@ -282,8 +363,40 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Server {!isCloud && "(Optional)"}</FormLabel>
|
||||
<FormDescription>
|
||||
Select a server to test the registry. this will run the
|
||||
following command on the server
|
||||
{!isCloud ? (
|
||||
<>
|
||||
{serverId && serverId !== "none" && selectedServer ? (
|
||||
<>
|
||||
Authentication will be performed on{" "}
|
||||
<strong>{selectedServer.name}</strong>. This
|
||||
registry will be available on this server.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Choose where to authenticate with the registry. By
|
||||
default, authentication occurs on the Dokploy
|
||||
server. Select a specific server to authenticate
|
||||
from that server instead.
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{serverId && serverId !== "none" && selectedServer ? (
|
||||
<>
|
||||
Authentication will be performed on{" "}
|
||||
<strong>{selectedServer.name}</strong>. This
|
||||
registry will be available on this server.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Select a server to authenticate with the registry.
|
||||
The authentication will be performed from the
|
||||
selected server.
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Select
|
||||
@@ -294,16 +407,33 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{deployServers && deployServers.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Deploy Servers</SelectLabel>
|
||||
{deployServers.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{buildServers && buildServers.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Build Servers</SelectLabel>
|
||||
{buildServers.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
<SelectGroup>
|
||||
<SelectLabel>Servers</SelectLabel>
|
||||
{servers?.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={"none"}>None</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
@@ -321,8 +451,37 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
<Button
|
||||
type="button"
|
||||
variant={"secondary"}
|
||||
isLoading={isLoading}
|
||||
isLoading={isLoading || isLoadingById}
|
||||
onClick={async () => {
|
||||
// When editing with empty password, use the existing password from DB
|
||||
if (registryId && (!password || password.length === 0)) {
|
||||
await testRegistryById({
|
||||
registryId: registryId || "",
|
||||
...(serverId && { serverId }),
|
||||
})
|
||||
.then((data) => {
|
||||
if (data) {
|
||||
toast.success("Registry Tested Successfully");
|
||||
} else {
|
||||
toast.error("Registry Test Failed");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error testing the registry");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// When creating, password is required
|
||||
if (!registryId && (!password || password.length === 0)) {
|
||||
form.setError("password", {
|
||||
type: "manual",
|
||||
message: "Password is required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// When creating or editing with new password, validate and test with provided credentials
|
||||
const validationResult = AddRegistrySchema.safeParse({
|
||||
username,
|
||||
password,
|
||||
@@ -330,6 +489,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
registryName: "Dokploy Registry",
|
||||
imagePrefix,
|
||||
serverId,
|
||||
isEditing: !!registryId,
|
||||
});
|
||||
|
||||
if (!validationResult.success) {
|
||||
@@ -345,7 +505,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
||||
await testRegistry({
|
||||
username: username,
|
||||
password: password,
|
||||
registryUrl: registryUrl,
|
||||
registryUrl: registryUrl || "",
|
||||
registryName: registryName,
|
||||
registryType: "cloud",
|
||||
imagePrefix: imagePrefix,
|
||||
|
||||
@@ -122,6 +122,9 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
||||
.then(async () => {
|
||||
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
|
||||
await utils.destination.all.invalidate();
|
||||
if (destinationId) {
|
||||
await utils.destination.one.invalidate({ destinationId });
|
||||
}
|
||||
setOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -217,7 +220,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{(isError || isErrorConnection) && (
|
||||
<AlertBlock type="error" className="break-words">
|
||||
<AlertBlock type="error" className="w-full">
|
||||
{connectionError?.message || error?.message}
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
@@ -48,7 +48,7 @@ export const ShowGitProviders = () => {
|
||||
) => {
|
||||
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
|
||||
const scope = "api read_user read_repository";
|
||||
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
|
||||
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scopes=${encodeURIComponent(scope)}`;
|
||||
return authUrl;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
"use client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { Check, ChevronDown, PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -26,13 +33,12 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const Schema = z.object({
|
||||
@@ -53,6 +59,8 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [modelPopoverOpen, setModelPopoverOpen] = useState(false);
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
const { data, refetch } = api.ai.one.useQuery(
|
||||
{
|
||||
aiId: aiId || "",
|
||||
@@ -77,13 +85,17 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
name: data?.name ?? "",
|
||||
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
|
||||
apiKey: data?.apiKey ?? "",
|
||||
model: data?.model ?? "",
|
||||
isEnabled: data?.isEnabled ?? true,
|
||||
});
|
||||
if (data) {
|
||||
form.reset({
|
||||
name: data?.name ?? "",
|
||||
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
|
||||
apiKey: data?.apiKey ?? "",
|
||||
model: data?.model ?? "",
|
||||
isEnabled: data?.isEnabled ?? true,
|
||||
});
|
||||
}
|
||||
setModelSearch("");
|
||||
setModelPopoverOpen(false);
|
||||
}, [aiId, form, data]);
|
||||
|
||||
const apiUrl = form.watch("apiUrl");
|
||||
@@ -104,14 +116,6 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const apiUrl = form.watch("apiUrl");
|
||||
const apiKey = form.watch("apiKey");
|
||||
if (apiUrl && apiKey) {
|
||||
form.setValue("model", "");
|
||||
}
|
||||
}, [form.watch("apiUrl"), form.watch("apiKey")]);
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
try {
|
||||
await mutateAsync({
|
||||
@@ -131,7 +135,16 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setModelSearch("");
|
||||
setModelPopoverOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger className="" asChild>
|
||||
{aiId ? (
|
||||
<Button
|
||||
@@ -182,7 +195,17 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>API URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://api.openai.com/v1" {...field} />
|
||||
<Input
|
||||
placeholder="https://api.openai.com/v1"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
// Reset model when user changes API URL
|
||||
if (form.getValues("model")) {
|
||||
form.setValue("model", "");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The base URL for your AI provider's API
|
||||
@@ -205,6 +228,13 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
placeholder="sk-..."
|
||||
autoComplete="one-time-code"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
// Reset model when user changes API Key
|
||||
if (form.getValues("model")) {
|
||||
form.setValue("model", "");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
@@ -232,30 +262,89 @@ export const HandleAi = ({ aiId }: Props) => {
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Model</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>Select an AI model to use</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
render={({ field }) => {
|
||||
const selectedModel = models.find(
|
||||
(m) => m.id === field.value,
|
||||
);
|
||||
const filteredModels = models.filter((model) =>
|
||||
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
|
||||
);
|
||||
|
||||
// Ensure selected model is always in the filtered list
|
||||
const displayModels =
|
||||
field.value &&
|
||||
!filteredModels.find((m) => m.id === field.value) &&
|
||||
selectedModel
|
||||
? [selectedModel, ...filteredModels]
|
||||
: filteredModels;
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Model</FormLabel>
|
||||
<Popover
|
||||
open={modelPopoverOpen}
|
||||
onOpenChange={setModelPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? (selectedModel?.id ?? field.value)
|
||||
: "Select a model"}
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search models..."
|
||||
value={modelSearch}
|
||||
onValueChange={setModelSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No models found.</CommandEmpty>
|
||||
{displayModels.map((model) => {
|
||||
const isSelected = field.value === model.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
onSelect={() => {
|
||||
field.onChange(model.id);
|
||||
setModelPopoverOpen(false);
|
||||
setModelSearch("");
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
isSelected
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{model.id}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
Select an AI model to use
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Mail,
|
||||
MessageCircleMore,
|
||||
PenBoxIcon,
|
||||
PlusIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
@@ -12,6 +12,9 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
DiscordIcon,
|
||||
GotifyIcon,
|
||||
LarkIcon,
|
||||
NtfyIcon,
|
||||
SlackIcon,
|
||||
TelegramIcon,
|
||||
} from "@/components/icons/notification-icons";
|
||||
@@ -47,6 +50,7 @@ const notificationBaseSchema = z.object({
|
||||
appDeploy: z.boolean().default(false),
|
||||
appBuildError: z.boolean().default(false),
|
||||
databaseBackup: z.boolean().default(false),
|
||||
volumeBackup: z.boolean().default(false),
|
||||
dokployRestart: z.boolean().default(false),
|
||||
dockerCleanup: z.boolean().default(false),
|
||||
serverThreshold: z.boolean().default(false),
|
||||
@@ -106,10 +110,31 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
||||
type: z.literal("ntfy"),
|
||||
serverUrl: z.string().min(1, { message: "Server URL is required" }),
|
||||
topic: z.string().min(1, { message: "Topic is required" }),
|
||||
accessToken: z.string().min(1, { message: "Access Token is required" }),
|
||||
accessToken: z.string().optional(),
|
||||
priority: z.number().min(1).max(5).default(3),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("custom"),
|
||||
endpoint: z.string().min(1, { message: "Endpoint URL is required" }),
|
||||
headers: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("lark"),
|
||||
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
|
||||
})
|
||||
.merge(notificationBaseSchema),
|
||||
]);
|
||||
|
||||
export const notificationsMap = {
|
||||
@@ -125,18 +150,26 @@ export const notificationsMap = {
|
||||
icon: <DiscordIcon />,
|
||||
label: "Discord",
|
||||
},
|
||||
lark: {
|
||||
icon: <LarkIcon className="text-muted-foreground" />,
|
||||
label: "Lark",
|
||||
},
|
||||
email: {
|
||||
icon: <Mail size={29} className="text-muted-foreground" />,
|
||||
label: "Email",
|
||||
},
|
||||
gotify: {
|
||||
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
|
||||
icon: <GotifyIcon />,
|
||||
label: "Gotify",
|
||||
},
|
||||
ntfy: {
|
||||
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
|
||||
icon: <NtfyIcon />,
|
||||
label: "ntfy",
|
||||
},
|
||||
custom: {
|
||||
icon: <PenBoxIcon size={29} className="text-muted-foreground" />,
|
||||
label: "Custom",
|
||||
},
|
||||
};
|
||||
|
||||
export type NotificationSchema = z.infer<typeof notificationSchema>;
|
||||
@@ -170,6 +203,15 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
api.notification.testGotifyConnection.useMutation();
|
||||
const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } =
|
||||
api.notification.testNtfyConnection.useMutation();
|
||||
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
|
||||
api.notification.testLarkConnection.useMutation();
|
||||
|
||||
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
|
||||
api.notification.testCustomConnection.useMutation();
|
||||
|
||||
const customMutation = notificationId
|
||||
? api.notification.updateCustom.useMutation()
|
||||
: api.notification.createCustom.useMutation();
|
||||
const slackMutation = notificationId
|
||||
? api.notification.updateSlack.useMutation()
|
||||
: api.notification.createSlack.useMutation();
|
||||
@@ -188,6 +230,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
const ntfyMutation = notificationId
|
||||
? api.notification.updateNtfy.useMutation()
|
||||
: api.notification.createNtfy.useMutation();
|
||||
const larkMutation = notificationId
|
||||
? api.notification.updateLark.useMutation()
|
||||
: api.notification.createLark.useMutation();
|
||||
|
||||
const form = useForm<NotificationSchema>({
|
||||
defaultValues: {
|
||||
@@ -205,11 +250,20 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
name: "toAddresses" as never,
|
||||
});
|
||||
|
||||
const {
|
||||
fields: headerFields,
|
||||
append: appendHeader,
|
||||
remove: removeHeader,
|
||||
} = useFieldArray({
|
||||
control: form.control,
|
||||
name: "headers" as never,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (type === "email") {
|
||||
if (type === "email" && fields.length === 0) {
|
||||
append("");
|
||||
}
|
||||
}, [type, append]);
|
||||
}, [type, append, fields.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (notification) {
|
||||
@@ -219,6 +273,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
webhookUrl: notification.slack?.webhookUrl,
|
||||
channel: notification.slack?.channel || "",
|
||||
@@ -232,6 +287,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
botToken: notification.telegram?.botToken,
|
||||
messageThreadId: notification.telegram?.messageThreadId || "",
|
||||
chatId: notification.telegram?.chatId,
|
||||
@@ -246,6 +302,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
webhookUrl: notification.discord?.webhookUrl,
|
||||
decoration: notification.discord?.decoration || undefined,
|
||||
@@ -259,6 +316,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
smtpServer: notification.email?.smtpServer,
|
||||
smtpPort: notification.email?.smtpPort,
|
||||
@@ -276,6 +334,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
appToken: notification.gotify?.appToken,
|
||||
decoration: notification.gotify?.decoration || undefined,
|
||||
@@ -290,13 +349,49 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
type: notification.notificationType,
|
||||
accessToken: notification.ntfy?.accessToken,
|
||||
accessToken: notification.ntfy?.accessToken || "",
|
||||
topic: notification.ntfy?.topic,
|
||||
priority: notification.ntfy?.priority,
|
||||
serverUrl: notification.ntfy?.serverUrl,
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "lark") {
|
||||
form.reset({
|
||||
appBuildError: notification.appBuildError,
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
type: notification.notificationType,
|
||||
webhookUrl: notification.lark?.webhookUrl,
|
||||
name: notification.name,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
} else if (notification.notificationType === "custom") {
|
||||
form.reset({
|
||||
appBuildError: notification.appBuildError,
|
||||
appDeploy: notification.appDeploy,
|
||||
dokployRestart: notification.dokployRestart,
|
||||
databaseBackup: notification.databaseBackup,
|
||||
type: notification.notificationType,
|
||||
endpoint: notification.custom?.endpoint || "",
|
||||
headers: notification.custom?.headers
|
||||
? Object.entries(notification.custom.headers).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}),
|
||||
)
|
||||
: [],
|
||||
name: notification.name,
|
||||
volumeBackup: notification.volumeBackup,
|
||||
dockerCleanup: notification.dockerCleanup,
|
||||
serverThreshold: notification.serverThreshold,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -311,6 +406,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
email: emailMutation,
|
||||
gotify: gotifyMutation,
|
||||
ntfy: ntfyMutation,
|
||||
lark: larkMutation,
|
||||
custom: customMutation,
|
||||
};
|
||||
|
||||
const onSubmit = async (data: NotificationSchema) => {
|
||||
@@ -319,6 +416,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy,
|
||||
dokployRestart,
|
||||
databaseBackup,
|
||||
volumeBackup,
|
||||
dockerCleanup,
|
||||
serverThreshold,
|
||||
} = data;
|
||||
@@ -329,6 +427,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
channel: data.channel,
|
||||
name: data.name,
|
||||
@@ -343,6 +442,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
botToken: data.botToken,
|
||||
messageThreadId: data.messageThreadId || "",
|
||||
chatId: data.chatId,
|
||||
@@ -358,6 +458,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
decoration: data.decoration,
|
||||
name: data.name,
|
||||
@@ -372,6 +473,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
smtpServer: data.smtpServer,
|
||||
smtpPort: data.smtpPort,
|
||||
username: data.username,
|
||||
@@ -390,6 +492,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
serverUrl: data.serverUrl,
|
||||
appToken: data.appToken,
|
||||
priority: data.priority,
|
||||
@@ -405,8 +508,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
serverUrl: data.serverUrl,
|
||||
accessToken: data.accessToken,
|
||||
accessToken: data.accessToken || "",
|
||||
topic: data.topic,
|
||||
priority: data.priority,
|
||||
name: data.name,
|
||||
@@ -414,6 +518,47 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
notificationId: notificationId || "",
|
||||
ntfyId: notification?.ntfyId || "",
|
||||
});
|
||||
} else if (data.type === "lark") {
|
||||
promise = larkMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
webhookUrl: data.webhookUrl,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
notificationId: notificationId || "",
|
||||
larkId: notification?.larkId || "",
|
||||
serverThreshold: serverThreshold,
|
||||
});
|
||||
} else if (data.type === "custom") {
|
||||
// Convert headers array to object
|
||||
const headersRecord =
|
||||
data.headers && data.headers.length > 0
|
||||
? data.headers.reduce(
|
||||
(acc, { key, value }) => {
|
||||
if (key.trim()) acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
promise = customMutation.mutateAsync({
|
||||
appBuildError: appBuildError,
|
||||
appDeploy: appDeploy,
|
||||
dokployRestart: dokployRestart,
|
||||
databaseBackup: databaseBackup,
|
||||
volumeBackup: volumeBackup,
|
||||
endpoint: data.endpoint,
|
||||
headers: headersRecord,
|
||||
name: data.name,
|
||||
dockerCleanup: dockerCleanup,
|
||||
serverThreshold: serverThreshold,
|
||||
notificationId: notificationId || "",
|
||||
customId: notification?.customId || "",
|
||||
});
|
||||
}
|
||||
|
||||
if (promise) {
|
||||
@@ -502,7 +647,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
/>
|
||||
<Label
|
||||
htmlFor={key}
|
||||
className="flex flex-col gap-2 items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
|
||||
className="h-24 flex flex-col gap-2 items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
|
||||
>
|
||||
{value.icon}
|
||||
{value.label}
|
||||
@@ -962,8 +1107,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
<Input
|
||||
placeholder="AzxcvbnmKjhgfdsa..."
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional. Leave blank for public topics.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -1000,6 +1149,112 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{type === "custom" && (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpoint"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://api.example.com/webhook"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The URL where POST requests will be sent with
|
||||
notification data.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<FormLabel>Headers</FormLabel>
|
||||
<FormDescription>
|
||||
Optional. Custom headers for your POST request (e.g.,
|
||||
Authorization, Content-Type).
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{headerFields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-center gap-2 p-2 border rounded-md bg-muted/50"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`headers.${index}.key` as never}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input placeholder="Key" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`headers.${index}.value` as never}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-[2]">
|
||||
<FormControl>
|
||||
<Input placeholder="Value" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeHeader(index)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendHeader({ key: "", value: "" })}
|
||||
className="w-full"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Add header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{type === "lark" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://open.larksuite.com/open-apis/bot/v2/hook/xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -1070,6 +1325,27 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="volumeBackup"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Volume Backup</FormLabel>
|
||||
<FormDescription>
|
||||
Trigger the action when a volume backup is created.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dockerCleanup"
|
||||
@@ -1150,54 +1426,83 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
||||
isLoadingDiscord ||
|
||||
isLoadingEmail ||
|
||||
isLoadingGotify ||
|
||||
isLoadingNtfy
|
||||
isLoadingNtfy ||
|
||||
isLoadingLark ||
|
||||
isLoadingCustom
|
||||
}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) return;
|
||||
|
||||
const data = form.getValues();
|
||||
|
||||
try {
|
||||
if (type === "slack") {
|
||||
if (data.type === "slack") {
|
||||
await testSlackConnection({
|
||||
webhookUrl: form.getValues("webhookUrl"),
|
||||
channel: form.getValues("channel"),
|
||||
webhookUrl: data.webhookUrl,
|
||||
channel: data.channel,
|
||||
});
|
||||
} else if (type === "telegram") {
|
||||
} else if (data.type === "telegram") {
|
||||
await testTelegramConnection({
|
||||
botToken: form.getValues("botToken"),
|
||||
chatId: form.getValues("chatId"),
|
||||
messageThreadId: form.getValues("messageThreadId") || "",
|
||||
botToken: data.botToken,
|
||||
chatId: data.chatId,
|
||||
messageThreadId: data.messageThreadId || "",
|
||||
});
|
||||
} else if (type === "discord") {
|
||||
} else if (data.type === "discord") {
|
||||
await testDiscordConnection({
|
||||
webhookUrl: form.getValues("webhookUrl"),
|
||||
decoration: form.getValues("decoration"),
|
||||
webhookUrl: data.webhookUrl,
|
||||
decoration: data.decoration,
|
||||
});
|
||||
} else if (type === "email") {
|
||||
} else if (data.type === "email") {
|
||||
await testEmailConnection({
|
||||
smtpServer: form.getValues("smtpServer"),
|
||||
smtpPort: form.getValues("smtpPort"),
|
||||
username: form.getValues("username"),
|
||||
password: form.getValues("password"),
|
||||
toAddresses: form.getValues("toAddresses"),
|
||||
fromAddress: form.getValues("fromAddress"),
|
||||
smtpServer: data.smtpServer,
|
||||
smtpPort: data.smtpPort,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
fromAddress: data.fromAddress,
|
||||
toAddresses: data.toAddresses,
|
||||
});
|
||||
} else if (type === "gotify") {
|
||||
} else if (data.type === "gotify") {
|
||||
await testGotifyConnection({
|
||||
serverUrl: form.getValues("serverUrl"),
|
||||
appToken: form.getValues("appToken"),
|
||||
priority: form.getValues("priority"),
|
||||
decoration: form.getValues("decoration"),
|
||||
serverUrl: data.serverUrl,
|
||||
appToken: data.appToken,
|
||||
priority: data.priority,
|
||||
decoration: data.decoration,
|
||||
});
|
||||
} else if (type === "ntfy") {
|
||||
} else if (data.type === "ntfy") {
|
||||
await testNtfyConnection({
|
||||
serverUrl: form.getValues("serverUrl"),
|
||||
topic: form.getValues("topic"),
|
||||
accessToken: form.getValues("accessToken"),
|
||||
priority: form.getValues("priority"),
|
||||
serverUrl: data.serverUrl,
|
||||
topic: data.topic,
|
||||
accessToken: data.accessToken || "",
|
||||
priority: data.priority,
|
||||
});
|
||||
} else if (data.type === "lark") {
|
||||
await testLarkConnection({
|
||||
webhookUrl: data.webhookUrl,
|
||||
});
|
||||
} else if (data.type === "custom") {
|
||||
const headersRecord =
|
||||
data.headers && data.headers.length > 0
|
||||
? data.headers.reduce(
|
||||
(acc, { key, value }) => {
|
||||
if (key.trim()) acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
)
|
||||
: undefined;
|
||||
await testCustomConnection({
|
||||
endpoint: data.endpoint,
|
||||
headers: headersRecord,
|
||||
});
|
||||
}
|
||||
toast.success("Connection Success");
|
||||
} catch {
|
||||
toast.error("Error testing the provider");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
`Error testing the provider: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Bell, Loader2, Mail, MessageCircleMore, Trash2 } from "lucide-react";
|
||||
import { Bell, Loader2, Mail, PenBoxIcon, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DiscordIcon,
|
||||
GotifyIcon,
|
||||
LarkIcon,
|
||||
NtfyIcon,
|
||||
SlackIcon,
|
||||
TelegramIcon,
|
||||
} from "@/components/icons/notification-icons";
|
||||
@@ -33,7 +36,7 @@ export const ShowNotifications = () => {
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add your providers to receive notifications, like Discord, Slack,
|
||||
Telegram, Email.
|
||||
Telegram, Email, Lark.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
@@ -85,12 +88,22 @@ export const ShowNotifications = () => {
|
||||
)}
|
||||
{notification.notificationType === "gotify" && (
|
||||
<div className="flex items-center justify-center rounded-lg ">
|
||||
<MessageCircleMore className="size-6 text-muted-foreground" />
|
||||
<GotifyIcon className="size-6" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "ntfy" && (
|
||||
<div className="flex items-center justify-center rounded-lg ">
|
||||
<MessageCircleMore className="size-6 text-muted-foreground" />
|
||||
<NtfyIcon className="size-6" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "custom" && (
|
||||
<div className="flex items-center justify-center rounded-lg ">
|
||||
<PenBoxIcon className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{notification.notificationType === "lark" && (
|
||||
<div className="flex items-center justify-center rounded-lg">
|
||||
<LarkIcon className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import copy from "copy-to-clipboard";
|
||||
import {
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
KeyRound,
|
||||
RefreshCw,
|
||||
ShieldOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
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 { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
BACKUP_CODES_PLACEHOLDER,
|
||||
backupCodeTemplate,
|
||||
DATE_PLACEHOLDER,
|
||||
USERNAME_PLACEHOLDER,
|
||||
} from "./enable-2fa";
|
||||
|
||||
const PasswordSchema = z.object({
|
||||
password: z.string().min(8, {
|
||||
message: "Password is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type PasswordForm = z.infer<typeof PasswordSchema>;
|
||||
type Step = "password" | "actions" | "backup-codes";
|
||||
|
||||
export const Configure2FA = () => {
|
||||
const utils = api.useUtils();
|
||||
const { data: currentUser } = api.user.get.useQuery();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [step, setStep] = useState<Step>("password");
|
||||
const [password, setPassword] = useState("");
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [showDisableConfirm, setShowDisableConfirm] = useState(false);
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
|
||||
const form = useForm<PasswordForm>({
|
||||
resolver: zodResolver(PasswordSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDialogOpen) {
|
||||
setStep("password");
|
||||
setPassword("");
|
||||
setBackupCodes([]);
|
||||
form.reset();
|
||||
}
|
||||
}, [isDialogOpen, form]);
|
||||
|
||||
const handlePasswordSubmit = async (formData: PasswordForm) => {
|
||||
setIsRegenerating(true);
|
||||
try {
|
||||
// Verify password by attempting to generate backup codes
|
||||
// This validates the password and checks if 2FA is enabled
|
||||
const result = await authClient.twoFactor.generateBackupCodes({
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
form.setError("password", { message: result.error.message });
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we get here, password is correct
|
||||
setPassword(formData.password);
|
||||
setStep("actions");
|
||||
} catch (error) {
|
||||
form.setError("password", {
|
||||
message: error instanceof Error ? error.message : "Incorrect password",
|
||||
});
|
||||
toast.error("Incorrect password");
|
||||
} finally {
|
||||
setIsRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerateBackupCodes = async () => {
|
||||
setIsRegenerating(true);
|
||||
try {
|
||||
const result = await authClient.twoFactor.generateBackupCodes({
|
||||
password,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.backupCodes) {
|
||||
setBackupCodes(result.data.backupCodes);
|
||||
setStep("backup-codes");
|
||||
toast.success("Backup codes regenerated successfully");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to regenerate backup codes",
|
||||
);
|
||||
} finally {
|
||||
setIsRegenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable2FA = async () => {
|
||||
setIsDisabling(true);
|
||||
try {
|
||||
const result = await authClient.twoFactor.disable({
|
||||
password,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("2FA disabled successfully");
|
||||
utils.user.get.invalidate();
|
||||
setIsDialogOpen(false);
|
||||
setShowDisableConfirm(false);
|
||||
} catch (error) {
|
||||
toast.error("Failed to disable 2FA. Please try again.");
|
||||
} finally {
|
||||
setIsDisabling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
if (step === "backup-codes") {
|
||||
setStep("actions");
|
||||
} else {
|
||||
setIsDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadBackupCodes = () => {
|
||||
if (!backupCodes || backupCodes.length === 0) {
|
||||
toast.error("No backup codes to download.");
|
||||
return;
|
||||
}
|
||||
|
||||
const backupCodesFormatted = backupCodes
|
||||
.map((code, index) => ` ${index + 1}. ${code}`)
|
||||
.join("\n");
|
||||
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const filename = `dokploy-2fa-backup-codes-${year}${month}${day}.txt`;
|
||||
|
||||
const backupCodesText = backupCodeTemplate
|
||||
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
|
||||
.replace(DATE_PLACEHOLDER, date.toLocaleString())
|
||||
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
|
||||
|
||||
const blob = new Blob([backupCodesText], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleCopyBackupCodes = () => {
|
||||
const date = new Date();
|
||||
|
||||
const backupCodesFormatted = backupCodes
|
||||
.map((code, index) => ` ${index + 1}. ${code}`)
|
||||
.join("\n");
|
||||
|
||||
const backupCodesText = backupCodeTemplate
|
||||
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
|
||||
.replace(DATE_PLACEHOLDER, date.toLocaleString())
|
||||
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
|
||||
|
||||
copy(backupCodesText);
|
||||
toast.success("Backup codes copied to clipboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary">
|
||||
<KeyRound className="size-4 text-muted-foreground" />
|
||||
Manage 2FA
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === "password" && "Verify Your Identity"}
|
||||
{step === "actions" && "2FA Configuration"}
|
||||
{step === "backup-codes" && "New Backup Codes"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "password" &&
|
||||
"Enter your password to manage your 2FA settings"}
|
||||
{step === "actions" &&
|
||||
"Choose an action to manage your two-factor authentication"}
|
||||
{step === "backup-codes" &&
|
||||
"Save these backup codes in a secure place"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === "password" && (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handlePasswordSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter your password to continue
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isRegenerating}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{step === "actions" && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3">
|
||||
<div className="flex flex-col gap-2 p-4 border rounded-lg hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium flex items-center gap-2">
|
||||
<RefreshCw className="size-4" />
|
||||
Regenerate Backup Codes
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Generate new backup codes to replace your existing ones.
|
||||
This will invalidate all previous backup codes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleRegenerateBackupCodes}
|
||||
variant="outline"
|
||||
className="w-full mt-2"
|
||||
isLoading={isRegenerating}
|
||||
>
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
Regenerate Backup Codes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 p-4 border border-destructive/50 rounded-lg hover:bg-destructive/5 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium flex items-center gap-2 text-destructive">
|
||||
<ShieldOff className="size-4" />
|
||||
Disable 2FA
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Completely disable two-factor authentication for your
|
||||
account. This will make your account less secure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowDisableConfirm(true)}
|
||||
variant="destructive"
|
||||
className="w-full mt-2"
|
||||
>
|
||||
<ShieldOff className="size-4 mr-2" />
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "backup-codes" && (
|
||||
<div className="space-y-4">
|
||||
<div className="w-full space-y-3 border rounded-lg p-4 bg-muted/50">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{backupCodes.map((code, index) => (
|
||||
<code
|
||||
key={index}
|
||||
className="bg-background p-2 rounded text-sm font-mono text-center"
|
||||
>
|
||||
{code}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Save these backup codes in a secure place. You can use them to
|
||||
access your account if you lose access to your authenticator
|
||||
device. Each code can only be used once.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDownloadBackupCodes}
|
||||
className="flex-1"
|
||||
>
|
||||
<DownloadIcon className="size-4 mr-2" />
|
||||
Download
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCopyBackupCodes}
|
||||
className="flex-1"
|
||||
>
|
||||
<CopyIcon className="size-4 mr-2" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" onClick={handleCloseDialog}>
|
||||
Back to Actions
|
||||
</Button>
|
||||
<Button onClick={() => setIsDialogOpen(false)}>Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={showDisableConfirm}
|
||||
onOpenChange={setShowDisableConfirm}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently disable Two-Factor Authentication for your
|
||||
account. Your account will be less secure without 2FA enabled.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDisable2FA}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={isDisabling}
|
||||
>
|
||||
{isDisabling ? "Disabling..." : "Disable 2FA"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,135 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const PasswordSchema = z.object({
|
||||
password: z.string().min(8, {
|
||||
message: "Password is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type PasswordForm = z.infer<typeof PasswordSchema>;
|
||||
|
||||
export const Disable2FA = () => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const form = useForm<PasswordForm>({
|
||||
resolver: zodResolver(PasswordSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (formData: PasswordForm) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await authClient.twoFactor.disable({
|
||||
password: formData.password,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
form.setError("password", {
|
||||
message: result.error.message,
|
||||
});
|
||||
toast.error(result.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("2FA disabled successfully");
|
||||
utils.user.get.invalidate();
|
||||
setIsOpen(false);
|
||||
} catch {
|
||||
form.setError("password", {
|
||||
message: "Connection error. Please try again.",
|
||||
});
|
||||
toast.error("Connection error. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">Disable 2FA</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently disable
|
||||
Two-Factor Authentication for your account.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter your password to disable 2FA
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="destructive" isLoading={isLoading}>
|
||||
Disable 2FA
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Fingerprint, QrCode } from "lucide-react";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon, DownloadIcon, Fingerprint, QrCode } from "lucide-react";
|
||||
import QRCode from "qrcode";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -29,6 +30,12 @@ import {
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
@@ -54,6 +61,26 @@ type TwoFactorSetupData = {
|
||||
type PasswordForm = z.infer<typeof PasswordSchema>;
|
||||
type PinForm = z.infer<typeof PinSchema>;
|
||||
|
||||
export const USERNAME_PLACEHOLDER = "%username%";
|
||||
export const DATE_PLACEHOLDER = "%date%";
|
||||
export const BACKUP_CODES_PLACEHOLDER = "%backupCodes%";
|
||||
|
||||
export const backupCodeTemplate = `Dokploy - BACKUP VERIFICATION CODES
|
||||
|
||||
Points to note
|
||||
--------------
|
||||
# Each code can be used only once.
|
||||
# Do not share these codes with anyone.
|
||||
|
||||
Generated codes
|
||||
---------------
|
||||
Username: ${USERNAME_PLACEHOLDER}
|
||||
Generated on: ${DATE_PLACEHOLDER}
|
||||
|
||||
|
||||
${BACKUP_CODES_PLACEHOLDER}
|
||||
`;
|
||||
|
||||
export const Enable2FA = () => {
|
||||
const utils = api.useUtils();
|
||||
const [data, setData] = useState<TwoFactorSetupData | null>(null);
|
||||
@@ -62,6 +89,7 @@ export const Enable2FA = () => {
|
||||
const [step, setStep] = useState<"password" | "verify">("password");
|
||||
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
|
||||
const [otpValue, setOtpValue] = useState("");
|
||||
const { data: currentUser } = api.user.get.useQuery();
|
||||
|
||||
const handleVerifySubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -178,6 +206,54 @@ export const Enable2FA = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadBackupCodes = () => {
|
||||
if (!backupCodes || backupCodes.length === 0) {
|
||||
toast.error("No backup codes to download.");
|
||||
return;
|
||||
}
|
||||
|
||||
const backupCodesFormatted = backupCodes
|
||||
.map((code, index) => ` ${index + 1}. ${code}`)
|
||||
.join("\n");
|
||||
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const filename = `dokploy-2fa-backup-codes-${year}${month}${day}.txt`;
|
||||
|
||||
const backupCodesText = backupCodeTemplate
|
||||
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
|
||||
.replace(DATE_PLACEHOLDER, date.toLocaleString())
|
||||
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
|
||||
|
||||
const blob = new Blob([backupCodesText], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleCopyBackupCodes = () => {
|
||||
const date = new Date();
|
||||
|
||||
const backupCodesFormatted = backupCodes
|
||||
.map((code, index) => ` ${index + 1}. ${code}`)
|
||||
.join("\n");
|
||||
|
||||
const backupCodesText = backupCodeTemplate
|
||||
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
|
||||
.replace(DATE_PLACEHOLDER, date.toLocaleString())
|
||||
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
|
||||
|
||||
copy(backupCodesText);
|
||||
toast.success("Backup codes copied to clipboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -264,6 +340,7 @@ export const Enable2FA = () => {
|
||||
<span className="text-sm font-medium">
|
||||
Scan this QR code with your authenticator app
|
||||
</span>
|
||||
{/** biome-ignore lint/performance/noImgElement: This is a valid use case for an img element */}
|
||||
<img
|
||||
src={data.qrCodeUrl}
|
||||
alt="2FA QR Code"
|
||||
@@ -281,7 +358,46 @@ export const Enable2FA = () => {
|
||||
|
||||
{backupCodes && backupCodes.length > 0 && (
|
||||
<div className="w-full space-y-3 border rounded-lg p-4">
|
||||
<h4 className="font-medium">Backup Codes</h4>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium">Backup Codes</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyBackupCodes}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Copy</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleDownloadBackupCodes}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Download</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{backupCodes.map((code, index) => (
|
||||
<code
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2, User } from "lucide-react";
|
||||
import { Loader2, Palette, User } from "lucide-react";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -27,9 +27,10 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getAvatarType, isSolidColorAvatar } from "@/lib/avatar-utils";
|
||||
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { Disable2FA } from "./disable-2fa";
|
||||
import { Configure2FA } from "./configure-2fa";
|
||||
import { Enable2FA } from "./enable-2fa";
|
||||
|
||||
const profileSchema = z.object({
|
||||
@@ -41,6 +42,7 @@ const profileSchema = z.object({
|
||||
currentPassword: z.string().nullable(),
|
||||
image: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
allowImpersonation: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
@@ -62,7 +64,6 @@ const randomImages = [
|
||||
];
|
||||
|
||||
export const ProfileForm = () => {
|
||||
const _utils = api.useUtils();
|
||||
const { data, refetch, isLoading } = api.user.get.useQuery();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
|
||||
@@ -74,6 +75,7 @@ export const ProfileForm = () => {
|
||||
} = api.user.update.useMutation();
|
||||
const { t } = useTranslation("settings");
|
||||
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const availableAvatars = useMemo(() => {
|
||||
if (gravatarHash === null) return randomImages;
|
||||
@@ -89,7 +91,8 @@ export const ProfileForm = () => {
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: "",
|
||||
allowImpersonation: data?.user?.allowImpersonation || false,
|
||||
name: data?.user?.name || "",
|
||||
name: data?.user?.firstName || "",
|
||||
lastName: data?.user?.lastName || "",
|
||||
},
|
||||
resolver: zodResolver(profileSchema),
|
||||
});
|
||||
@@ -103,7 +106,8 @@ export const ProfileForm = () => {
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: form.getValues("currentPassword") || "",
|
||||
allowImpersonation: data?.user?.allowImpersonation,
|
||||
name: data?.user?.name || "",
|
||||
name: data?.user?.firstName || "",
|
||||
lastName: data?.user?.lastName || "",
|
||||
},
|
||||
{
|
||||
keepValues: true,
|
||||
@@ -120,28 +124,29 @@ export const ProfileForm = () => {
|
||||
}, [form, data]);
|
||||
|
||||
const onSubmit = async (values: Profile) => {
|
||||
await mutateAsync({
|
||||
email: values.email.toLowerCase(),
|
||||
password: values.password || undefined,
|
||||
image: values.image,
|
||||
currentPassword: values.currentPassword || undefined,
|
||||
allowImpersonation: values.allowImpersonation,
|
||||
name: values.name || undefined,
|
||||
})
|
||||
.then(async () => {
|
||||
await refetch();
|
||||
toast.success("Profile Updated");
|
||||
form.reset({
|
||||
email: values.email,
|
||||
password: "",
|
||||
image: values.image,
|
||||
currentPassword: "",
|
||||
name: values.name || "",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating the profile");
|
||||
try {
|
||||
await mutateAsync({
|
||||
email: values.email.toLowerCase(),
|
||||
password: values.password || undefined,
|
||||
image: values.image,
|
||||
currentPassword: values.currentPassword || undefined,
|
||||
allowImpersonation: values.allowImpersonation,
|
||||
name: values.name || undefined,
|
||||
lastName: values.lastName || undefined,
|
||||
});
|
||||
await refetch();
|
||||
toast.success("Profile Updated");
|
||||
form.reset({
|
||||
email: values.email,
|
||||
password: "",
|
||||
image: values.image,
|
||||
currentPassword: "",
|
||||
name: values.name || "",
|
||||
lastName: values.lastName || "",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Error updating the profile");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -158,7 +163,8 @@ export const ProfileForm = () => {
|
||||
{t("settings.profile.description")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Disable2FA />}
|
||||
|
||||
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Configure2FA />}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
@@ -181,9 +187,22 @@ export const ProfileForm = () => {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Name" {...field} />
|
||||
<Input placeholder="John" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -257,8 +276,8 @@ export const ProfileForm = () => {
|
||||
onValueChange={(e) => {
|
||||
field.onChange(e);
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
defaultValue={getAvatarType(field.value)}
|
||||
value={getAvatarType(field.value)}
|
||||
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
|
||||
>
|
||||
<FormItem key="no-avatar">
|
||||
@@ -273,12 +292,112 @@ export const ProfileForm = () => {
|
||||
<Avatar className="default-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-transform">
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{getFallbackAvatarInitials(
|
||||
data?.user?.name,
|
||||
`${data?.user?.firstName} ${data?.user?.lastName}`.trim(),
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem key="custom-upload">
|
||||
<FormLabel className="[&:has([data-state=checked])>.upload-avatar]:border-primary [&:has([data-state=checked])>.upload-avatar]:border-1 [&:has([data-state=checked])>.upload-avatar]:p-px cursor-pointer">
|
||||
<FormControl>
|
||||
<RadioGroupItem
|
||||
value="upload"
|
||||
className="sr-only"
|
||||
/>
|
||||
</FormControl>
|
||||
<div
|
||||
className="upload-avatar h-12 w-12 rounded-full border border-dashed border-muted-foreground hover:border-primary transition-colors flex items-center justify-center bg-muted/50 hover:bg-muted overflow-hidden"
|
||||
onClick={() =>
|
||||
document
|
||||
.getElementById("avatar-upload")
|
||||
?.click()
|
||||
}
|
||||
>
|
||||
{field.value?.startsWith("data:") ? (
|
||||
// biome-ignore lint/performance/noImgElement: this is an justified use of img element
|
||||
<img
|
||||
src={field.value}
|
||||
alt="Custom avatar"
|
||||
className="h-full w-full object-cover rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
id="avatar-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// max file size 2mb
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error(
|
||||
"Image size must be less than 2MB",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const result = event.target
|
||||
?.result as string;
|
||||
field.onChange(result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem key="color-avatar">
|
||||
<FormLabel className="[&:has([data-state=checked])>.color-avatar]:border-primary [&:has([data-state=checked])>.color-avatar]:border-1 [&:has([data-state=checked])>.color-avatar]:p-px cursor-pointer relative">
|
||||
<FormControl>
|
||||
<RadioGroupItem
|
||||
value="color"
|
||||
className="sr-only"
|
||||
/>
|
||||
</FormControl>
|
||||
<div
|
||||
className="color-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-colors flex items-center justify-center overflow-hidden cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: isSolidColorAvatar(
|
||||
field.value,
|
||||
)
|
||||
? field.value
|
||||
: undefined,
|
||||
}}
|
||||
onClick={() =>
|
||||
colorInputRef.current?.click()
|
||||
}
|
||||
>
|
||||
{!isSolidColorAvatar(field.value) && (
|
||||
<Palette className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={colorInputRef}
|
||||
type="color"
|
||||
className="absolute opacity-0 pointer-events-none w-12 h-12 top-0 left-0"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
{availableAvatars.map((image) => (
|
||||
<FormItem key={image}>
|
||||
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px cursor-pointer">
|
||||
@@ -289,6 +408,7 @@ export const ProfileForm = () => {
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* biome-ignore lint/performance/noImgElement: this is an justified use of img element */}
|
||||
<img
|
||||
key={image}
|
||||
src={image}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Activity } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,20 +15,30 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
asButton?: boolean;
|
||||
}
|
||||
|
||||
export const ShowServerActions = ({ serverId }: Props) => {
|
||||
export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{asButton ? (
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Activity className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
View Actions
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<div className="flex flex-col gap-1">
|
||||
<DialogTitle className="text-xl">Web server settings</DialogTitle>
|
||||
|
||||
@@ -173,7 +173,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
serverId: serverId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Cleaned all");
|
||||
toast.success("Cleaning in progress... Please wait");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error cleaning all");
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -85,7 +87,26 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
</DropdownMenuItem>
|
||||
</EditTraefikEnv>
|
||||
|
||||
<DropdownMenuItem
|
||||
<DialogAction
|
||||
title={
|
||||
haveTraefikDashboardPortEnabled
|
||||
? "Disable Traefik Dashboard"
|
||||
: "Enable Traefik Dashboard"
|
||||
}
|
||||
description={
|
||||
<div className="space-y-4">
|
||||
<AlertBlock type="warning">
|
||||
The Traefik container will be recreated from scratch. This
|
||||
means the container will be deleted and created again, which
|
||||
may cause downtime in your applications.
|
||||
</AlertBlock>
|
||||
<p>
|
||||
Are you sure you want to{" "}
|
||||
{haveTraefikDashboardPortEnabled ? "disable" : "enable"} the
|
||||
Traefik dashboard?
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
onClick={async () => {
|
||||
await toggleDashboard({
|
||||
enableDashboard: !haveTraefikDashboardPortEnabled,
|
||||
@@ -97,14 +118,26 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
);
|
||||
refetchDashboard();
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
error?.message ||
|
||||
"Failed to toggle dashboard. Please check if port 8080 is available.";
|
||||
toast.error(errorMessage);
|
||||
});
|
||||
}}
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
disabled={toggleDashboardIsLoading}
|
||||
type="default"
|
||||
>
|
||||
<span>
|
||||
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"} Dashboard
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
>
|
||||
<span>
|
||||
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "}
|
||||
Dashboard
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
<ManageTraefikPorts serverId={serverId}>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
|
||||
@@ -7,9 +7,12 @@ interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
const { data, refetch } = api.user.get.useQuery(undefined, {
|
||||
enabled: !serverId,
|
||||
});
|
||||
const { data, refetch } = api.settings.getWebServerSettings.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: !serverId,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: server, refetch: refetchServer } = api.server.one.useQuery(
|
||||
{
|
||||
@@ -22,7 +25,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
|
||||
const enabled = serverId
|
||||
? server?.enableDockerCleanup
|
||||
: data?.user.enableDockerCleanup;
|
||||
: data?.enableDockerCleanup;
|
||||
|
||||
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
|
||||
|
||||
@@ -30,7 +33,10 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
try {
|
||||
await mutateAsync({
|
||||
enableDockerCleanup: checked,
|
||||
serverId: serverId,
|
||||
...(serverId && { serverId }),
|
||||
} as {
|
||||
enableDockerCleanup: boolean;
|
||||
serverId?: string;
|
||||
});
|
||||
if (serverId) {
|
||||
await refetchServer();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { Pencil, PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -52,15 +52,17 @@ const Schema = z.object({
|
||||
sshKeyId: z.string().min(1, {
|
||||
message: "SSH Key is required",
|
||||
}),
|
||||
serverType: z.enum(["deploy", "build"]).default("deploy"),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
|
||||
interface Props {
|
||||
serverId?: string;
|
||||
asButton?: boolean;
|
||||
}
|
||||
|
||||
export const HandleServers = ({ serverId }: Props) => {
|
||||
export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const utils = api.useUtils();
|
||||
@@ -89,6 +91,7 @@ export const HandleServers = ({ serverId }: Props) => {
|
||||
port: 22,
|
||||
username: "root",
|
||||
sshKeyId: "",
|
||||
serverType: "deploy",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
@@ -101,6 +104,7 @@ export const HandleServers = ({ serverId }: Props) => {
|
||||
port: data?.port || 22,
|
||||
username: data?.username || "root",
|
||||
sshKeyId: data?.sshKeyId || "",
|
||||
serverType: data?.serverType || "deploy",
|
||||
});
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
||||
|
||||
@@ -116,6 +120,7 @@ export const HandleServers = ({ serverId }: Props) => {
|
||||
port: data.port || 22,
|
||||
username: data.username || "root",
|
||||
sshKeyId: data.sshKeyId || "",
|
||||
serverType: data.serverType || "deploy",
|
||||
serverId: serverId || "",
|
||||
})
|
||||
.then(async (_data) => {
|
||||
@@ -133,21 +138,32 @@ export const HandleServers = ({ serverId }: Props) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{serverId ? (
|
||||
{serverId ? (
|
||||
asButton ? (
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer "
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
Edit Server
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
)
|
||||
) : (
|
||||
<DialogTrigger asChild>
|
||||
<Button className="cursor-pointer space-x-3">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Create Server
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent className="sm:max-w-3xl ">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
|
||||
@@ -266,6 +282,50 @@ export const HandleServers = ({ serverId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serverType"
|
||||
render={({ field }) => {
|
||||
const serverTypeValue = form.watch("serverType");
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Server Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="deploy">Deploy Server</SelectItem>
|
||||
<SelectItem value="build">Build Server</SelectItem>
|
||||
<SelectLabel>Server Type</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
{serverTypeValue === "deploy" && (
|
||||
<AlertBlock type="info" className="mt-2">
|
||||
Deploy servers are used to run your applications,
|
||||
databases, and services. They handle the deployment and
|
||||
execution of your projects.
|
||||
</AlertBlock>
|
||||
)}
|
||||
{serverTypeValue === "build" && (
|
||||
<AlertBlock type="info" className="mt-2">
|
||||
Build servers are dedicated to building your
|
||||
applications. They handle the compilation and build
|
||||
process, offloading this work from your deployment
|
||||
servers. Build servers won't appear in deployment
|
||||
options.
|
||||
</AlertBlock>
|
||||
)}
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshKeyId"
|
||||
|
||||
@@ -80,7 +80,7 @@ const Schema = z.object({
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
|
||||
export const SetupMonitoring = ({ serverId }: Props) => {
|
||||
const { data } = serverId
|
||||
const { data: serverData } = serverId
|
||||
? api.server.one.useQuery(
|
||||
{
|
||||
serverId: serverId || "",
|
||||
@@ -89,7 +89,14 @@ export const SetupMonitoring = ({ serverId }: Props) => {
|
||||
enabled: !!serverId,
|
||||
},
|
||||
)
|
||||
: api.user.getServerMetrics.useQuery();
|
||||
: { data: null };
|
||||
|
||||
const { data: webServerSettings } =
|
||||
api.settings.getWebServerSettings.useQuery(undefined, {
|
||||
enabled: !serverId,
|
||||
});
|
||||
|
||||
const data = serverId ? serverData : webServerSettings;
|
||||
|
||||
const url = useUrl();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon, ExternalLinkIcon, ServerIcon } from "lucide-react";
|
||||
import { CopyIcon, ExternalLinkIcon, ServerIcon, Settings } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
@@ -36,9 +35,10 @@ import { ValidateServer } from "./validate-server";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
asButton?: boolean;
|
||||
}
|
||||
|
||||
export const SetupServer = ({ serverId }: Props) => {
|
||||
export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data: server } = api.server.one.useQuery(
|
||||
{
|
||||
@@ -51,6 +51,7 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
|
||||
const [activeLog, setActiveLog] = useState<string | null>(null);
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const isBuildServer = server?.serverType === "build";
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
@@ -80,14 +81,23 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
{asButton ? (
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full cursor-pointer "
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
Setup Server
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
Setup Server <Settings className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<DialogContent className="sm:max-w-4xl ">
|
||||
<DialogHeader>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -117,17 +127,26 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
<TabsList
|
||||
className={cn(
|
||||
"grid w-[700px]",
|
||||
isCloud ? "grid-cols-6" : "grid-cols-5",
|
||||
isBuildServer
|
||||
? "grid-cols-3"
|
||||
: isCloud
|
||||
? "grid-cols-6"
|
||||
: "grid-cols-5",
|
||||
)}
|
||||
>
|
||||
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="validate">Validate</TabsTrigger>
|
||||
<TabsTrigger value="audit">Security</TabsTrigger>
|
||||
{isCloud && (
|
||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||
|
||||
{!isBuildServer && (
|
||||
<>
|
||||
<TabsTrigger value="audit">Security</TabsTrigger>
|
||||
{isCloud && (
|
||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value="ssh-keys"
|
||||
@@ -311,32 +330,36 @@ export const SetupServer = ({ serverId }: Props) => {
|
||||
<ValidateServer serverId={serverId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="audit"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
||||
<SecurityAudit serverId={serverId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="monitoring"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm pt-3">
|
||||
<div className="rounded-xl bg-background shadow-md border">
|
||||
<SetupMonitoring serverId={serverId} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="gpu-setup"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
||||
<GPUSupport serverId={serverId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
{!isBuildServer && (
|
||||
<>
|
||||
<TabsContent
|
||||
value="audit"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
||||
<SecurityAudit serverId={serverId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="monitoring"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm pt-3">
|
||||
<div className="rounded-xl bg-background shadow-md border">
|
||||
<SetupMonitoring serverId={serverId} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="gpu-setup"
|
||||
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
>
|
||||
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
|
||||
<GPUSupport serverId={serverId} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user