mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-16 20:55:21 +02:00
Compare commits
835 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c688311580 | ||
|
|
b9c62cc515 | ||
|
|
605931861b | ||
|
|
4e8d37bff7 | ||
|
|
be35709cea | ||
|
|
6c3230648a | ||
|
|
756d276f47 | ||
|
|
1d5ab71bd5 | ||
|
|
9880c71dba | ||
|
|
33c3a4ed4e | ||
|
|
3689a82ec5 | ||
|
|
b818d661fd | ||
|
|
1302d705e7 | ||
|
|
685a4c0b69 | ||
|
|
b58f2b236f | ||
|
|
06fd561bb1 | ||
|
|
62fb117ecf | ||
|
|
8713d3e1aa | ||
|
|
76038f6db6 | ||
|
|
a511f4db40 | ||
|
|
95a944c4e5 | ||
|
|
6d6cf18108 | ||
|
|
32ed0c7285 | ||
|
|
923466b4fa | ||
|
|
d5163322fb | ||
|
|
714849883e | ||
|
|
407ce3f425 | ||
|
|
49a189fcbf | ||
|
|
7e8d3b7162 | ||
|
|
24010af265 | ||
|
|
33192ce4d1 | ||
|
|
02a695c6af | ||
|
|
e5f51fd7be | ||
|
|
620e4c4835 | ||
|
|
125c23e2c0 | ||
|
|
51e005701d | ||
|
|
c04dd63db8 | ||
|
|
4fd06b00a0 | ||
|
|
1f9335ad5d | ||
|
|
2cd3c27ae9 | ||
|
|
53ae08cec4 | ||
|
|
8aab8dd2a5 | ||
|
|
e8bec0ae03 | ||
|
|
389a69484e | ||
|
|
f656e624f7 | ||
|
|
f5635f6645 | ||
|
|
81a04d0777 | ||
|
|
b63c22a7df | ||
|
|
05ad6d812c | ||
|
|
aa579977e3 | ||
|
|
2788323e01 | ||
|
|
3b74425d35 | ||
|
|
edbc98aea7 | ||
|
|
60f5ab304a | ||
|
|
8291c6d835 | ||
|
|
7928d117b3 | ||
|
|
eec4e21751 | ||
|
|
343a84d6bc | ||
|
|
89416fef47 | ||
|
|
74d72f1494 | ||
|
|
a24dbe365a | ||
|
|
3b753ecfbf | ||
|
|
7184b7d4b2 | ||
|
|
5c36ca3986 | ||
|
|
3a3f3ab7d4 | ||
|
|
1779a8a950 | ||
|
|
a51a4b3e87 | ||
|
|
034d55d7cb | ||
|
|
eeb7f00d05 | ||
|
|
1326d14a00 | ||
|
|
59f843f8a0 | ||
|
|
fe807ae2a6 | ||
|
|
744ebab15a | ||
|
|
17da1d5b3c | ||
|
|
f7613d9375 | ||
|
|
a43ad106f2 | ||
|
|
0e26c5023b | ||
|
|
f4a4530481 | ||
|
|
00dc3fae11 | ||
|
|
1da23f8888 | ||
|
|
bee4e4639c | ||
|
|
bd5b27ad51 | ||
|
|
b391abfd5c | ||
|
|
21a6657e00 | ||
|
|
d348ad5556 | ||
|
|
5d8b7b9b99 | ||
|
|
f5fa39b97e | ||
|
|
0a3a90c4e9 | ||
|
|
f440df343a | ||
|
|
4ec282b2f3 | ||
|
|
c039e638a6 | ||
|
|
65ffc63da4 | ||
|
|
5ba120567f | ||
|
|
8a335789b3 | ||
|
|
d420311507 | ||
|
|
a01ace12e8 | ||
|
|
24c022f837 | ||
|
|
ecd81eb7fa | ||
|
|
f2e4a96154 | ||
|
|
08ba24c252 | ||
|
|
ff55270b52 | ||
|
|
f78819d81a | ||
|
|
79e02483ad | ||
|
|
f25ed46dbc | ||
|
|
7ad09c0d0d | ||
|
|
a212d42495 | ||
|
|
51095e3ac5 | ||
|
|
a897fe6115 | ||
|
|
a0d9f06a35 | ||
|
|
f1d0fb95f4 | ||
|
|
4bc494e009 | ||
|
|
110bdce38c | ||
|
|
b9e700243e | ||
|
|
bc39addfa8 | ||
|
|
2532934cdf | ||
|
|
f4b5a589b6 | ||
|
|
105562bdcb | ||
|
|
16359e21a2 | ||
|
|
9451958193 | ||
|
|
1a810790cd | ||
|
|
e426c89cb2 | ||
|
|
325a0aeedf | ||
|
|
a8293b7b5c | ||
|
|
54bd25da39 | ||
|
|
e4c440b265 | ||
|
|
e39f0fee77 | ||
|
|
5b48e45536 | ||
|
|
3a7f76e33e | ||
|
|
a54c84a138 | ||
|
|
8ba26f01e3 | ||
|
|
9bc88eba72 | ||
|
|
b741618251 | ||
|
|
c0328ab63f | ||
|
|
425bcf8958 | ||
|
|
26d4058457 | ||
|
|
ccaac28f08 | ||
|
|
a1a348e22d | ||
|
|
ad29bb6ec2 | ||
|
|
aa2e0e81c6 | ||
|
|
3750cdab44 | ||
|
|
6cf448ba80 | ||
|
|
3e64647d0d | ||
|
|
dde00fc380 | ||
|
|
f4ad3dae35 | ||
|
|
85a8ec8ba9 | ||
|
|
c68525aa59 | ||
|
|
91d6365275 | ||
|
|
35a7445a09 | ||
|
|
4607b15a85 | ||
|
|
4eae1a5c14 | ||
|
|
5381b13813 | ||
|
|
66ae8e1fff | ||
|
|
1aa05eaa8d | ||
|
|
4f13c25ca2 | ||
|
|
83599cee37 | ||
|
|
1c4e95d8e3 | ||
|
|
97f1105cf4 | ||
|
|
c65026353a | ||
|
|
8872dc178c | ||
|
|
fa0c2ec5e3 | ||
|
|
f9eda8e95d | ||
|
|
5b2b0db686 | ||
|
|
6576731842 | ||
|
|
ec7bf9fd2f | ||
|
|
bc053744fc | ||
|
|
af87614cb0 | ||
|
|
b2484da2af | ||
|
|
5d3c05d291 | ||
|
|
40accfbf60 | ||
|
|
3f0558d077 | ||
|
|
7ae3d7d906 | ||
|
|
6877ebe027 | ||
|
|
51e881d831 | ||
|
|
80bbb752b6 | ||
|
|
3c9945ec35 | ||
|
|
4d8c358b33 | ||
|
|
4b82659a48 | ||
|
|
d77c562c84 | ||
|
|
37ea75be3e | ||
|
|
82158ed34d | ||
|
|
9ab98c9a63 | ||
|
|
ca7d3f8cb3 | ||
|
|
66448ff6c2 | ||
|
|
31d47efb1e | ||
|
|
33802f554a | ||
|
|
38265fd921 | ||
|
|
ca2efc5c68 | ||
|
|
dfcb422294 | ||
|
|
47470e2343 | ||
|
|
bac9dd5c31 | ||
|
|
65dab84e7f | ||
|
|
3a0da19ea8 | ||
|
|
5e460e6b4f | ||
|
|
9299f04f74 | ||
|
|
2746133252 | ||
|
|
bde192c1e7 | ||
|
|
99646f887b | ||
|
|
542ccc4479 | ||
|
|
9910c0e602 | ||
|
|
4f0d707905 | ||
|
|
a86fe46b7b | ||
|
|
139c06b63d | ||
|
|
999dc7d360 | ||
|
|
dc74d3057a | ||
|
|
ac833ef265 | ||
|
|
00e31f399e | ||
|
|
8001e5d24a | ||
|
|
cfb9534e06 | ||
|
|
c2894260dc | ||
|
|
582f493f3f | ||
|
|
8335f40238 | ||
|
|
f6f0921560 | ||
|
|
c739c67616 | ||
|
|
dc8148ae51 | ||
|
|
3307f62183 | ||
|
|
2b36381f8d | ||
|
|
de2579401c | ||
|
|
945406adc5 | ||
|
|
1e7522d173 | ||
|
|
2b72b4888c | ||
|
|
8b1cc949c0 | ||
|
|
a70018f70a | ||
|
|
71b87895eb | ||
|
|
354407cd12 | ||
|
|
766fd00be5 | ||
|
|
c31e970172 | ||
|
|
c56def9c97 | ||
|
|
aa558b3a8c | ||
|
|
11082f25d7 | ||
|
|
00ce8cad1b | ||
|
|
dc756e2bbb | ||
|
|
fb06cf8e55 | ||
|
|
69ba901535 | ||
|
|
4667cb525f | ||
|
|
54229b0dcd | ||
|
|
6b42c9d142 | ||
|
|
7665b38b79 | ||
|
|
d5de5b8ad7 | ||
|
|
fa201a5a96 | ||
|
|
d22d96105c | ||
|
|
bc5c65b2d2 | ||
|
|
431ad914f8 | ||
|
|
0575fabb0f | ||
|
|
385a494c83 | ||
|
|
d3f0bf654b | ||
|
|
9e8dacfe06 | ||
|
|
f450b13dc5 | ||
|
|
9e80bf45d0 | ||
|
|
a635908e43 | ||
|
|
960892fd8d | ||
|
|
acb3c1d238 | ||
|
|
68587c3c8b | ||
|
|
cae7a92599 | ||
|
|
f3d9960b7f | ||
|
|
66b4bf2c4e | ||
|
|
c4515a2ca4 | ||
|
|
1f33b0fd24 | ||
|
|
3c2f675eb9 | ||
|
|
61f6bbfe1c | ||
|
|
21b1652259 | ||
|
|
8caae549b2 | ||
|
|
30c3e44422 | ||
|
|
f72bc28d70 | ||
|
|
82c06a487a | ||
|
|
12a87f9f8b | ||
|
|
9a8de9ae16 | ||
|
|
6064b8ca48 | ||
|
|
7f27601f7f | ||
|
|
2e7f4dc1a2 | ||
|
|
2b52332e43 | ||
|
|
346216fc71 | ||
|
|
c9ffb99808 | ||
|
|
cbfa690a80 | ||
|
|
262960a59a | ||
|
|
709ffddd4f | ||
|
|
0c299a3807 | ||
|
|
25fa362cdb | ||
|
|
f680818b56 | ||
|
|
20226a300c | ||
|
|
5f5c4f0e18 | ||
|
|
25d37b76a1 | ||
|
|
ad382f1fe5 | ||
|
|
d5b0e3193a | ||
|
|
43228fc51b | ||
|
|
ba8a334fbe | ||
|
|
6c90075a64 | ||
|
|
c579dbeb1c | ||
|
|
cee1dc97ba | ||
|
|
b9419ed5f1 | ||
|
|
6bc07d7675 | ||
|
|
f72dfb3fc7 | ||
|
|
27a0490536 | ||
|
|
ec6849205a | ||
|
|
9934346d8c | ||
|
|
5c89973cc2 | ||
|
|
4e8cdfbc80 | ||
|
|
d0ea8b5283 | ||
|
|
060a053fdb | ||
|
|
304069d3c8 | ||
|
|
5967f48c6b | ||
|
|
f3bb56910a | ||
|
|
24c1c2a377 | ||
|
|
6fdb2e4a21 | ||
|
|
15e90e9ca9 | ||
|
|
d1553e1bda | ||
|
|
880a377e54 | ||
|
|
74e0bd5fe3 | ||
|
|
74aecf6828 | ||
|
|
7362cc49d2 | ||
|
|
84fa805acc | ||
|
|
6271f3bb1a | ||
|
|
57eee45dbb | ||
|
|
bcbf433607 | ||
|
|
bc6647071f | ||
|
|
dd10d0b1a4 | ||
|
|
9714695d5a | ||
|
|
37e817ff26 | ||
|
|
733f4c4a23 | ||
|
|
86548a1f24 | ||
|
|
dbd354d928 | ||
|
|
9a9e3dc295 | ||
|
|
cbd70fe5d0 | ||
|
|
c8ec86c639 | ||
|
|
b902c160a2 | ||
|
|
8f2a0f8029 | ||
|
|
f334e89108 | ||
|
|
a8fc2adab6 | ||
|
|
b8d8d9e5b2 | ||
|
|
6c2457907f | ||
|
|
36f082f12a | ||
|
|
f3f52c21ab | ||
|
|
9c565656b1 | ||
|
|
983c8d5e9e | ||
|
|
9a7b7c0c23 | ||
|
|
a76147d820 | ||
|
|
7e48b2cf29 | ||
|
|
a0d8eb9380 | ||
|
|
e5fcc10db2 | ||
|
|
a33c6bcce4 | ||
|
|
5aa5b5538c | ||
|
|
49e52ac674 | ||
|
|
2a8387bcc2 | ||
|
|
2be92d20bb | ||
|
|
2be938a695 | ||
|
|
95dd9ddeb6 | ||
|
|
33fb21bfe1 | ||
|
|
5ca4d8366e | ||
|
|
cc49db63da | ||
|
|
138b193577 | ||
|
|
f0400495b0 | ||
|
|
240e5cb12f | ||
|
|
2760c16ade | ||
|
|
79655b5673 | ||
|
|
384fdd01d6 | ||
|
|
c93ec1f06c | ||
|
|
7b3f0273cb | ||
|
|
66ed6e07c0 | ||
|
|
c1d452bcf7 | ||
|
|
f39b511316 | ||
|
|
a2df52ea7c | ||
|
|
3e5a189177 | ||
|
|
2b9231dcd1 | ||
|
|
5d26df9d9f | ||
|
|
7db1f3a69a | ||
|
|
67f0c93298 | ||
|
|
046c52529b | ||
|
|
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 | ||
|
|
0a401843f8 | ||
|
|
0c62bc0f29 | ||
|
|
b19d3e94eb | ||
|
|
5005f9198b | ||
|
|
fe5efd7651 | ||
|
|
8db7a421dc | ||
|
|
068deecb61 | ||
|
|
9aa03efd13 | ||
|
|
016aa0248a | ||
|
|
eb9d140c5d | ||
|
|
9e8c3f1525 | ||
|
|
611b0b3113 | ||
|
|
2eb73b988b | ||
|
|
d2ce587494 | ||
|
|
13ad8cb846 | ||
|
|
0897417d7c | ||
|
|
27dd20b75d | ||
|
|
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 | ||
|
|
3142818cf2 | ||
|
|
0b45b795e8 | ||
|
|
d187b52e09 | ||
|
|
5f13679a97 | ||
|
|
415327c246 | ||
|
|
12b8f8a4fd | ||
|
|
fea3ec9a6f | ||
|
|
2976bb5cf7 | ||
|
|
092afbe1fa | ||
|
|
a32e7e0041 | ||
|
|
d8465ac251 | ||
|
|
c33b41d082 | ||
|
|
3eea875932 | ||
|
|
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 | ||
|
|
f5f21ef195 | ||
|
|
464d58daaa | ||
|
|
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 | ||
|
|
153390ff26 | ||
|
|
e86caccfd5 | ||
|
|
8a93116ce0 | ||
|
|
daff2adb02 | ||
|
|
052fc5ffe1 | ||
|
|
a79afe49b4 | ||
|
|
48503c96c1 | ||
|
|
d08fdeb939 | ||
|
|
8ca8839d7e | ||
|
|
50b0a5d61c | ||
|
|
8b13919d3b | ||
|
|
b2264a9148 | ||
|
|
f7ddc715c7 | ||
|
|
d02913d69e | ||
|
|
c459997453 | ||
|
|
1c0673b327 | ||
|
|
334d9c91ef | ||
|
|
615d89ee0c | ||
|
|
0c24507872 | ||
|
|
d2cd01aff7 | ||
|
|
6349cabf27 | ||
|
|
94536ab05a | ||
|
|
8e5be8dbcb | ||
|
|
046606e496 | ||
|
|
e9cf1f4caa | ||
|
|
ee0a299343 | ||
|
|
1b77c8029b | ||
|
|
e4aefe7f9d | ||
|
|
15c81a0982 | ||
|
|
48e4fd3ddf | ||
|
|
276f870e74 | ||
|
|
6e86fafa5e | ||
|
|
008788a38a | ||
|
|
b4a14e6e76 | ||
|
|
e64ee98d99 | ||
|
|
95d0da25a0 | ||
|
|
0cc8c02359 | ||
|
|
7f3fe52b53 | ||
|
|
b5bc384664 | ||
|
|
39d0b9649f | ||
|
|
1ce880bd6d | ||
|
|
8a8ed58fef | ||
|
|
95714c1749 | ||
|
|
a181b7b8b8 | ||
|
|
0e2f1e2832 | ||
|
|
2ec495b2f2 | ||
|
|
4b44bc86b4 | ||
|
|
178ccb3f45 | ||
|
|
a47a5f3b9e | ||
|
|
95bf60ac75 | ||
|
|
544408886e |
21
.devcontainer/Dockerfile
Normal file
21
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
# Dockerfile for DevContainer
|
||||
FROM node:20.16.0-bullseye-slim
|
||||
|
||||
# Install essential packages
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
bash \
|
||||
git \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set up PNPM
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
|
||||
# Create workspace directory
|
||||
WORKDIR /workspaces/dokploy
|
||||
|
||||
# Set up user permissions
|
||||
USER node
|
||||
53
.devcontainer/devcontainer.json
Normal file
53
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "Dokploy development container",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": ".."
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"moby": true,
|
||||
"version": "latest"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/git:1": {
|
||||
"ppa": true,
|
||||
"version": "latest"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/go:1": {
|
||||
"version": "1.20"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-vscode.vscode-typescript-next",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"ms-vscode.vscode-json",
|
||||
"biomejs.biome",
|
||||
"golang.go",
|
||||
"redhat.vscode-xml",
|
||||
"github.vscode-github-actions",
|
||||
"github.copilot",
|
||||
"github.copilot-chat"
|
||||
]
|
||||
}
|
||||
},
|
||||
"forwardPorts": [3000, 5432, 6379],
|
||||
"portsAttributes": {
|
||||
"3000": {
|
||||
"label": "Dokploy App",
|
||||
"onAutoForward": "notify"
|
||||
},
|
||||
"5432": {
|
||||
"label": "PostgreSQL",
|
||||
"onAutoForward": "silent"
|
||||
},
|
||||
"6379": {
|
||||
"label": "Redis",
|
||||
"onAutoForward": "silent"
|
||||
}
|
||||
},
|
||||
"remoteUser": "node",
|
||||
"workspaceFolder": "/workspaces/dokploy",
|
||||
"runArgs": ["--name", "dokploy-devcontainer"]
|
||||
}
|
||||
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@@ -8,7 +8,7 @@ Before submitting this PR, please make sure that:
|
||||
|
||||
- [ ] You created a dedicated branch based on the `canary` branch.
|
||||
- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request
|
||||
- [ ] You have tested this PR in your local instance.
|
||||
- [ ] You have tested this PR in your local instance. If you have not tested it yet, please do so before submitting. This helps avoid wasting maintainers' time reviewing code that has not been verified by you.
|
||||
|
||||
## Issues related (if applicable)
|
||||
|
||||
|
||||
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 |
22
.github/workflows/pr-quality.yml
vendored
Normal file
22
.github/workflows/pr-quality.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
name: PR Quality
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
anti-slop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@v0
|
||||
with:
|
||||
max-failures: 4
|
||||
blocked-commit-authors: "claude,copilot"
|
||||
require-description: true
|
||||
min-account-age: 5
|
||||
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"
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,6 +13,8 @@ node_modules
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
openapi.json
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Hey, thanks for your interest in contributing to Dokploy! We appreciate your help and taking your time to contribute.
|
||||
|
||||
Before you start, please first discuss the feature/bug you want to add with the owners and comunity via github issues.
|
||||
Before you start, please first discuss the feature/bug you want to add with the owners and community via github issues.
|
||||
|
||||
We have a few guidelines to follow when contributing to this project:
|
||||
|
||||
@@ -11,6 +11,7 @@ We have a few guidelines to follow when contributing to this project:
|
||||
- [Development](#development)
|
||||
- [Build](#build)
|
||||
- [Pull Request](#pull-request)
|
||||
- [Important Considerations](#important-considerations-for-pull-requests)
|
||||
|
||||
## Commit Convention
|
||||
|
||||
@@ -148,7 +149,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
|
||||
@@ -162,8 +163,9 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.
|
||||
- If your pull request fixes an open issue, please reference the issue in the pull request description.
|
||||
- Once your pull request is merged, you will be automatically added as a contributor to the project.
|
||||
|
||||
**Important Considerations for Pull Requests:**
|
||||
### Important Considerations for Pull Requests
|
||||
|
||||
- **Testing is Mandatory:** All Pull Requests **must be tested** before submission. You must verify that your changes work as expected in a local development environment (see [Setup](#setup)). **Pull Requests that have not been tested will be closed.** This policy ensures clean contributions and reduces the time maintainers spend reviewing untested or broken code.
|
||||
- **Focus and Scope:** Each Pull Request should ideally address a single, well-defined problem or introduce one new feature. This greatly facilitates review and reduces the chances of introducing unintended side effects.
|
||||
- **Avoid Unfocused Changes:** Please avoid submitting Pull Requests that contain only minor changes such as whitespace adjustments, IDE-generated formatting, or removal of unused variables, unless these are part of a larger, clearly defined refactor or a dedicated "cleanup" Pull Request that addresses a specific `good first issue` or maintenance task.
|
||||
- **Issue Association:** For any significant change, it's highly recommended to open an issue first to discuss the proposed solution with the community and maintainers. This ensures alignment and avoids duplicated effort. If your PR resolves an existing issue, please link it in the description (e.g., `Fixes #123`, `Closes #456`).
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -46,23 +46,27 @@ 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" ]
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
|
||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||
|
||||
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
||||
|
||||
@@ -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,5 @@ 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
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
CMD ["pnpm", "start"]
|
||||
|
||||
@@ -35,4 +35,5 @@ 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
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
CMD ["pnpm", "start"]
|
||||
|
||||
19
LICENSE.MD
19
LICENSE.MD
@@ -1,8 +1,13 @@
|
||||
# License
|
||||
Copyright 2026-present Dokploy Technology, Inc.
|
||||
|
||||
## Core License (Apache License 2.0)
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
Copyright 2025 Mauricio Siu.
|
||||
* All content that resides under a "/proprietary" directory of this repository, if that directory exists, is licensed under the license defined in "LICENSE_PROPRIETARY".
|
||||
* Content outside of the above mentioned directories or restrictions above is available under the "Apache License 2.0" license as defined below.
|
||||
|
||||
## Apache License 2.0
|
||||
|
||||
Copyright 2026-present Dokploy Technology, Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -15,12 +20,4 @@ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
## Additional Terms for Specific Features
|
||||
|
||||
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
||||
|
||||
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
|
||||
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
||||
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
||||
|
||||
For further inquiries or permissions, please contact us directly.
|
||||
|
||||
11
LICENSE_PROPRIETARY.md
Normal file
11
LICENSE_PROPRIETARY.md
Normal file
@@ -0,0 +1,11 @@
|
||||
The Dokploy Source Available license (DSAL) version 1.0
|
||||
|
||||
Copyright (c) 2026-present Dokploy Technology, Inc.
|
||||
|
||||
With regard to the Dokploy Software:This software and associated documentation files (the "Software") may only beused in production, if you (and any entity that you represent) have agreed to, and are in compliance with, a valid commercial agreement from Dokploy.Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software. You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid Dokploy Source Available License. Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes, without requiring a subscription. You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications. You are not granted any other rights beyond what is expressly stated herein. Subject to theforegoing, it is forbidden to copy, merge, publish, distribute, sublicense,and/or sell the Software.
|
||||
|
||||
This Dokploy Source Available license applies only to the part of this Software that is in a /proprietary folder. The full text of this License shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.
|
||||
|
||||
For all third party components incorporated into the Dokploy Software, thosecomponents are licensed under the original license provided by the owner of the applicable component.
|
||||
77
README.md
77
README.md
@@ -12,24 +12,8 @@
|
||||
<br />
|
||||
|
||||
|
||||
|
||||
<div align="center" markdown="1">
|
||||
<sup>Special thanks to:</sup>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://tuple.app/dokploy">
|
||||
<img src=".github/sponsors/tuple.png" alt="Tuple's sponsorship image" width="400"/>
|
||||
</a>
|
||||
|
||||
### [Tuple, the premier screen sharing app for developers](https://tuple.app/dokploy)
|
||||
[Available for MacOS & Windows](https://tuple.app/dokploy)<br>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.
|
||||
|
||||
|
||||
## ✨ Features
|
||||
|
||||
Dokploy includes multiple features to make your life easier.
|
||||
@@ -60,70 +44,9 @@ curl -sSL https://dokploy.com/install.sh | sh
|
||||
|
||||
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||
|
||||
## ♥️ Sponsors
|
||||
|
||||
🙏 We're deeply grateful to all our sponsors who make Dokploy possible! Your support helps cover the costs of hosting, testing, and developing new features.
|
||||
|
||||
[Dokploy Open Collective](https://opencollective.com/dokploy)
|
||||
|
||||
[Github Sponsors](https://github.com/sponsors/Siumauricio)
|
||||
|
||||
<!-- Hero Sponsors 🎖 -->
|
||||
|
||||
<!-- Add Hero Sponsors here -->
|
||||
|
||||
### Hero Sponsors 🎖
|
||||
|
||||
<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>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Premium Supporters 🥇 -->
|
||||
|
||||
<!-- Add Premium Supporters here -->
|
||||
|
||||
### Premium Supporters 🥇
|
||||
|
||||
<div>
|
||||
<a href="https://supafort.com/?ref=dokploy"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="300"/></a>
|
||||
<a href="https://agentdock.ai/?ref=dokploy"><img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/></a>
|
||||
</div>
|
||||
|
||||
<!-- Elite Contributors 🥈 -->
|
||||
|
||||
<!-- Add Elite Contributors here -->
|
||||
|
||||
### Elite Contributors 🥈
|
||||
|
||||
<div>
|
||||
<a href="https://americancloud.com/?ref=dokploy"><img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="300"/></a>
|
||||
<a href="https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy"><img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/></a>
|
||||
</div>
|
||||
|
||||
### Supporting Members 🥉
|
||||
|
||||
<div>
|
||||
|
||||
<a href="https://cloudblast.io/?ref=dokploy"><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
|
||||
|
||||
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
|
||||
</div>
|
||||
|
||||
### Community Backers 🤝
|
||||
|
||||
#### Organizations:
|
||||
|
||||
[Sponsors on Open Collective](https://opencollective.com/dokploy)
|
||||
|
||||
#### Individuals:
|
||||
|
||||
[](https://opencollective.com/dokploy)
|
||||
|
||||
### Contributors 🤝
|
||||
|
||||
<a href="https://github.com/dokploy/dokploy/graphs/contributors">
|
||||
|
||||
@@ -13,18 +13,17 @@
|
||||
"@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",
|
||||
"hono": "^4.11.7",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"redis": "4.7.0",
|
||||
"zod": "^3.25.32"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.17.51",
|
||||
"@types/node": "^20.16.0",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"tsx": "^4.16.2",
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
deployPreviewApplication,
|
||||
rebuildApplication,
|
||||
rebuildCompose,
|
||||
rebuildPreviewApplication,
|
||||
updateApplicationStatus,
|
||||
updateCompose,
|
||||
updatePreviewDeployment,
|
||||
@@ -54,7 +55,14 @@ export const deploy = async (job: DeployJob) => {
|
||||
previewStatus: "running",
|
||||
});
|
||||
if (job.server) {
|
||||
if (job.type === "deploy") {
|
||||
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",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
NODE_ENV=development
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy"
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
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`[^`]+`\)/);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,21 +4,30 @@ import { describe, expect, it } from "vitest";
|
||||
describe("addDokployNetworkToService", () => {
|
||||
it("should add network to an empty array", () => {
|
||||
const result = addDokployNetworkToService([]);
|
||||
expect(result).toEqual(["dokploy-network"]);
|
||||
expect(result).toEqual(["dokploy-network", "default"]);
|
||||
});
|
||||
|
||||
it("should not add duplicate network to an array", () => {
|
||||
const result = addDokployNetworkToService(["dokploy-network"]);
|
||||
expect(result).toEqual(["dokploy-network"]);
|
||||
expect(result).toEqual(["dokploy-network", "default"]);
|
||||
});
|
||||
|
||||
it("should add network to an existing array with other networks", () => {
|
||||
const result = addDokployNetworkToService(["other-network"]);
|
||||
expect(result).toEqual(["other-network", "dokploy-network"]);
|
||||
expect(result).toEqual(["other-network", "dokploy-network", "default"]);
|
||||
});
|
||||
|
||||
it("should add network to an object if networks is an object", () => {
|
||||
const result = addDokployNetworkToService({ "other-network": {} });
|
||||
expect(result).toEqual({ "other-network": {}, "dokploy-network": {} });
|
||||
expect(result).toEqual({
|
||||
"other-network": {},
|
||||
"dokploy-network": {},
|
||||
default: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not duplicate default network when already present", () => {
|
||||
const result = addDokployNetworkToService(["default", "dokploy-network"]);
|
||||
expect(result).toEqual(["default", "dokploy-network"]);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
@@ -83,6 +83,14 @@ describe("GitHub Webhook Skip CI", () => {
|
||||
{ commits: [{ message: "[skip ci] test" }] },
|
||||
),
|
||||
).toBe("[skip ci] test");
|
||||
|
||||
// Soft Serve
|
||||
expect(
|
||||
extractCommitMessage(
|
||||
{ "x-softserve-event": "push" },
|
||||
{ commits: [{ message: "[skip ci] test" }] },
|
||||
),
|
||||
).toBe("[skip ci] test");
|
||||
});
|
||||
|
||||
it("should handle missing commit message", () => {
|
||||
@@ -99,6 +107,9 @@ describe("GitHub Webhook Skip CI", () => {
|
||||
expect(extractCommitMessage({ "x-gitea-event": "push" }, {})).toBe(
|
||||
"NEW COMMIT",
|
||||
);
|
||||
expect(extractCommitMessage({ "x-softserve-event": "push" }, {})).toBe(
|
||||
"NEW COMMIT",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
49
apps/dokploy/__test__/deploy/soft-serve.test.ts
Normal file
49
apps/dokploy/__test__/deploy/soft-serve.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractBranchName,
|
||||
extractCommitMessage,
|
||||
extractHash,
|
||||
getProviderByHeader,
|
||||
} from "@/pages/api/deploy/[refreshToken]";
|
||||
|
||||
describe("Soft Serve Webhook", () => {
|
||||
const mockSoftServeHeaders = {
|
||||
"x-softserve-event": "push",
|
||||
};
|
||||
|
||||
const createMockBody = (message: string, hash: string, branch: string) => ({
|
||||
event: "push",
|
||||
ref: `refs/heads/${branch}`,
|
||||
after: hash,
|
||||
commits: [{ message: message }],
|
||||
});
|
||||
const message: string = "feat: add new feature";
|
||||
const hash: string = "3c91c24ef9560bddc695bce138bf8a7094ec3df5";
|
||||
const branch: string = "feat/add-new";
|
||||
const goodWebhook = createMockBody(message, hash, branch);
|
||||
|
||||
it("should properly extract the provider name", () => {
|
||||
expect(getProviderByHeader(mockSoftServeHeaders)).toBe("soft-serve");
|
||||
});
|
||||
|
||||
it("should properly extract the commit message", () => {
|
||||
expect(extractCommitMessage(mockSoftServeHeaders, goodWebhook)).toBe(
|
||||
message,
|
||||
);
|
||||
});
|
||||
|
||||
it("should properly extract hash", () => {
|
||||
expect(extractHash(mockSoftServeHeaders, goodWebhook)).toBe(hash);
|
||||
});
|
||||
|
||||
it("should properly extract branch name", () => {
|
||||
expect(extractBranchName(mockSoftServeHeaders, goodWebhook)).toBe(branch);
|
||||
});
|
||||
|
||||
it("should gracefully handle invalid webhook", () => {
|
||||
expect(getProviderByHeader({})).toBeNull();
|
||||
expect(extractCommitMessage(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
|
||||
expect(extractHash(mockSoftServeHeaders, {})).toBe("NEW COMMIT");
|
||||
expect(extractBranchName(mockSoftServeHeaders, {})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { paths } from "@dokploy/server/constants";
|
||||
import AdmZip from "adm-zip";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const OUTPUT_BASE = "./__test__/drop/zips/output";
|
||||
const { APPLICATIONS_PATH } = paths();
|
||||
vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
@@ -13,7 +14,10 @@ vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
||||
// @ts-ignore
|
||||
...actual,
|
||||
paths: () => ({
|
||||
APPLICATIONS_PATH: "./__test__/drop/zips/output",
|
||||
// @ts-ignore
|
||||
...actual.paths(),
|
||||
BASE_PATH: OUTPUT_BASE,
|
||||
APPLICATIONS_PATH: OUTPUT_BASE,
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -25,11 +29,17 @@ if (typeof window === "undefined") {
|
||||
}
|
||||
|
||||
const baseApp: ApplicationNested = {
|
||||
railpackVersion: "0.2.2",
|
||||
railpackVersion: "0.15.4",
|
||||
applicationId: "",
|
||||
previewLabels: [],
|
||||
createEnvFile: true,
|
||||
bitbucketRepositorySlug: "",
|
||||
herokuVersion: "",
|
||||
giteaBranch: "",
|
||||
buildServerId: "",
|
||||
buildRegistryId: "",
|
||||
buildRegistry: null,
|
||||
args: [],
|
||||
giteaBuildPath: "",
|
||||
previewRequireCollaboratorPermissions: false,
|
||||
giteaId: "",
|
||||
@@ -37,6 +47,9 @@ const baseApp: ApplicationNested = {
|
||||
giteaRepository: "",
|
||||
cleanCache: false,
|
||||
watchPaths: [],
|
||||
rollbackRegistryId: "",
|
||||
rollbackRegistry: null,
|
||||
deployments: [],
|
||||
enableSubmodules: false,
|
||||
applicationStatus: "done",
|
||||
triggerType: "push",
|
||||
@@ -60,6 +73,7 @@ const baseApp: ApplicationNested = {
|
||||
previewWildcard: "",
|
||||
environment: {
|
||||
env: "",
|
||||
isDefault: false,
|
||||
environmentId: "",
|
||||
name: "",
|
||||
createdAt: "",
|
||||
@@ -137,8 +151,179 @@ const baseApp: ApplicationNested = {
|
||||
dockerContextPath: null,
|
||||
rollbackActive: false,
|
||||
stopGracePeriodSwarm: null,
|
||||
ulimitsSwarm: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal.
|
||||
* Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron
|
||||
* plus cover files (package.json, index.js). unzipDrop must reject and never write outside output.
|
||||
*/
|
||||
describe("GHSA-66v7-g3fh-47h3 path traversal RCE", () => {
|
||||
beforeAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js", async () => {
|
||||
baseApp.appName = "ghsa-rce";
|
||||
// PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace)
|
||||
const traversalEntry = "../../../../../etc/cron.d/malicious-cron";
|
||||
const cronPayload = "* * * * * root id\n";
|
||||
const placeholder = "x".repeat(traversalEntry.length);
|
||||
const zip = new AdmZip();
|
||||
zip.addFile(
|
||||
"package.json",
|
||||
Buffer.from('{"name": "app", "version": "1.0.0"}'),
|
||||
);
|
||||
zip.addFile("index.js", Buffer.from('console.log("Application");'));
|
||||
zip.addFile(placeholder, Buffer.from(cronPayload));
|
||||
let buf = Buffer.from(zip.toBuffer());
|
||||
buf = Buffer.from(
|
||||
buf.toString("binary").split(placeholder).join(traversalEntry),
|
||||
"binary",
|
||||
);
|
||||
const file = new File([buf as unknown as ArrayBuffer], "exploit.zip");
|
||||
await expect(unzipDrop(file, baseApp)).rejects.toThrow(
|
||||
/Path traversal detected.*resolved path escapes output directory/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("security: existing symlink escape", () => {
|
||||
beforeAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should NOT write outside base when directory is a symlink", async () => {
|
||||
const appName = "symlink-existing";
|
||||
const output = path.join(APPLICATIONS_PATH, appName, "code");
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
|
||||
// outside target (attacker wants to write here)
|
||||
const outside = path.join(APPLICATIONS_PATH, "..", "outside");
|
||||
await fs.mkdir(outside, { recursive: true });
|
||||
|
||||
// attacker-controlled symlink inside project
|
||||
await fs.symlink(outside, path.join(output, "logs"));
|
||||
|
||||
// zip looks totally harmless
|
||||
const zip = new AdmZip();
|
||||
zip.addFile("logs/pwned.txt", Buffer.from("owned"));
|
||||
|
||||
const file = new File([zip.toBuffer() as any], "exploit.zip");
|
||||
|
||||
await unzipDrop(file, { ...baseApp, appName });
|
||||
|
||||
// if vulnerable -> file exists outside sandbox
|
||||
const escaped = await fs
|
||||
.readFile(path.join(outside, "pwned.txt"), "utf8")
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
expect(escaped).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("security: zip symlink entry blocked", () => {
|
||||
beforeAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects zip containing real symlink entry", async () => {
|
||||
const appName = "zip-symlink";
|
||||
|
||||
const zipBuffer = await fs.readFile(
|
||||
path.join(__dirname, "./zips/payload/symlink-entry.zip"),
|
||||
);
|
||||
|
||||
const file = new File([zipBuffer as any], "exploit.zip");
|
||||
|
||||
await expect(unzipDrop(file, { ...baseApp, appName })).rejects.toThrow(
|
||||
/Dangerous node entries are not allowed/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unzipDrop path under output (no traversal)", () => {
|
||||
beforeAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
afterAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("allows entry etc/cron.d/malicious-cron when under output (no path traversal)", async () => {
|
||||
baseApp.appName = "cron-under-output";
|
||||
const zip = new AdmZip();
|
||||
zip.addFile(
|
||||
"etc/cron.d/malicious-cron",
|
||||
Buffer.from("* * * * * root id\n"),
|
||||
);
|
||||
zip.addFile("package.json", Buffer.from('{"name":"app"}'));
|
||||
const file = new File(
|
||||
[zip.toBuffer() as unknown as ArrayBuffer],
|
||||
"app.zip",
|
||||
);
|
||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||
await unzipDrop(file, baseApp);
|
||||
const content = await fs.readFile(
|
||||
path.join(outputPath, "etc/cron.d/malicious-cron"),
|
||||
"utf8",
|
||||
);
|
||||
expect(content).toBe("* * * * * root id\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("security: traversal inside BASE_PATH (sandbox escape)", () => {
|
||||
beforeAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await fs.rm(APPLICATIONS_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("should NOT allow writing outside application directory but inside BASE_PATH", async () => {
|
||||
const appName = "sandbox-escape";
|
||||
|
||||
const base = APPLICATIONS_PATH.replace("/applications", "");
|
||||
const output = path.join(APPLICATIONS_PATH, appName, "code");
|
||||
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
|
||||
// attacker writes into traefik config inside base
|
||||
const zip = new AdmZip();
|
||||
zip.addFile(
|
||||
"../../../traefik/dynamic/evil.yml",
|
||||
Buffer.from("pwned: true"),
|
||||
);
|
||||
|
||||
const file = new File([zip.toBuffer() as any], "exploit.zip");
|
||||
|
||||
await unzipDrop(file, { ...baseApp, appName });
|
||||
|
||||
const escapedPath = path.join(base, "traefik/dynamic/evil.yml");
|
||||
|
||||
const exists = await fs
|
||||
.readFile(escapedPath)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
expect(exists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unzipDrop using real zip files", () => {
|
||||
// const { APPLICATIONS_PATH } = paths();
|
||||
beforeAll(async () => {
|
||||
@@ -155,14 +340,12 @@ describe("unzipDrop using real zip files", () => {
|
||||
try {
|
||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||
const zip = new AdmZip("./__test__/drop/zips/single-file.zip");
|
||||
console.log(`Output Path: ${outputPath}`);
|
||||
const zipBuffer = zip.toBuffer() as Buffer<ArrayBuffer>;
|
||||
const file = new File([zipBuffer], "single.zip");
|
||||
await unzipDrop(file, baseApp);
|
||||
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||
expect(files.some((f) => f.name === "test.txt")).toBe(true);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
} finally {
|
||||
}
|
||||
});
|
||||
|
||||
1
apps/dokploy/__test__/drop/zips/payload/link
Symbolic link
1
apps/dokploy/__test__/drop/zips/payload/link
Symbolic link
@@ -0,0 +1 @@
|
||||
/etc/passwd
|
||||
BIN
apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip
Normal file
BIN
apps/dokploy/__test__/drop/zips/payload/symlink-entry.zip
Normal file
Binary file not shown.
294
apps/dokploy/__test__/env/environment-access-fallback.test.ts
vendored
Normal file
294
apps/dokploy/__test__/env/environment-access-fallback.test.ts
vendored
Normal file
@@ -0,0 +1,294 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Type definitions matching the project structure
|
||||
type Environment = {
|
||||
environmentId: string;
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
type Project = {
|
||||
projectId: string;
|
||||
name: string;
|
||||
environments: Environment[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function that selects the appropriate environment for a user
|
||||
* This matches the logic used in search-command.tsx and show.tsx
|
||||
*/
|
||||
function selectAccessibleEnvironment(
|
||||
project: Project | null | undefined,
|
||||
): Environment | null {
|
||||
if (!project || !project.environments || project.environments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find default environment from accessible environments, or fall back to first accessible environment
|
||||
const defaultEnvironment =
|
||||
project.environments.find((environment) => environment.isDefault) ||
|
||||
project.environments[0];
|
||||
|
||||
return defaultEnvironment || null;
|
||||
}
|
||||
|
||||
describe("Environment Access Fallback", () => {
|
||||
describe("selectAccessibleEnvironment", () => {
|
||||
it("should return default environment when user has access to it", () => {
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-prod",
|
||||
name: "production",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
environmentId: "env-dev",
|
||||
name: "development",
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.environmentId).toBe("env-prod");
|
||||
expect(result?.isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it("should return first accessible environment when user doesn't have access to default", () => {
|
||||
// Simulating filtered environments (user only has access to development)
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: [
|
||||
// Note: production is not in the list because user doesn't have access
|
||||
{
|
||||
environmentId: "env-dev",
|
||||
name: "development",
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
environmentId: "env-staging",
|
||||
name: "staging",
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.environmentId).toBe("env-dev");
|
||||
expect(result?.name).toBe("development");
|
||||
});
|
||||
|
||||
it("should return first environment when no default is marked but environments exist", () => {
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-dev",
|
||||
name: "development",
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
environmentId: "env-staging",
|
||||
name: "staging",
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.environmentId).toBe("env-dev");
|
||||
});
|
||||
|
||||
it("should return null when project has no accessible environments", () => {
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: [],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when project is null", () => {
|
||||
const result = selectAccessibleEnvironment(null);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when project is undefined", () => {
|
||||
const result = selectAccessibleEnvironment(undefined);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle project with single accessible environment", () => {
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-dev",
|
||||
name: "development",
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.environmentId).toBe("env-dev");
|
||||
});
|
||||
|
||||
it("should prioritize default environment even when it's not first in the array", () => {
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-dev",
|
||||
name: "development",
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
environmentId: "env-staging",
|
||||
name: "staging",
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
environmentId: "env-prod",
|
||||
name: "production",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.environmentId).toBe("env-prod");
|
||||
expect(result?.isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle multiple default environments by returning the first one found", () => {
|
||||
// Edge case: multiple environments marked as default (shouldn't happen, but test it)
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-prod-1",
|
||||
name: "production-1",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
environmentId: "env-prod-2",
|
||||
name: "production-2",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.isDefault).toBe(true);
|
||||
// Should return the first default found
|
||||
expect(result?.environmentId).toBe("env-prod-1");
|
||||
});
|
||||
|
||||
it("should work correctly when user has access to multiple environments including default", () => {
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-prod",
|
||||
name: "production",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
environmentId: "env-dev",
|
||||
name: "development",
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
environmentId: "env-staging",
|
||||
name: "staging",
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.environmentId).toBe("env-prod");
|
||||
expect(result?.isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle real-world scenario: user with only development access", () => {
|
||||
// This simulates the exact bug we're fixing:
|
||||
// User has access to development but not production (default)
|
||||
// The filtered environments array only contains development
|
||||
const project: Project = {
|
||||
projectId: "proj-1",
|
||||
name: "My Project",
|
||||
environments: [
|
||||
// Only development is accessible (production was filtered out)
|
||||
{
|
||||
environmentId: "env-dev-123",
|
||||
name: "development",
|
||||
isDefault: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.environmentId).toBe("env-dev-123");
|
||||
expect(result?.name).toBe("development");
|
||||
// Should not be null even though it's not the default
|
||||
});
|
||||
});
|
||||
|
||||
describe("Environment selection edge cases", () => {
|
||||
it("should handle project with environments property as undefined", () => {
|
||||
const project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: undefined,
|
||||
} as unknown as Project;
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle project with null environments array", () => {
|
||||
const project = {
|
||||
projectId: "proj-1",
|
||||
name: "Test Project",
|
||||
environments: null,
|
||||
} as unknown as Project;
|
||||
|
||||
const result = selectAccessibleEnvironment(project);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
184
apps/dokploy/__test__/env/stack-environment.test.ts
vendored
Normal file
184
apps/dokploy/__test__/env/stack-environment.test.ts
vendored
Normal file
@@ -0,0 +1,184 @@
|
||||
import { getEnviromentVariablesObject } from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const projectEnv = `
|
||||
ENVIRONMENT=staging
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
|
||||
PORT=3000
|
||||
`;
|
||||
|
||||
const environmentEnv = `
|
||||
NODE_ENV=development
|
||||
API_URL=https://api.dev.example.com
|
||||
REDIS_URL=redis://localhost:6379
|
||||
DATABASE_NAME=dev_database
|
||||
SECRET_KEY=env-secret-123
|
||||
`;
|
||||
|
||||
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
|
||||
it("resolves environment variables correctly for Stack compose", () => {
|
||||
const serviceEnv = `
|
||||
FOO=\${{environment.NODE_ENV}}
|
||||
BAR=\${{environment.API_URL}}
|
||||
BAZ=test
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
serviceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
FOO: "development",
|
||||
BAR: "https://api.dev.example.com",
|
||||
BAZ: "test",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves both project and environment variables for Stack compose", () => {
|
||||
const serviceEnv = `
|
||||
ENVIRONMENT=\${{project.ENVIRONMENT}}
|
||||
NODE_ENV=\${{environment.NODE_ENV}}
|
||||
API_URL=\${{environment.API_URL}}
|
||||
DATABASE_URL=\${{project.DATABASE_URL}}
|
||||
SERVICE_PORT=4000
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
serviceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ENVIRONMENT: "staging",
|
||||
NODE_ENV: "development",
|
||||
API_URL: "https://api.dev.example.com",
|
||||
DATABASE_URL: "postgres://postgres:postgres@localhost:5432/project_db",
|
||||
SERVICE_PORT: "4000",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles multiple environment references in single value for Stack compose", () => {
|
||||
const multiRefEnv = `
|
||||
HOST=localhost
|
||||
PORT=5432
|
||||
USERNAME=postgres
|
||||
PASSWORD=secret123
|
||||
`;
|
||||
|
||||
const serviceEnv = `
|
||||
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
|
||||
|
||||
expect(result).toEqual({
|
||||
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws error for undefined environment variables in Stack compose", () => {
|
||||
const serviceWithUndefined = `
|
||||
UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
|
||||
`;
|
||||
|
||||
expect(() =>
|
||||
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
|
||||
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
|
||||
});
|
||||
|
||||
it("allows service variables to override environment variables in Stack compose", () => {
|
||||
const serviceOverrideEnv = `
|
||||
NODE_ENV=production
|
||||
API_URL=\${{environment.API_URL}}
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
serviceOverrideEnv,
|
||||
"",
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
NODE_ENV: "production",
|
||||
API_URL: "https://api.dev.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves complex references with project, environment, and service variables for Stack compose", () => {
|
||||
const complexServiceEnv = `
|
||||
FULL_DATABASE_URL=\${{project.DATABASE_URL}}/\${{environment.DATABASE_NAME}}
|
||||
API_ENDPOINT=\${{environment.API_URL}}/\${{project.ENVIRONMENT}}/api
|
||||
SERVICE_NAME=my-service
|
||||
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
complexServiceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
FULL_DATABASE_URL:
|
||||
"postgres://postgres:postgres@localhost:5432/project_db/dev_database",
|
||||
API_ENDPOINT: "https://api.dev.example.com/staging/api",
|
||||
SERVICE_NAME: "my-service",
|
||||
COMPLEX_VAR: "my-service-development-staging",
|
||||
});
|
||||
});
|
||||
|
||||
it("maintains precedence: service > environment > project in Stack compose", () => {
|
||||
const conflictingProjectEnv = `
|
||||
NODE_ENV=production-project
|
||||
API_URL=https://project.api.com
|
||||
DATABASE_NAME=project_db
|
||||
`;
|
||||
|
||||
const conflictingEnvironmentEnv = `
|
||||
NODE_ENV=development-environment
|
||||
API_URL=https://environment.api.com
|
||||
DATABASE_NAME=env_db
|
||||
`;
|
||||
|
||||
const serviceWithConflicts = `
|
||||
NODE_ENV=service-override
|
||||
PROJECT_ENV=\${{project.NODE_ENV}}
|
||||
ENV_VAR=\${{environment.API_URL}}
|
||||
DB_NAME=\${{environment.DATABASE_NAME}}
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
serviceWithConflicts,
|
||||
conflictingProjectEnv,
|
||||
conflictingEnvironmentEnv,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
NODE_ENV: "service-override",
|
||||
PROJECT_ENV: "production-project",
|
||||
ENV_VAR: "https://environment.api.com",
|
||||
DB_NAME: "env_db",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles empty environment variables in Stack compose", () => {
|
||||
const serviceWithEmpty = `
|
||||
SERVICE_VAR=test
|
||||
PROJECT_VAR=\${{project.ENVIRONMENT}}
|
||||
`;
|
||||
|
||||
const result = getEnviromentVariablesObject(
|
||||
serviceWithEmpty,
|
||||
projectEnv,
|
||||
"",
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
SERVICE_VAR: "test",
|
||||
PROJECT_VAR: "staging",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
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 = {
|
||||
StopGracePeriod?: number;
|
||||
TaskTemplate?: {
|
||||
ContainerSpec?: {
|
||||
StopGracePeriod?: number;
|
||||
Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>;
|
||||
};
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
|
||||
vi.hoisted(() => {
|
||||
const inspect = vi.fn<[], Promise<never>>();
|
||||
const inspect = vi.fn<() => Promise<never>>();
|
||||
const getService = vi.fn(() => ({ inspect }));
|
||||
const createService = vi.fn<[MockCreateServiceOptions], Promise<void>>(
|
||||
async () => undefined,
|
||||
);
|
||||
const createService = vi.fn<
|
||||
(opts: MockCreateServiceOptions) => Promise<void>
|
||||
>(async () => undefined);
|
||||
const getRemoteDocker = vi.fn(async () => ({
|
||||
getService,
|
||||
createService,
|
||||
@@ -54,6 +58,7 @@ const createApplication = (
|
||||
},
|
||||
replicas: 1,
|
||||
stopGracePeriodSwarm: 0n,
|
||||
ulimitsSwarm: null,
|
||||
serverId: "server-id",
|
||||
...overrides,
|
||||
}) as unknown as ApplicationNested;
|
||||
@@ -77,13 +82,17 @@ describe("mechanizeDockerContainer", () => {
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
const call = createServiceMock.mock.calls[0] as
|
||||
| [MockCreateServiceOptions]
|
||||
| undefined;
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.StopGracePeriod).toBe(0);
|
||||
expect(typeof settings.StopGracePeriod).toBe("number");
|
||||
expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0);
|
||||
expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(
|
||||
"number",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
|
||||
@@ -91,12 +100,62 @@ describe("mechanizeDockerContainer", () => {
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0] as
|
||||
| [MockCreateServiceOptions]
|
||||
| undefined;
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty(
|
||||
"StopGracePeriod",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes ulimits to ContainerSpec when ulimitsSwarm is defined", async () => {
|
||||
const ulimits = [
|
||||
{ Name: "nofile", Soft: 10000, Hard: 20000 },
|
||||
{ Name: "nproc", Soft: 4096, Hard: 8192 },
|
||||
];
|
||||
const application = createApplication({ ulimitsSwarm: ulimits });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings).not.toHaveProperty("StopGracePeriod");
|
||||
expect(settings.TaskTemplate?.ContainerSpec?.Ulimits).toEqual(ulimits);
|
||||
});
|
||||
|
||||
it("omits Ulimits when ulimitsSwarm is null", async () => {
|
||||
const application = createApplication({ ulimitsSwarm: null });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
|
||||
});
|
||||
|
||||
it("omits Ulimits when ulimitsSwarm is an empty array", async () => {
|
||||
const application = createApplication({ ulimitsSwarm: [] });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
|
||||
});
|
||||
});
|
||||
|
||||
40
apps/dokploy/__test__/setup.ts
Normal file
40
apps/dokploy/__test__/setup.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Mock the DB module so tests that import from @dokploy/server (barrel)
|
||||
* never open a real TCP connection to PostgreSQL (e.g. in CI where no DB runs).
|
||||
* Without this, loading the server barrel pulls in lib/auth and db, which
|
||||
* connect to localhost:5432 and cause ECONNREFUSED.
|
||||
*/
|
||||
vi.mock("@dokploy/server/db", () => {
|
||||
const chain = () => chain;
|
||||
chain.set = () => chain;
|
||||
chain.where = () => chain;
|
||||
chain.values = () => chain;
|
||||
chain.returning = () => Promise.resolve([{}]);
|
||||
chain.then = undefined;
|
||||
|
||||
const tableMock = {
|
||||
findFirst: vi.fn(() => Promise.resolve(undefined)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
insert: vi.fn(() => Promise.resolve([{}])),
|
||||
update: vi.fn(() => chain),
|
||||
delete: vi.fn(() => chain),
|
||||
};
|
||||
const createQueryMock = () => tableMock;
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(() => chain),
|
||||
insert: vi.fn(() => ({
|
||||
values: () => ({ returning: () => Promise.resolve([{}]) }),
|
||||
})),
|
||||
update: vi.fn(() => chain),
|
||||
delete: vi.fn(() => chain),
|
||||
query: new Proxy({} as Record<string, typeof tableMock>, {
|
||||
get: () => tableMock,
|
||||
}),
|
||||
},
|
||||
dbUrl: "postgres://mock:mock@localhost:5432/mock",
|
||||
};
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,16 +3,25 @@ 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,
|
||||
bitbucketRepositorySlug: "",
|
||||
herokuVersion: "",
|
||||
giteaRepository: "",
|
||||
giteaOwner: "",
|
||||
giteaBranch: "",
|
||||
buildServerId: "",
|
||||
buildRegistryId: "",
|
||||
buildRegistry: null,
|
||||
giteaBuildPath: "",
|
||||
giteaId: "",
|
||||
args: [],
|
||||
rollbackRegistryId: "",
|
||||
rollbackRegistry: null,
|
||||
deployments: [],
|
||||
cleanCache: false,
|
||||
applicationStatus: "done",
|
||||
endpointSpecSwarm: null,
|
||||
@@ -42,6 +51,7 @@ const baseApp: ApplicationNested = {
|
||||
environmentId: "",
|
||||
environment: {
|
||||
env: "",
|
||||
isDefault: false,
|
||||
environmentId: "",
|
||||
name: "",
|
||||
createdAt: "",
|
||||
@@ -115,6 +125,7 @@ const baseApp: ApplicationNested = {
|
||||
username: null,
|
||||
dockerContextPath: null,
|
||||
stopGracePeriodSwarm: null,
|
||||
ulimitsSwarm: null,
|
||||
};
|
||||
|
||||
const baseDomain: Domain = {
|
||||
@@ -264,3 +275,51 @@ test("CertificateType on websecure entrypoint", async () => {
|
||||
|
||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
||||
});
|
||||
|
||||
/** IDN/Punycode */
|
||||
|
||||
test("Internationalized domain name is converted to punycode", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, host: "тест.рф" },
|
||||
"web",
|
||||
);
|
||||
|
||||
// тест.рф in punycode is xn--e1aybc.xn--p1ai
|
||||
expect(router.rule).toContain("Host(`xn--e1aybc.xn--p1ai`)");
|
||||
expect(router.rule).not.toContain("тест.рф");
|
||||
});
|
||||
|
||||
test("ASCII domain remains unchanged", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, host: "example.com" },
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(router.rule).toContain("Host(`example.com`)");
|
||||
});
|
||||
|
||||
test("Russian Cyrillic label with .ru TLD is converted to punycode", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, host: "сайт.ru" },
|
||||
"web",
|
||||
);
|
||||
|
||||
// сайт in punycode is xn--80aswg
|
||||
expect(router.rule).toContain("Host(`xn--80aswg.ru`)");
|
||||
expect(router.rule).not.toContain("сайт");
|
||||
});
|
||||
|
||||
test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, host: "app.тест.рф" },
|
||||
"web",
|
||||
);
|
||||
|
||||
// app stays ASCII, тест.рф becomes xn--e1aybc.xn--p1ai
|
||||
expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)");
|
||||
expect(router.rule).not.toContain("тест.рф");
|
||||
});
|
||||
|
||||
@@ -7,13 +7,22 @@ export default defineConfig({
|
||||
include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__
|
||||
exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"],
|
||||
pool: "forks",
|
||||
setupFiles: [path.resolve(__dirname, "setup.ts")],
|
||||
},
|
||||
define: {
|
||||
"process.env": {
|
||||
NODE: "test",
|
||||
GITHUB_CLIENT_ID: "test",
|
||||
GITHUB_CLIENT_SECRET: "test",
|
||||
GOOGLE_CLIENT_ID: "test",
|
||||
GOOGLE_CLIENT_SECRET: "test",
|
||||
},
|
||||
},
|
||||
plugins: [tsconfigPaths()],
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
projects: [path.resolve(__dirname, "../tsconfig.json")],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@dokploy/server": path.resolve(
|
||||
|
||||
81
apps/dokploy/__test__/wss/readValidDirectory.test.ts
Normal file
81
apps/dokploy/__test__/wss/readValidDirectory.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const BASE = "/base";
|
||||
|
||||
vi.mock("@dokploy/server/constants", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@dokploy/server/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
paths: () => ({
|
||||
...actual.paths(),
|
||||
BASE_PATH: BASE,
|
||||
LOGS_PATH: `${BASE}/logs`,
|
||||
APPLICATIONS_PATH: `${BASE}/applications`,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mock so paths() uses our BASE
|
||||
const { readValidDirectory } = await import("@dokploy/server");
|
||||
|
||||
describe("readValidDirectory (path traversal)", () => {
|
||||
it("returns true when directory is exactly BASE_PATH", () => {
|
||||
expect(readValidDirectory(BASE)).toBe(true);
|
||||
expect(readValidDirectory(path.resolve(BASE))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when directory is under BASE_PATH", () => {
|
||||
expect(readValidDirectory(`${BASE}/logs`)).toBe(true);
|
||||
expect(readValidDirectory(`${BASE}/logs/app/foo.log`)).toBe(true);
|
||||
expect(readValidDirectory(`${BASE}/applications/myapp/code`)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for path traversal escaping base (absolute)", () => {
|
||||
expect(readValidDirectory("/etc/passwd")).toBe(false);
|
||||
expect(readValidDirectory("/etc/cron.d/malicious")).toBe(false);
|
||||
expect(readValidDirectory("/tmp/outside")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when resolved path escapes base via ..", () => {
|
||||
// Resolved: /etc/passwd (outside /base)
|
||||
expect(readValidDirectory(`${BASE}/../etc/passwd`)).toBe(false);
|
||||
expect(readValidDirectory(`${BASE}/logs/../../etc/passwd`)).toBe(false);
|
||||
expect(readValidDirectory(`${BASE}/..`)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when .. stays within base", () => {
|
||||
// e.g. /base/logs/../applications -> /base/applications (still under /base)
|
||||
expect(readValidDirectory(`${BASE}/logs/../applications`)).toBe(true);
|
||||
expect(readValidDirectory(`${BASE}/foo/../bar`)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts serverId for remote base path", () => {
|
||||
// With our mock, serverId doesn't change BASE_PATH; just ensure it doesn't throw
|
||||
expect(readValidDirectory(BASE, "server-1")).toBe(true);
|
||||
expect(readValidDirectory("/etc/passwd", "server-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null/undefined-like paths that resolve outside", () => {
|
||||
// Paths that might resolve to cwd or root
|
||||
expect(readValidDirectory(".")).toBe(false);
|
||||
expect(readValidDirectory("..")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for BASE_PATH with trailing slash or double slashes under base", () => {
|
||||
expect(readValidDirectory(`${BASE}/`)).toBe(true);
|
||||
expect(readValidDirectory(`${BASE}//logs`)).toBe(true);
|
||||
expect(readValidDirectory(`${BASE}/applications///myapp/code`)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when path looks like base but is a sibling or prefix", () => {
|
||||
expect(readValidDirectory("/base-evil")).toBe(false);
|
||||
expect(readValidDirectory("/bas")).toBe(false);
|
||||
expect(readValidDirectory(`${BASE}/../base-evil`)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty string (resolves to cwd)", () => {
|
||||
expect(readValidDirectory("")).toBe(false);
|
||||
});
|
||||
});
|
||||
132
apps/dokploy/__test__/wss/utils.test.ts
Normal file
132
apps/dokploy/__test__/wss/utils.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isValidContainerId,
|
||||
isValidSearch,
|
||||
isValidSince,
|
||||
isValidTail,
|
||||
} from "../../server/wss/utils";
|
||||
|
||||
describe("isValidTail (docker-container-logs)", () => {
|
||||
it("accepts valid numeric tail values", () => {
|
||||
expect(isValidTail("0")).toBe(true);
|
||||
expect(isValidTail("1")).toBe(true);
|
||||
expect(isValidTail("100")).toBe(true);
|
||||
expect(isValidTail("10000")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects tail above 10000", () => {
|
||||
expect(isValidTail("10001")).toBe(false);
|
||||
expect(isValidTail("99999")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-numeric tail", () => {
|
||||
expect(isValidTail("")).toBe(false);
|
||||
expect(isValidTail("abc")).toBe(false);
|
||||
expect(isValidTail("10a")).toBe(false);
|
||||
expect(isValidTail("-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects command injection payloads in tail", () => {
|
||||
expect(isValidTail("10; whoami; #")).toBe(false);
|
||||
expect(isValidTail("100 | cat /etc/passwd")).toBe(false);
|
||||
expect(isValidTail("$(id)")).toBe(false);
|
||||
expect(isValidTail("`id`")).toBe(false);
|
||||
expect(isValidTail("100\nid")).toBe(false);
|
||||
expect(isValidTail("100 && id")).toBe(false);
|
||||
expect(isValidTail("100; env | grep DATABASE")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidSince (docker-container-logs)", () => {
|
||||
it("accepts 'all'", () => {
|
||||
expect(isValidSince("all")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts valid duration format (number + s|m|h|d)", () => {
|
||||
expect(isValidSince("5s")).toBe(true);
|
||||
expect(isValidSince("10m")).toBe(true);
|
||||
expect(isValidSince("1h")).toBe(true);
|
||||
expect(isValidSince("2d")).toBe(true);
|
||||
expect(isValidSince("0s")).toBe(true);
|
||||
expect(isValidSince("999d")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invalid duration format", () => {
|
||||
expect(isValidSince("")).toBe(false);
|
||||
expect(isValidSince("5")).toBe(false);
|
||||
expect(isValidSince("s")).toBe(false);
|
||||
expect(isValidSince("5x")).toBe(false);
|
||||
expect(isValidSince("5sec")).toBe(false);
|
||||
expect(isValidSince("5 m")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects command injection payloads in since", () => {
|
||||
expect(isValidSince("5s; whoami")).toBe(false);
|
||||
expect(isValidSince("all; id")).toBe(false);
|
||||
expect(isValidSince("1m$(id)")).toBe(false);
|
||||
expect(isValidSince("1m | cat /etc/passwd")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidSearch (docker-container-logs)", () => {
|
||||
it("accepts empty string", () => {
|
||||
expect(isValidSearch("")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts only alphanumeric, space, dot, underscore, hyphen", () => {
|
||||
expect(isValidSearch("error")).toBe(true);
|
||||
expect(isValidSearch("foo bar")).toBe(true);
|
||||
expect(isValidSearch("a-zA-Z0-9_.-")).toBe(true);
|
||||
expect(isValidSearch("")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects strings longer than 500 chars", () => {
|
||||
expect(isValidSearch("a".repeat(501))).toBe(false);
|
||||
expect(isValidSearch("a".repeat(500))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects control characters and non-printable", () => {
|
||||
expect(isValidSearch("foo\nbar")).toBe(false);
|
||||
expect(isValidSearch("foo\rbar")).toBe(false);
|
||||
expect(isValidSearch("\x00")).toBe(false);
|
||||
expect(isValidSearch("a\x19b")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects command injection vectors in search (search is concatenated into shell)", () => {
|
||||
// Double-quoted context (SSH line 99): $ and ` execute
|
||||
expect(isValidSearch("$(whoami)")).toBe(false);
|
||||
expect(isValidSearch("`id`")).toBe(false);
|
||||
expect(isValidSearch("$(id)")).toBe(false);
|
||||
// Single-quoted context (local line 153): ' breaks out
|
||||
expect(isValidSearch("'$(whoami)'")).toBe(false);
|
||||
expect(isValidSearch("error'")).toBe(false);
|
||||
expect(isValidSearch("'; whoami; #")).toBe(false);
|
||||
// Other shell-metacharacters
|
||||
expect(isValidSearch("error; id")).toBe(false);
|
||||
expect(isValidSearch("a|b")).toBe(false);
|
||||
expect(isValidSearch('error"')).toBe(false);
|
||||
expect(isValidSearch("a&b")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidContainerId (docker-container-logs)", () => {
|
||||
it("accepts valid hex container IDs", () => {
|
||||
expect(isValidContainerId("a".repeat(12))).toBe(true);
|
||||
expect(isValidContainerId("abc123def456")).toBe(true);
|
||||
expect(isValidContainerId("a".repeat(64))).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts valid container names", () => {
|
||||
expect(isValidContainerId("my-container")).toBe(true);
|
||||
expect(isValidContainerId("app_1")).toBe(true);
|
||||
expect(isValidContainerId("service.name")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects command injection in container ID", () => {
|
||||
expect(isValidContainerId("dummy; whoami")).toBe(false);
|
||||
expect(isValidContainerId("$(id)")).toBe(false);
|
||||
expect(isValidContainerId("`id`")).toBe(false);
|
||||
expect(isValidContainerId("container|cat /etc/passwd")).toBe(false);
|
||||
expect(isValidContainerId("x; env | grep DATABASE")).toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const endpointSpecFormSchema = z.object({
|
||||
Mode: z.string().optional(),
|
||||
});
|
||||
|
||||
interface EndpointSpecFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
}
|
||||
|
||||
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<any>({
|
||||
resolver: zodResolver(endpointSpecFormSchema),
|
||||
defaultValues: {
|
||||
Mode: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.endpointSpecSwarm) {
|
||||
const es = data.endpointSpecSwarm;
|
||||
form.reset({
|
||||
Mode: es.Mode,
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (formData: z.infer<typeof endpointSpecFormSchema>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Check if all values are empty, if so, send null to clear the database
|
||||
const hasAnyValue =
|
||||
formData.Mode !== undefined &&
|
||||
formData.Mode !== null &&
|
||||
formData.Mode !== "";
|
||||
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
endpointSpecSwarm: hasAnyValue ? formData : null,
|
||||
});
|
||||
|
||||
toast.success("Endpoint spec updated successfully");
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error("Error updating endpoint spec");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Mode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Mode</FormLabel>
|
||||
<FormDescription>Endpoint mode (vip or dnsrr)</FormDescription>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select endpoint mode" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="vip">VIP (Virtual IP)</SelectItem>
|
||||
<SelectItem value="dnsrr">DNS Round Robin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset({
|
||||
Mode: undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save Endpoint Spec
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,270 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const healthCheckFormSchema = z.object({
|
||||
Test: z.array(z.string()).optional(),
|
||||
Interval: z.coerce.number().optional(),
|
||||
Timeout: z.coerce.number().optional(),
|
||||
StartPeriod: z.coerce.number().optional(),
|
||||
Retries: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
interface HealthCheckFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
}
|
||||
|
||||
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<any>({
|
||||
resolver: zodResolver(healthCheckFormSchema),
|
||||
defaultValues: {
|
||||
Test: [],
|
||||
Interval: undefined,
|
||||
Timeout: undefined,
|
||||
StartPeriod: undefined,
|
||||
Retries: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const testCommands = form.watch("Test") || [];
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.healthCheckSwarm) {
|
||||
const hc = data.healthCheckSwarm;
|
||||
form.reset({
|
||||
Test: hc.Test || [],
|
||||
Interval: hc.Interval,
|
||||
Timeout: hc.Timeout,
|
||||
StartPeriod: hc.StartPeriod,
|
||||
Retries: hc.Retries,
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (formData: z.infer<typeof healthCheckFormSchema>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Check if all values are empty, if so, send null to clear the database
|
||||
const hasAnyValue =
|
||||
(formData.Test && formData.Test.length > 0) ||
|
||||
formData.Interval !== undefined ||
|
||||
formData.Timeout !== undefined ||
|
||||
formData.StartPeriod !== undefined ||
|
||||
formData.Retries !== undefined;
|
||||
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
healthCheckSwarm: hasAnyValue ? formData : null,
|
||||
});
|
||||
|
||||
toast.success("Health check updated successfully");
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error("Error updating health check");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addTestCommand = () => {
|
||||
form.setValue("Test", [...testCommands, ""]);
|
||||
};
|
||||
|
||||
const updateTestCommand = (index: number, value: string) => {
|
||||
const newCommands = [...testCommands];
|
||||
newCommands[index] = value;
|
||||
form.setValue("Test", newCommands);
|
||||
};
|
||||
|
||||
const removeTestCommand = (index: number) => {
|
||||
form.setValue(
|
||||
"Test",
|
||||
testCommands.filter((_: string, i: number) => i !== index),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<FormLabel>Test Commands</FormLabel>
|
||||
<FormDescription>
|
||||
Command to run for health check (e.g., ["CMD-SHELL", "curl -f
|
||||
http://localhost:3000/health"])
|
||||
</FormDescription>
|
||||
<div className="space-y-2 mt-2">
|
||||
{testCommands.map((cmd: string, index: number) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={cmd}
|
||||
onChange={(e) => updateTestCommand(index, e.target.value)}
|
||||
placeholder={
|
||||
index === 0
|
||||
? "CMD-SHELL"
|
||||
: "curl -f http://localhost:3000/health"
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removeTestCommand(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addTestCommand}
|
||||
>
|
||||
Add Command
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Interval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Interval (nanoseconds)</FormLabel>
|
||||
<FormDescription>
|
||||
Time between health checks (e.g., 10000000000 for 10 seconds)
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Timeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Timeout (nanoseconds)</FormLabel>
|
||||
<FormDescription>
|
||||
Maximum time to wait for health check response
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="StartPeriod"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Start Period (nanoseconds)</FormLabel>
|
||||
<FormDescription>
|
||||
Initial grace period before health checks begin
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Retries"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Retries</FormLabel>
|
||||
<FormDescription>
|
||||
Number of consecutive failures needed to consider container
|
||||
unhealthy
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="3" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset({
|
||||
Test: [],
|
||||
Interval: undefined,
|
||||
Timeout: undefined,
|
||||
StartPeriod: undefined,
|
||||
Retries: undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save Health Check
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export { EndpointSpecForm } from "./endpoint-spec-form";
|
||||
export { HealthCheckForm } from "./health-check-form";
|
||||
export { LabelsForm } from "./labels-form";
|
||||
export { ModeForm } from "./mode-form";
|
||||
export { NetworkForm } from "./network-form";
|
||||
export { PlacementForm } from "./placement-form";
|
||||
export { RestartPolicyForm } from "./restart-policy-form";
|
||||
export { RollbackConfigForm } from "./rollback-config-form";
|
||||
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
||||
export { UpdateConfigForm } from "./update-config-form";
|
||||
export { filterEmptyValues, hasValues } from "./utils";
|
||||
@@ -0,0 +1,200 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const labelsFormSchema = z.object({
|
||||
labels: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface LabelsFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
}
|
||||
|
||||
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<any>({
|
||||
resolver: zodResolver(labelsFormSchema),
|
||||
defaultValues: {
|
||||
labels: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "labels",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.labelsSwarm && typeof data.labelsSwarm === "object") {
|
||||
const labelEntries = Object.entries(data.labelsSwarm).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
value: value as string,
|
||||
}),
|
||||
);
|
||||
form.reset({ labels: labelEntries });
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (formData: z.infer<typeof labelsFormSchema>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const labelsObject =
|
||||
formData.labels?.reduce(
|
||||
(acc, { key, value }) => {
|
||||
if (key && value) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
) || {};
|
||||
|
||||
// If no labels, send null to clear the database
|
||||
const labelsToSend =
|
||||
Object.keys(labelsObject).length > 0 ? labelsObject : null;
|
||||
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
labelsSwarm: labelsToSend,
|
||||
});
|
||||
|
||||
toast.success("Labels updated successfully");
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error("Error updating labels");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<FormLabel>Labels</FormLabel>
|
||||
<FormDescription>
|
||||
Add key-value labels to your service
|
||||
</FormDescription>
|
||||
<div className="space-y-2 mt-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`labels.${index}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="com.example.app.name" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`labels.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="my-app" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ key: "", value: "" })}
|
||||
>
|
||||
Add Label
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset({ labels: [] });
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save Labels
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface ModeFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
}
|
||||
|
||||
export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<any>({
|
||||
defaultValues: {
|
||||
type: undefined,
|
||||
Replicas: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const modeType = form.watch("type");
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.modeSwarm) {
|
||||
const mode = data.modeSwarm;
|
||||
if (mode.Replicated) {
|
||||
form.reset({
|
||||
type: "Replicated",
|
||||
Replicas: mode.Replicated.Replicas,
|
||||
});
|
||||
} else if (mode.Global) {
|
||||
form.reset({
|
||||
type: "Global",
|
||||
Replicas: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (formData: any) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// If no type is selected, send null to clear the database
|
||||
if (!formData.type) {
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
modeSwarm: null,
|
||||
});
|
||||
toast.success("Mode updated successfully");
|
||||
refetch();
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const modeData =
|
||||
formData.type === "Replicated"
|
||||
? {
|
||||
Replicated: {
|
||||
Replicas:
|
||||
formData.Replicas !== undefined && formData.Replicas !== ""
|
||||
? Number(formData.Replicas)
|
||||
: undefined,
|
||||
},
|
||||
}
|
||||
: { Global: {} };
|
||||
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
modeSwarm: modeData,
|
||||
});
|
||||
|
||||
toast.success("Mode updated successfully");
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error("Error updating mode");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Mode Type</FormLabel>
|
||||
<FormDescription>
|
||||
Choose between replicated or global service mode
|
||||
</FormDescription>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select mode type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="Replicated">Replicated</SelectItem>
|
||||
<SelectItem value="Global">Global</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{modeType === "Replicated" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Replicas"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Replicas</FormLabel>
|
||||
<FormDescription>Number of replicas to run</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="1" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset({
|
||||
type: undefined,
|
||||
Replicas: undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save Mode
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,313 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const driverOptEntrySchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const networkFormSchema = z.object({
|
||||
networks: z
|
||||
.array(
|
||||
z.object({
|
||||
Target: z.string().optional(),
|
||||
Aliases: z.string().optional(),
|
||||
DriverOptsEntries: z.array(driverOptEntrySchema).optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
interface NetworkFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
}
|
||||
|
||||
export const NetworkForm = ({ id, type }: NetworkFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<z.infer<typeof networkFormSchema>>({
|
||||
resolver: zodResolver(networkFormSchema),
|
||||
defaultValues: {
|
||||
networks: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "networks",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.networkSwarm && Array.isArray(data.networkSwarm)) {
|
||||
const networkEntries = data.networkSwarm.map((network) => ({
|
||||
Target: network.Target || "",
|
||||
Aliases: network.Aliases?.join(", ") || "",
|
||||
DriverOptsEntries: network.DriverOpts
|
||||
? Object.entries(network.DriverOpts).map(([key, value]) => ({
|
||||
key,
|
||||
value: value ?? "",
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
form.reset({ networks: networkEntries });
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (formData: z.infer<typeof networkFormSchema>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const networksArray =
|
||||
formData.networks
|
||||
?.filter((network) => network.Target)
|
||||
.map((network) => {
|
||||
const entries = (network.DriverOptsEntries ?? []).filter(
|
||||
(e) => e.key.trim() !== "",
|
||||
);
|
||||
const driverOpts =
|
||||
entries.length > 0
|
||||
? Object.fromEntries(
|
||||
entries.map((e) => [e.key.trim(), e.value]),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
Target: network.Target,
|
||||
Aliases: network.Aliases
|
||||
? network.Aliases.split(",").map((alias) => alias.trim())
|
||||
: undefined,
|
||||
DriverOpts: driverOpts,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
// If no networks, send null to clear the database
|
||||
const networksToSend = networksArray.length > 0 ? networksArray : null;
|
||||
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
networkSwarm: networksToSend,
|
||||
});
|
||||
|
||||
toast.success("Network configuration updated successfully");
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error("Error updating network configuration");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<FormLabel>Networks</FormLabel>
|
||||
<FormDescription>
|
||||
Configure network attachments for your service
|
||||
</FormDescription>
|
||||
<div className="space-y-2 mt-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="space-y-2 p-3 border rounded">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.Target`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Network Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="my-network" />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The name of the network to attach to
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.Aliases`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Aliases (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="alias1, alias2, alias3"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Comma-separated list of network aliases
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Driver options (optional)</FormLabel>
|
||||
<FormDescription>
|
||||
e.g. com.docker.network.driver.mtu,
|
||||
com.docker.network.driver.host_binding
|
||||
</FormDescription>
|
||||
{(
|
||||
form.watch(`networks.${index}.DriverOptsEntries`) ?? []
|
||||
).map((_, optIndex) => (
|
||||
<div
|
||||
key={optIndex}
|
||||
className="flex gap-2 items-end flex-wrap"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.DriverOptsEntries.${optIndex}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1 min-w-[140px]">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="com.docker.network.driver.mtu"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`networks.${index}.DriverOptsEntries.${optIndex}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1 min-w-[100px]">
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="1500" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const entries =
|
||||
form.getValues(
|
||||
`networks.${index}.DriverOptsEntries`,
|
||||
) ?? [];
|
||||
form.setValue(
|
||||
`networks.${index}.DriverOptsEntries`,
|
||||
entries.filter((_, i) => i !== optIndex),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const entries =
|
||||
form.getValues(`networks.${index}.DriverOptsEntries`) ??
|
||||
[];
|
||||
form.setValue(`networks.${index}.DriverOptsEntries`, [
|
||||
...entries,
|
||||
{ key: "", value: "" },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add driver option
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
Remove Network
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({
|
||||
Target: "",
|
||||
Aliases: "",
|
||||
DriverOptsEntries: [],
|
||||
})
|
||||
}
|
||||
>
|
||||
Add Network
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset({ networks: [] });
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save Networks
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,347 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const PreferenceSchema = z.object({
|
||||
SpreadDescriptor: z.string(),
|
||||
});
|
||||
|
||||
const PlatformSchema = z.object({
|
||||
Architecture: z.string(),
|
||||
OS: z.string(),
|
||||
});
|
||||
|
||||
export const placementFormSchema = z.object({
|
||||
Constraints: z.array(z.string()).optional(),
|
||||
Preferences: z.array(PreferenceSchema).optional(),
|
||||
MaxReplicas: z.coerce.number().optional(),
|
||||
Platforms: z.array(PlatformSchema).optional(),
|
||||
});
|
||||
|
||||
interface PlacementFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
}
|
||||
|
||||
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<any>({
|
||||
resolver: zodResolver(placementFormSchema),
|
||||
defaultValues: {
|
||||
Constraints: [],
|
||||
Preferences: [],
|
||||
MaxReplicas: undefined,
|
||||
Platforms: [],
|
||||
},
|
||||
});
|
||||
|
||||
const constraints = form.watch("Constraints") || [];
|
||||
const preferences = form.watch("Preferences") || [];
|
||||
const platforms = form.watch("Platforms") || [];
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.placementSwarm) {
|
||||
const placement = data.placementSwarm;
|
||||
form.reset({
|
||||
Constraints: placement.Constraints || [],
|
||||
Preferences:
|
||||
placement.Preferences?.map((p: any) => ({
|
||||
SpreadDescriptor: p.Spread?.SpreadDescriptor || "",
|
||||
})) || [],
|
||||
MaxReplicas: placement.MaxReplicas,
|
||||
Platforms: placement.Platforms || [],
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (formData: z.infer<typeof placementFormSchema>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Check if all values are empty, if so, send null to clear the database
|
||||
const hasAnyValue =
|
||||
(formData.Constraints && formData.Constraints.length > 0) ||
|
||||
(formData.Preferences && formData.Preferences.length > 0) ||
|
||||
(formData.Platforms && formData.Platforms.length > 0) ||
|
||||
formData.MaxReplicas !== undefined;
|
||||
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
placementSwarm: hasAnyValue
|
||||
? {
|
||||
...formData,
|
||||
Preferences: formData.Preferences?.map((p) => ({
|
||||
Spread: { SpreadDescriptor: p.SpreadDescriptor },
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
toast.success("Placement updated successfully");
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error("Error updating placement");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addConstraint = () => {
|
||||
form.setValue("Constraints", [...constraints, ""]);
|
||||
};
|
||||
|
||||
const updateConstraint = (index: number, value: string) => {
|
||||
const newConstraints = [...constraints];
|
||||
newConstraints[index] = value;
|
||||
form.setValue("Constraints", newConstraints);
|
||||
};
|
||||
|
||||
const removeConstraint = (index: number) => {
|
||||
form.setValue(
|
||||
"Constraints",
|
||||
constraints.filter((_: string, i: number) => i !== index),
|
||||
);
|
||||
};
|
||||
|
||||
const addPreference = () => {
|
||||
form.setValue("Preferences", [...preferences, { SpreadDescriptor: "" }]);
|
||||
};
|
||||
|
||||
const updatePreference = (index: number, value: string) => {
|
||||
const newPreferences = [...preferences];
|
||||
if (newPreferences[index]) {
|
||||
newPreferences[index].SpreadDescriptor = value;
|
||||
form.setValue("Preferences", newPreferences);
|
||||
}
|
||||
};
|
||||
|
||||
const removePreference = (index: number) => {
|
||||
form.setValue(
|
||||
"Preferences",
|
||||
preferences.filter((_: any, i: number) => i !== index),
|
||||
);
|
||||
};
|
||||
|
||||
const addPlatform = () => {
|
||||
form.setValue("Platforms", [...platforms, { Architecture: "", OS: "" }]);
|
||||
};
|
||||
|
||||
const updatePlatform = (
|
||||
index: number,
|
||||
field: "Architecture" | "OS",
|
||||
value: string,
|
||||
) => {
|
||||
const newPlatforms = [...platforms];
|
||||
if (newPlatforms[index]) {
|
||||
newPlatforms[index][field] = value;
|
||||
form.setValue("Platforms", newPlatforms);
|
||||
}
|
||||
};
|
||||
|
||||
const removePlatform = (index: number) => {
|
||||
form.setValue(
|
||||
"Platforms",
|
||||
platforms.filter((_: any, i: number) => i !== index),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<FormLabel>Constraints</FormLabel>
|
||||
<FormDescription>
|
||||
Placement constraints (e.g., "node.role==manager")
|
||||
</FormDescription>
|
||||
<div className="space-y-2 mt-2">
|
||||
{constraints.map((constraint: string, index: number) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={constraint}
|
||||
onChange={(e) => updateConstraint(index, e.target.value)}
|
||||
placeholder="node.role==manager"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removeConstraint(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addConstraint}
|
||||
>
|
||||
Add Constraint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormLabel>Preferences</FormLabel>
|
||||
<FormDescription>
|
||||
Spread preferences for task distribution (e.g.,
|
||||
"node.labels.region")
|
||||
</FormDescription>
|
||||
<div className="space-y-2 mt-2">
|
||||
{preferences.map((pref: any, index: number) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={pref.SpreadDescriptor}
|
||||
onChange={(e) => updatePreference(index, e.target.value)}
|
||||
placeholder="node.labels.region"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removePreference(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addPreference}
|
||||
>
|
||||
Add Preference
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="MaxReplicas"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max Replicas</FormLabel>
|
||||
<FormDescription>
|
||||
Maximum number of replicas per node
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<FormLabel>Platforms</FormLabel>
|
||||
<FormDescription>
|
||||
Target platforms for task scheduling
|
||||
</FormDescription>
|
||||
<div className="space-y-2 mt-2">
|
||||
{platforms.map((platform: any, index: number) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
value={platform.Architecture}
|
||||
onChange={(e) =>
|
||||
updatePlatform(index, "Architecture", e.target.value)
|
||||
}
|
||||
placeholder="amd64"
|
||||
/>
|
||||
<Input
|
||||
value={platform.OS}
|
||||
onChange={(e) => updatePlatform(index, "OS", e.target.value)}
|
||||
placeholder="linux"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removePlatform(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addPlatform}
|
||||
>
|
||||
Add Platform
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset({
|
||||
Constraints: [],
|
||||
Preferences: [],
|
||||
MaxReplicas: undefined,
|
||||
Platforms: [],
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save Placement
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const restartPolicyFormSchema = z.object({
|
||||
Condition: z.string().optional(),
|
||||
Delay: z.coerce.number().optional(),
|
||||
MaxAttempts: z.coerce.number().optional(),
|
||||
Window: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
interface RestartPolicyFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
}
|
||||
|
||||
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<any>({
|
||||
resolver: zodResolver(restartPolicyFormSchema),
|
||||
defaultValues: {
|
||||
Condition: undefined,
|
||||
Delay: undefined,
|
||||
MaxAttempts: undefined,
|
||||
Window: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.restartPolicySwarm) {
|
||||
form.reset({
|
||||
Condition: data.restartPolicySwarm.Condition,
|
||||
Delay: data.restartPolicySwarm.Delay,
|
||||
MaxAttempts: data.restartPolicySwarm.MaxAttempts,
|
||||
Window: data.restartPolicySwarm.Window,
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (
|
||||
formData: z.infer<typeof restartPolicyFormSchema>,
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Check if all values are empty, if so, send null to clear the database
|
||||
const hasAnyValue = Object.values(formData).some(
|
||||
(value) => value !== undefined && value !== null && value !== "",
|
||||
);
|
||||
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
restartPolicySwarm: hasAnyValue ? formData : null,
|
||||
});
|
||||
|
||||
toast.success("Restart policy updated successfully");
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error("Error updating restart policy");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Condition"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Condition</FormLabel>
|
||||
<FormDescription>When to restart the container</FormDescription>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select restart condition" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="on-failure">On Failure</SelectItem>
|
||||
<SelectItem value="any">Any</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Delay"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Delay (nanoseconds)</FormLabel>
|
||||
<FormDescription>
|
||||
Wait time between restart attempts
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="MaxAttempts"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max Attempts</FormLabel>
|
||||
<FormDescription>
|
||||
Maximum number of restart attempts
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="3" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Window"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Window (nanoseconds)</FormLabel>
|
||||
<FormDescription>
|
||||
Time window to evaluate restart policy
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset({
|
||||
Condition: undefined,
|
||||
Delay: undefined,
|
||||
MaxAttempts: undefined,
|
||||
Window: undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save Restart Policy
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,257 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const rollbackConfigFormSchema = z.object({
|
||||
Parallelism: z.coerce.number().optional(),
|
||||
Delay: z.coerce.number().optional(),
|
||||
FailureAction: z.string().optional(),
|
||||
Monitor: z.coerce.number().optional(),
|
||||
MaxFailureRatio: z.coerce.number().optional(),
|
||||
Order: z.string().optional(),
|
||||
});
|
||||
|
||||
interface RollbackConfigFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
}
|
||||
|
||||
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<any>({
|
||||
resolver: zodResolver(rollbackConfigFormSchema),
|
||||
defaultValues: {
|
||||
Parallelism: undefined,
|
||||
Delay: undefined,
|
||||
FailureAction: undefined,
|
||||
Monitor: undefined,
|
||||
MaxFailureRatio: undefined,
|
||||
Order: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.rollbackConfigSwarm) {
|
||||
form.reset(data.rollbackConfigSwarm);
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (
|
||||
formData: z.infer<typeof rollbackConfigFormSchema>,
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Check if all values are empty, if so, send null to clear the database
|
||||
const hasAnyValue = Object.values(formData).some(
|
||||
(value) => value !== undefined && value !== null && value !== "",
|
||||
);
|
||||
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||
});
|
||||
|
||||
toast.success("Rollback config updated successfully");
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error("Error updating rollback config");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Parallelism"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Parallelism</FormLabel>
|
||||
<FormDescription>
|
||||
Number of tasks to rollback simultaneously
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="1" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Delay"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Delay (nanoseconds)</FormLabel>
|
||||
<FormDescription>Delay between task rollbacks</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="FailureAction"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Failure Action</FormLabel>
|
||||
<FormDescription>Action on rollback failure</FormDescription>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select failure action" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="pause">Pause</SelectItem>
|
||||
<SelectItem value="continue">Continue</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Monitor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Monitor (nanoseconds)</FormLabel>
|
||||
<FormDescription>
|
||||
Duration to monitor for failure after rollback
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="MaxFailureRatio"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max Failure Ratio</FormLabel>
|
||||
<FormDescription>
|
||||
Maximum failure ratio tolerated (0-1)
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" placeholder="0.1" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Order"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Order</FormLabel>
|
||||
<FormDescription>Rollback order strategy</FormDescription>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select order" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="stop-first">Stop First</SelectItem>
|
||||
<SelectItem value="start-first">Start First</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset({
|
||||
Parallelism: undefined,
|
||||
Delay: undefined,
|
||||
FailureAction: undefined,
|
||||
Monitor: undefined,
|
||||
MaxFailureRatio: undefined,
|
||||
Order: undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save Rollback Config
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const hasStopGracePeriodSwarm = (
|
||||
value: unknown,
|
||||
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"stopGracePeriodSwarm" in value;
|
||||
|
||||
interface StopGracePeriodFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
}
|
||||
|
||||
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<any>({
|
||||
defaultValues: {
|
||||
value: null as bigint | null,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasStopGracePeriodSwarm(data)) {
|
||||
const value = data.stopGracePeriodSwarm;
|
||||
const normalizedValue =
|
||||
value === null || value === undefined
|
||||
? null
|
||||
: typeof value === "bigint"
|
||||
? value
|
||||
: BigInt(value);
|
||||
form.reset({
|
||||
value: normalizedValue,
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (formData: any) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
stopGracePeriodSwarm: formData.value,
|
||||
});
|
||||
|
||||
toast.success("Stop grace period updated successfully");
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error("Error updating stop grace period");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Stop Grace Period (nanoseconds)</FormLabel>
|
||||
<FormDescription>
|
||||
Time to wait before forcefully killing the container
|
||||
<br />
|
||||
Examples: 30000000000 (30s), 120000000000 (2m)
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="30000000000"
|
||||
{...field}
|
||||
value={
|
||||
field?.value !== null && field?.value !== undefined
|
||||
? field.value.toString()
|
||||
: ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
e.target.value ? BigInt(e.target.value) : null,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset({
|
||||
value: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save Stop Grace Period
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const updateConfigFormSchema = z.object({
|
||||
Parallelism: z.coerce.number().optional(),
|
||||
Delay: z.coerce.number().optional(),
|
||||
FailureAction: z.string().optional(),
|
||||
Monitor: z.coerce.number().optional(),
|
||||
MaxFailureRatio: z.coerce.number().optional(),
|
||||
Order: z.string().optional(),
|
||||
});
|
||||
|
||||
interface UpdateConfigFormProps {
|
||||
id: string;
|
||||
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||
}
|
||||
|
||||
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
application: () =>
|
||||
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.postgres.update.useMutation(),
|
||||
redis: () => api.redis.update.useMutation(),
|
||||
mysql: () => api.mysql.update.useMutation(),
|
||||
mariadb: () => api.mariadb.update.useMutation(),
|
||||
application: () => api.application.update.useMutation(),
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<any>({
|
||||
resolver: zodResolver(updateConfigFormSchema),
|
||||
defaultValues: {
|
||||
Parallelism: undefined,
|
||||
Delay: undefined,
|
||||
FailureAction: undefined,
|
||||
Monitor: undefined,
|
||||
MaxFailureRatio: undefined,
|
||||
Order: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.updateConfigSwarm) {
|
||||
const config = data.updateConfigSwarm;
|
||||
form.reset({
|
||||
Parallelism: config.Parallelism,
|
||||
Delay: config.Delay,
|
||||
FailureAction: config.FailureAction,
|
||||
Monitor: config.Monitor,
|
||||
MaxFailureRatio: config.MaxFailureRatio,
|
||||
Order: config.Order,
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (formData: z.infer<typeof updateConfigFormSchema>) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Check if all values are empty, if so, send null to clear the database
|
||||
const hasAnyValue = Object.values(formData).some(
|
||||
(value) => value !== undefined && value !== null && value !== "",
|
||||
);
|
||||
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
postgresId: id || "",
|
||||
redisId: id || "",
|
||||
mysqlId: id || "",
|
||||
mariadbId: id || "",
|
||||
mongoId: id || "",
|
||||
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||
});
|
||||
|
||||
toast.success("Update config updated successfully");
|
||||
refetch();
|
||||
} catch {
|
||||
toast.error("Error updating update config");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Parallelism"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Parallelism</FormLabel>
|
||||
<FormDescription>
|
||||
Number of tasks to update simultaneously
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="1" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Delay"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Delay (nanoseconds)</FormLabel>
|
||||
<FormDescription>Delay between task updates</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="FailureAction"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Failure Action</FormLabel>
|
||||
<FormDescription>Action on update failure</FormDescription>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select failure action" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="pause">Pause</SelectItem>
|
||||
<SelectItem value="continue">Continue</SelectItem>
|
||||
<SelectItem value="rollback">Rollback</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Monitor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Monitor (nanoseconds)</FormLabel>
|
||||
<FormDescription>
|
||||
Duration to monitor for failure after update
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="10000000000" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="MaxFailureRatio"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Max Failure Ratio</FormLabel>
|
||||
<FormDescription>
|
||||
Maximum failure ratio tolerated (0-1)
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" placeholder="0.1" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="Order"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Order</FormLabel>
|
||||
<FormDescription>Update order strategy</FormDescription>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select order" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="stop-first">Stop First</SelectItem>
|
||||
<SelectItem value="start-first">Start First</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
form.reset({
|
||||
Parallelism: undefined,
|
||||
Delay: undefined,
|
||||
FailureAction: undefined,
|
||||
Monitor: undefined,
|
||||
MaxFailureRatio: undefined,
|
||||
Order: undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save Update Config
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Filters out undefined, null, and empty string values from form data
|
||||
* Only returns fields that have actual values
|
||||
*/
|
||||
export const filterEmptyValues = (
|
||||
formData: Record<string, any>,
|
||||
): Record<string, any> => {
|
||||
return Object.entries(formData).reduce(
|
||||
(acc, [key, value]) => {
|
||||
// Keep arrays even if empty (they might be intentionally cleared)
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
acc[key] = value;
|
||||
}
|
||||
}
|
||||
// For other values, filter out undefined, null, and empty strings
|
||||
else if (value !== undefined && value !== null && value !== "") {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if filtered data has any values to save
|
||||
*/
|
||||
export const hasValues = (data: Record<string, any>): boolean => {
|
||||
return Object.keys(data).length > 0;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { InfoIcon, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -22,6 +22,17 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
createConverter,
|
||||
NumberInputWithSteps,
|
||||
} from "@/components/ui/number-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -30,13 +41,53 @@ 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 ulimitSchema = z.object({
|
||||
Name: z.string().min(1, "Name is required"),
|
||||
Soft: z.coerce.number().int().min(-1, "Must be >= -1"),
|
||||
Hard: z.coerce.number().int().min(-1, "Must be >= -1"),
|
||||
});
|
||||
|
||||
const addResourcesSchema = z.object({
|
||||
memoryReservation: z.string().optional(),
|
||||
cpuLimit: z.string().optional(),
|
||||
memoryLimit: z.string().optional(),
|
||||
cpuReservation: z.string().optional(),
|
||||
ulimitsSwarm: z.array(ulimitSchema).optional(),
|
||||
});
|
||||
|
||||
const ULIMIT_PRESETS = [
|
||||
{ value: "nofile", label: "nofile (Open Files)" },
|
||||
{ value: "nproc", label: "nproc (Processes)" },
|
||||
{ value: "memlock", label: "memlock (Locked Memory)" },
|
||||
{ value: "stack", label: "stack (Stack Size)" },
|
||||
{ value: "core", label: "core (Core File Size)" },
|
||||
{ value: "cpu", label: "cpu (CPU Time)" },
|
||||
{ value: "data", label: "data (Data Segment)" },
|
||||
{ value: "fsize", label: "fsize (File Size)" },
|
||||
{ value: "locks", label: "locks (File Locks)" },
|
||||
{ value: "msgqueue", label: "msgqueue (Message Queues)" },
|
||||
{ value: "nice", label: "nice (Nice Priority)" },
|
||||
{ value: "rtprio", label: "rtprio (Real-time Priority)" },
|
||||
{ value: "sigpending", label: "sigpending (Pending Signals)" },
|
||||
];
|
||||
|
||||
export type ServiceType =
|
||||
| "postgres"
|
||||
| "mongo"
|
||||
@@ -51,6 +102,7 @@ interface Props {
|
||||
}
|
||||
|
||||
type AddResources = z.infer<typeof addResourcesSchema>;
|
||||
|
||||
export const ShowResources = ({ id, type }: Props) => {
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
@@ -86,10 +138,16 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: "",
|
||||
memoryLimit: "",
|
||||
memoryReservation: "",
|
||||
ulimitsSwarm: [],
|
||||
},
|
||||
resolver: zodResolver(addResourcesSchema),
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "ulimitsSwarm",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
@@ -97,6 +155,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: data?.cpuReservation || undefined,
|
||||
memoryLimit: data?.memoryLimit || undefined,
|
||||
memoryReservation: data?.memoryReservation || undefined,
|
||||
ulimitsSwarm: data?.ulimitsSwarm || [],
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
@@ -113,6 +172,10 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: formData.cpuReservation || null,
|
||||
memoryLimit: formData.memoryLimit || null,
|
||||
memoryReservation: formData.memoryReservation || null,
|
||||
ulimitsSwarm:
|
||||
formData.ulimitsSwarm && formData.ulimitsSwarm.length > 0
|
||||
? formData.ulimitsSwarm
|
||||
: null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Resources Updated");
|
||||
@@ -163,16 +226,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 />
|
||||
@@ -198,16 +265,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 />
|
||||
@@ -234,17 +305,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 />
|
||||
@@ -271,14 +345,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>
|
||||
@@ -286,6 +367,145 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ulimits Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="text-base">Ulimits</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>
|
||||
Set resource limits for the container. Each ulimit has
|
||||
a soft limit (warning threshold) and hard limit
|
||||
(maximum allowed). Use -1 for unlimited.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({ Name: "nofile", Soft: 65535, Hard: 65535 })
|
||||
}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Ulimit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ulimitsSwarm.${index}.Name`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel className="text-xs">Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select ulimit" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{ULIMIT_PRESETS.map((preset) => (
|
||||
<SelectItem
|
||||
key={preset.value}
|
||||
value={preset.value}
|
||||
>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ulimitsSwarm.${index}.Soft`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-32">
|
||||
<FormLabel className="text-xs">
|
||||
Soft Limit
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={-1}
|
||||
placeholder="65535"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`ulimitsSwarm.${index}.Hard`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-32">
|
||||
<FormLabel className="text-xs">
|
||||
Hard Limit
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={-1}
|
||||
placeholder="65535"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-6 text-destructive hover:text-destructive"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No ulimits configured. Click "Add Ulimit" to set
|
||||
resource limits.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
Save
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const UpdateTraefikConfigSchema = z.object({
|
||||
@@ -59,6 +61,7 @@ export const validateAndFormatYAML = (yamlText: string) => {
|
||||
|
||||
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
@@ -85,13 +88,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
}, [data]);
|
||||
|
||||
const onSubmit = async (data: UpdateTraefikConfig) => {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: (error as string) || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
if (!skipYamlValidation) {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: (error as string) || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
form.clearErrors("traefikConfig");
|
||||
await mutateAsync({
|
||||
@@ -116,6 +121,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setSkipYamlValidation(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -169,7 +175,28 @@ routers:
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="flex-col sm:flex-row gap-4">
|
||||
<div className="flex flex-col gap-1 w-full sm:w-auto sm:mr-auto">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="skip-yaml-validation-app"
|
||||
checked={skipYamlValidation}
|
||||
onCheckedChange={(checked) =>
|
||||
setSkipYamlValidation(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="skip-yaml-validation-app"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Skip YAML validation (for Go templating)
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Check to save configs with Go templating (e.g.{" "}
|
||||
<code className="text-xs">{"{{range}}"}</code>).
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-update-traefik-config"
|
||||
|
||||
@@ -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,9 +196,22 @@ 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]);
|
||||
|
||||
// Hide builder section when Docker provider is selected
|
||||
if (data?.sourceType === "docker") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSubmit = async (data: AddTemplate) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
@@ -186,7 +232,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 +449,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">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Paintbrush } from "lucide-react";
|
||||
import { Ban } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -35,7 +35,7 @@ export const CancelQueues = ({ id, type }: Props) => {
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
|
||||
Cancel Queues
|
||||
<Paintbrush className="size-4" />
|
||||
<Ban className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Paintbrush } 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 ClearDeployments = ({ id, type }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, isLoading } =
|
||||
type === "application"
|
||||
? api.application.clearDeployments.useMutation()
|
||||
: api.compose.clearDeployments.useMutation();
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="w-fit" isLoading={isLoading}>
|
||||
Clear deployments
|
||||
<Paintbrush className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure you want to clear old deployments?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will delete all old deployment records and logs, keeping only
|
||||
the active deployment (the most recent successful one).
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
composeId: id || "",
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Old deployments cleared successfully");
|
||||
await utils.deployment.allByType.invalidate({
|
||||
id,
|
||||
type: type as "application" | "compose",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
RefreshCcw,
|
||||
RocketIcon,
|
||||
Settings,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
import { api, type RouterOutputs } from "@/utils/api";
|
||||
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
|
||||
import { CancelQueues } from "./cancel-queues";
|
||||
import { ClearDeployments } from "./clear-deployments";
|
||||
import { KillBuild } from "./kill-build";
|
||||
import { RefreshToken } from "./refresh-token";
|
||||
import { ShowDeployment } from "./show-deployment";
|
||||
@@ -77,6 +79,8 @@ export const ShowDeployments = ({
|
||||
api.rollback.rollback.useMutation();
|
||||
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
|
||||
api.deployment.killProcess.useMutation();
|
||||
const { mutateAsync: removeDeployment, isLoading: isRemovingDeployment } =
|
||||
api.deployment.removeDeployment.useMutation();
|
||||
|
||||
// Cancel deployment mutations
|
||||
const {
|
||||
@@ -143,7 +147,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") && (
|
||||
<ClearDeployments id={id} type={type} />
|
||||
)}
|
||||
{(type === "application" || type === "compose") && (
|
||||
<KillBuild id={id} type={type} />
|
||||
)}
|
||||
@@ -252,13 +259,15 @@ export const ShowDeployments = ({
|
||||
const isExpanded = expandedDescriptions.has(
|
||||
deployment.deploymentId,
|
||||
);
|
||||
const canDelete =
|
||||
deployment.status === "done" || deployment.status === "error";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={deployment.deploymentId}
|
||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||
className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<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
|
||||
@@ -313,8 +322,8 @@ export const ShowDeployments = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 max-w-[300px] w-full justify-start">
|
||||
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
||||
<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
|
||||
@@ -333,7 +342,7 @@ export const ShowDeployments = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<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="Kill Process"
|
||||
@@ -355,6 +364,7 @@ export const ShowDeployments = ({
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isLoading={isKillingProcess}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Kill Process
|
||||
</Button>
|
||||
@@ -364,16 +374,56 @@ export const ShowDeployments = ({
|
||||
onClick={() => {
|
||||
setActiveLog(deployment);
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
|
||||
{canDelete && (
|
||||
<DialogAction
|
||||
title="Delete Deployment"
|
||||
description="Are you sure you want to delete this deployment? This action cannot be undone."
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await removeDeployment({
|
||||
deploymentId: deployment.deploymentId,
|
||||
});
|
||||
toast.success("Deployment deleted successfully");
|
||||
} catch (error) {
|
||||
toast.error("Error deleting deployment");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isLoading={isRemovingDeployment}
|
||||
>
|
||||
Delete
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
|
||||
{deployment?.rollback &&
|
||||
deployment.status === "done" &&
|
||||
type === "application" && (
|
||||
<DialogAction
|
||||
title="Rollback to this deployment"
|
||||
description="Are you sure you want to 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({
|
||||
@@ -393,6 +443,7 @@ export const ShowDeployments = ({
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isLoading={isRollingBack}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||
Rollback
|
||||
@@ -407,7 +458,7 @@ export const ShowDeployments = ({
|
||||
</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>
|
||||
|
||||
@@ -5,14 +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>;
|
||||
@@ -39,6 +48,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
env: "",
|
||||
buildArgs: "",
|
||||
buildSecrets: "",
|
||||
createEnvFile: true,
|
||||
},
|
||||
resolver: zodResolver(addEnvironmentSchema),
|
||||
});
|
||||
@@ -47,10 +57,12 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
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 || "") ||
|
||||
currentBuildSecrets !== (data?.buildSecrets || "");
|
||||
currentBuildSecrets !== (data?.buildSecrets || "") ||
|
||||
currentCreateEnvFile !== (data?.createEnvFile ?? true);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -58,6 +70,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
env: data.env || "",
|
||||
buildArgs: data.buildArgs || "",
|
||||
buildSecrets: data.buildSecrets || "",
|
||||
createEnvFile: data.createEnvFile ?? true,
|
||||
});
|
||||
}
|
||||
}, [data, form]);
|
||||
@@ -67,6 +80,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
env: formData.env,
|
||||
buildArgs: formData.buildArgs,
|
||||
buildSecrets: formData.buildSecrets,
|
||||
createEnvFile: formData.createEnvFile,
|
||||
applicationId,
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -83,6 +97,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
env: data?.env || "",
|
||||
buildArgs: data?.buildArgs || "",
|
||||
buildSecrets: data?.buildSecrets || "",
|
||||
createEnvFile: data?.createEnvFile ?? true,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -167,6 +182,31 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
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}>
|
||||
|
||||
@@ -54,6 +54,7 @@ const BitbucketProviderSchema = z.object({
|
||||
.object({
|
||||
repo: z.string().min(1, "Repo is required"),
|
||||
owner: z.string().min(1, "Owner is required"),
|
||||
slug: z.string().optional(),
|
||||
})
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
@@ -82,6 +83,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
repository: {
|
||||
owner: "",
|
||||
repo: "",
|
||||
slug: "",
|
||||
},
|
||||
bitbucketId: "",
|
||||
branch: "",
|
||||
@@ -114,11 +116,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
} = api.bitbucket.getBitbucketBranches.useQuery(
|
||||
{
|
||||
owner: repository?.owner,
|
||||
repo: repository?.repo,
|
||||
repo: repository?.slug || repository?.repo || "",
|
||||
bitbucketId,
|
||||
},
|
||||
{
|
||||
enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId,
|
||||
enabled:
|
||||
!!repository?.owner &&
|
||||
!!(repository?.slug || repository?.repo) &&
|
||||
!!bitbucketId,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -129,6 +134,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
repository: {
|
||||
repo: data.bitbucketRepository || "",
|
||||
owner: data.bitbucketOwner || "",
|
||||
slug: data.bitbucketRepositorySlug || "",
|
||||
},
|
||||
buildPath: data.bitbucketBuildPath || "/",
|
||||
bitbucketId: data.bitbucketId || "",
|
||||
@@ -142,6 +148,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
await mutateAsync({
|
||||
bitbucketBranch: data.branch,
|
||||
bitbucketRepository: data.repository.repo,
|
||||
bitbucketRepositorySlug: data.repository.slug || data.repository.repo,
|
||||
bitbucketOwner: data.repository.owner,
|
||||
bitbucketBuildPath: data.buildPath,
|
||||
bitbucketId: data.bitbucketId,
|
||||
@@ -181,6 +188,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
repo: "",
|
||||
slug: "",
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
@@ -217,7 +225,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Repository</FormLabel>
|
||||
{field.value.owner && field.value.repo && (
|
||||
<Link
|
||||
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
|
||||
href={`https://bitbucket.org/${field.value.owner}/${field.value.slug || field.value.repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
@@ -237,13 +245,13 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -255,11 +263,15 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!bitbucketId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a Bitbucket account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
@@ -271,6 +283,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
form.setValue("repository", {
|
||||
owner: repo.owner.username as string,
|
||||
repo: repo.name,
|
||||
slug: repo.slug,
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
|
||||
@@ -258,14 +258,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo: GiteaRepository) =>
|
||||
repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -277,11 +277,15 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!giteaId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a Gitea account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -233,13 +233,13 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -251,11 +251,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!githubId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a GitHub account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -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"
|
||||
@@ -254,13 +254,13 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -272,11 +272,15 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!gitlabId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a GitLab account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -34,6 +34,7 @@ export const DockerLogs = dynamic(
|
||||
export const badgeStateColor = (state: string) => {
|
||||
switch (state) {
|
||||
case "running":
|
||||
case "ready":
|
||||
return "green";
|
||||
case "exited":
|
||||
case "shutdown":
|
||||
@@ -142,6 +143,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.status ? ` ${container.status}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
@@ -157,6 +159,9 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.currentState
|
||||
? ` ${container.currentState}`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
@@ -166,6 +171,13 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{option === "swarm" &&
|
||||
services?.find((c) => c.containerId === containerId)?.error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
|
||||
<span className="font-medium">Error: </span>
|
||||
{services?.find((c) => c.containerId === containerId)?.error}
|
||||
</div>
|
||||
)}
|
||||
<DockerLogs
|
||||
serverId={serverId || ""}
|
||||
containerId={containerId || "select-a-container"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import {
|
||||
ExternalLink,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
Hammer,
|
||||
Loader2,
|
||||
PenSquare,
|
||||
RocketIcon,
|
||||
@@ -22,6 +24,12 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -193,6 +206,58 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
</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}`}
|
||||
domainId={deployment.domain?.domainId}
|
||||
|
||||
@@ -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 {
|
||||
@@ -100,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);
|
||||
@@ -120,7 +123,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
previewCertificateType: data.previewCertificateType || "none",
|
||||
previewCustomCertResolver: data.previewCustomCertResolver || "",
|
||||
previewRequireCollaboratorPermissions:
|
||||
data.previewRequireCollaboratorPermissions || true,
|
||||
data.previewRequireCollaboratorPermissions ?? true,
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
@@ -168,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)}
|
||||
|
||||
@@ -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,
|
||||
@@ -13,6 +15,14 @@ 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: "* * * * *" },
|
||||
@@ -75,6 +92,7 @@ const formSchema = z
|
||||
"dokploy-server",
|
||||
]),
|
||||
script: z.string(),
|
||||
timezone: z.string().optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.scheduleType === "compose" && !data.serviceName) {
|
||||
@@ -213,6 +231,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
serviceName: "",
|
||||
scheduleType: scheduleType || "application",
|
||||
script: "",
|
||||
timezone: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -251,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]);
|
||||
@@ -464,6 +484,89 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
formControl={form.control}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timezone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
Timezone
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Select a timezone for the schedule. If not
|
||||
specified, UTC will be used.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<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>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search timezone..."
|
||||
className="h-9"
|
||||
/>
|
||||
<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>
|
||||
Optional: Choose a timezone for the schedule execution time
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(scheduleTypeForm === "application" ||
|
||||
scheduleTypeForm === "compose") && (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -54,6 +54,7 @@ const BitbucketProviderSchema = z.object({
|
||||
.object({
|
||||
repo: z.string().min(1, "Repo is required"),
|
||||
owner: z.string().min(1, "Owner is required"),
|
||||
slug: z.string().optional(),
|
||||
})
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
@@ -82,6 +83,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
repository: {
|
||||
owner: "",
|
||||
repo: "",
|
||||
slug: "",
|
||||
},
|
||||
bitbucketId: "",
|
||||
branch: "",
|
||||
@@ -114,11 +116,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
} = api.bitbucket.getBitbucketBranches.useQuery(
|
||||
{
|
||||
owner: repository?.owner,
|
||||
repo: repository?.repo,
|
||||
repo: repository?.slug || repository?.repo || "",
|
||||
bitbucketId,
|
||||
},
|
||||
{
|
||||
enabled: !!repository?.owner && !!repository?.repo && !!bitbucketId,
|
||||
enabled:
|
||||
!!repository?.owner &&
|
||||
!!(repository?.slug || repository?.repo) &&
|
||||
!!bitbucketId,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -129,6 +134,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
repository: {
|
||||
repo: data.bitbucketRepository || "",
|
||||
owner: data.bitbucketOwner || "",
|
||||
slug: data.bitbucketRepositorySlug || "",
|
||||
},
|
||||
composePath: data.composePath,
|
||||
bitbucketId: data.bitbucketId || "",
|
||||
@@ -142,6 +148,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
await mutateAsync({
|
||||
bitbucketBranch: data.branch,
|
||||
bitbucketRepository: data.repository.repo,
|
||||
bitbucketRepositorySlug: data.repository.slug || data.repository.repo,
|
||||
bitbucketOwner: data.repository.owner,
|
||||
bitbucketId: data.bitbucketId,
|
||||
composePath: data.composePath,
|
||||
@@ -183,6 +190,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
repo: "",
|
||||
slug: "",
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
@@ -219,7 +227,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<FormLabel>Repository</FormLabel>
|
||||
{field.value.owner && field.value.repo && (
|
||||
<Link
|
||||
href={`https://bitbucket.org/${field.value.owner}/${field.value.repo}`}
|
||||
href={`https://bitbucket.org/${field.value.owner}/${field.value.slug || field.value.repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
@@ -239,13 +247,13 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -257,11 +265,15 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!bitbucketId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a Bitbucket account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
@@ -273,6 +285,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
form.setValue("repository", {
|
||||
owner: repo.owner.username as string,
|
||||
repo: repo.name,
|
||||
slug: repo.slug,
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
|
||||
@@ -244,13 +244,13 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
@@ -261,11 +261,15 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!giteaId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a Gitea account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -234,13 +234,13 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -252,11 +252,15 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!githubId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a GitHub account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -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,
|
||||
@@ -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"
|
||||
@@ -246,13 +256,13 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
{!field.value.owner
|
||||
? "Select repository"
|
||||
: isLoadingRepositories
|
||||
? "Loading...."
|
||||
: (repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
)?.name ?? "Select repository")}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -264,11 +274,15 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
{!gitlabId ? (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a GitLab account first
|
||||
</span>
|
||||
) : isLoadingRepositories ? (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
|
||||
@@ -128,6 +128,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.status ? ` ${container.status}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
@@ -143,6 +144,9 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.currentState
|
||||
? ` ${container.currentState}`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
@@ -152,6 +156,13 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{option === "swarm" &&
|
||||
services?.find((c) => c.containerId === containerId)?.error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-3 py-2 text-sm text-destructive">
|
||||
<span className="font-medium">Error: </span>
|
||||
{services.find((c) => c.containerId === containerId)?.error}
|
||||
</div>
|
||||
)}
|
||||
<DockerLogs
|
||||
serverId={serverId || ""}
|
||||
containerId={containerId || "select-a-container"}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -93,6 +93,7 @@ export const ShowDockerLogsCompose = ({
|
||||
<Badge variant={badgeStateColor(container.state)}>
|
||||
{container.state}
|
||||
</Badge>
|
||||
{container.status ? ` ${container.status}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Containers ({data?.length})</SelectLabel>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
import { validateAndFormatYAML } from "../application/advanced/traefik/update-traefik-config";
|
||||
|
||||
@@ -47,6 +49,7 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
|
||||
},
|
||||
);
|
||||
const [canEdit, setCanEdit] = useState(true);
|
||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.settings.updateTraefikFile.useMutation();
|
||||
@@ -66,13 +69,15 @@ export const ShowTraefikFile = ({ path, serverId }: Props) => {
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const onSubmit = async (data: UpdateServerMiddlewareConfig) => {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: error || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
if (!skipYamlValidation) {
|
||||
const { valid, error } = validateAndFormatYAML(data.traefikConfig);
|
||||
if (!valid) {
|
||||
form.setError("traefikConfig", {
|
||||
type: "manual",
|
||||
message: error || "Invalid YAML",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
form.clearErrors("traefikConfig");
|
||||
await mutateAsync({
|
||||
@@ -153,14 +158,37 @@ routers:
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
disabled={canEdit || isLoading}
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="skip-yaml-validation"
|
||||
checked={skipYamlValidation}
|
||||
onCheckedChange={(checked) =>
|
||||
setSkipYamlValidation(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="skip-yaml-validation"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Skip YAML validation (for Go templating)
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground -mt-2">
|
||||
Traefik supports Go templating in dynamic configs (e.g.{" "}
|
||||
<code className="text-xs">{"{{range}}"}</code>). Configs using
|
||||
templates will fail standard YAML validation. Check this to save
|
||||
without validation.
|
||||
</p>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
disabled={canEdit || isLoading}
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -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}
|
||||
@@ -283,10 +285,14 @@ export const ImpersonationBar = () => {
|
||||
<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">
|
||||
@@ -299,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">
|
||||
|
||||
@@ -73,8 +73,8 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -73,8 +73,8 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
@@ -113,7 +129,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Docker Image</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="postgres:15" {...field} />
|
||||
<Input placeholder="postgres:18" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
@@ -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
|
||||
|
||||
@@ -75,8 +75,8 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -58,7 +58,7 @@ const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
||||
mongo: "mongo:7",
|
||||
mariadb: "mariadb:11",
|
||||
mysql: "mysql:8",
|
||||
postgres: "postgres:15",
|
||||
postgres: "postgres:18",
|
||||
redis: "redis:7",
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
@@ -109,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}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,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}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,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) {
|
||||
@@ -246,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"
|
||||
@@ -273,22 +259,21 @@ export const AdvancedEnvironmentSelector = ({
|
||||
>
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
{canDeleteEnvironments && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openDeleteDialog(environment);
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{canDeleteEnvironments && !environment.isDefault && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openDeleteDialog(environment);
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -54,16 +55,23 @@ import {
|
||||
} 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";
|
||||
@@ -75,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
|
||||
@@ -130,7 +165,7 @@ export const ShowProjects = () => {
|
||||
}
|
||||
return direction === "asc" ? comparison : -comparison;
|
||||
});
|
||||
}, [data, searchQuery, sortBy]);
|
||||
}, [data, debouncedSearchQuery, sortBy]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -138,7 +173,7 @@ export const ShowProjects = () => {
|
||||
list={[{ name: "Projects", href: "/dashboard/projects" }]}
|
||||
/>
|
||||
{!isCloud && (
|
||||
<div className="absolute top-5 right-5">
|
||||
<div className="absolute top-4 right-4">
|
||||
<TimeBadge />
|
||||
</div>
|
||||
)}
|
||||
@@ -155,7 +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>
|
||||
@@ -251,13 +288,29 @@ export const ShowProjects = () => {
|
||||
)
|
||||
.some(Boolean);
|
||||
|
||||
// Find default environment from accessible environments, or fall back to first accessible environment
|
||||
const accessibleEnvironment =
|
||||
project?.environments.find((env) => env.isDefault) ||
|
||||
project?.environments?.[0];
|
||||
|
||||
const hasNoEnvironments = !accessibleEnvironment;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={project.projectId}
|
||||
className="w-full lg:max-w-md"
|
||||
>
|
||||
<Link
|
||||
href={`/dashboard/project/${project.projectId}/environment/${project?.environments?.[0]?.environmentId}`}
|
||||
href={
|
||||
hasNoEnvironments
|
||||
? "#"
|
||||
: `/dashboard/project/${project.projectId}/environment/${accessibleEnvironment?.environmentId}`
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (hasNoEnvironments) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
|
||||
{haveServicesWithDomains ? (
|
||||
@@ -377,8 +430,8 @@ export const ShowProjects = () => {
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between gap-2">
|
||||
<span className="flex flex-col gap-1.5">
|
||||
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
|
||||
<span className="flex flex-col gap-1.5 ">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookIcon className="size-4 text-muted-foreground" />
|
||||
<span className="text-base font-medium leading-none">
|
||||
@@ -386,9 +439,19 @@ export const ShowProjects = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
<span className="text-sm font-medium text-muted-foreground break-normal">
|
||||
{project.description}
|
||||
</span>
|
||||
|
||||
{hasNoEnvironments && (
|
||||
<div className="flex flex-row gap-2 items-center rounded-lg bg-yellow-50 p-2 mt-2 dark:bg-yellow-950">
|
||||
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
|
||||
<span className="text-xs text-yellow-600 dark:text-yellow-400">
|
||||
You have access to this project but no
|
||||
environments are available
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex self-start space-x-1">
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -74,8 +74,8 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
.catch((error: Error) => {
|
||||
toast.error(error?.message || "Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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 from accessible environments, or fall back to first accessible 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>
|
||||
);
|
||||
})}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user