mirror of
https://github.com/dokploy/dokploy.git
synced 2026-06-14 11:29:49 +00:00
Compare commits
649 Commits
core-model
...
v0.28.7
| Author | SHA1 | Date | |
|---|---|---|---|
| 9067452a38 | |||
| 1fa4d5b2ba | |||
| bade36ea9d | |||
| 0c22041623 | |||
| cccee05173 | |||
| 9f9c8fccf2 | |||
| ad2e53a67a | |||
| 00f3853bd7 | |||
| 2880327e94 | |||
| 827b84f57e | |||
| 11aa8fe0c5 | |||
| b9ac720d99 | |||
| 77b0ff7bbf | |||
| e7af2c0ebd | |||
| 6a1bedb90f | |||
| a2f142174b | |||
| f4ce304a04 | |||
| bb521f3e7e | |||
| baaa470234 | |||
| 4871520dbb | |||
| dad49ec96f | |||
| ce4e37c75b | |||
| c317ec39cb | |||
| a4e9c6e890 | |||
| 72fb85f616 | |||
| 1e7a6f2071 | |||
| 5ffd664570 | |||
| 947100c041 | |||
| 5410a56638 | |||
| 8127dc4536 | |||
| 2f37235aea | |||
| 290267bca4 | |||
| 8eace173b9 | |||
| c9a9ed8164 | |||
| 30428053e8 | |||
| 1c0dbbcfd6 | |||
| 178f4fbdf7 | |||
| 2c07a4b2e3 | |||
| 75a797097b | |||
| 2879816e41 | |||
| 3501996b9e | |||
| 47556a6486 | |||
| e554adc376 | |||
| 1804b935f6 | |||
| 985c9102da | |||
| 2e03cf3d48 | |||
| 33532d3cf7 | |||
| a6999b1cf2 | |||
| f5d18d6f9b | |||
| e3ff7ef3e3 | |||
| b84bc9b7c6 | |||
| de201d0b0a | |||
| 6866e2b63a | |||
| 3e4a1b92eb | |||
| b9ca6ea9db | |||
| f1d4543d5e | |||
| d8c7c1eaf4 | |||
| 4330d7bd99 | |||
| 6e67864204 | |||
| 2102840bb9 | |||
| 30f061e774 | |||
| c00aa6acbf | |||
| 8e9ab98a7a | |||
| ce82e2322b | |||
| ec7df05990 | |||
| 75a4e8e8ef | |||
| b4319c7ea2 | |||
| e9787b753d | |||
| b419294b09 | |||
| 922b4d58f1 | |||
| dc8ff78ee5 | |||
| 735c9952d8 | |||
| 21821295e3 | |||
| a8467e80e8 | |||
| 95e14b4199 | |||
| 076262e479 | |||
| c4f4db3ebc | |||
| 4882bd25ad | |||
| 7a8f2e53d5 | |||
| 50182a8048 | |||
| 35d35028f6 | |||
| a5a4a1a818 | |||
| c106d13ab5 | |||
| 808001d8de | |||
| ce24eadbb4 | |||
| b87f8cc5d8 | |||
| f650200771 | |||
| f961dc6e7a | |||
| 4be25da185 | |||
| 675c1d7a7d | |||
| 28cc361c47 | |||
| cedec5239f | |||
| 2f4cbbd3ac | |||
| 38b20450dc | |||
| 49f43ab3fb | |||
| 2eae756cec | |||
| 70c261d021 | |||
| 9ae2ebff46 | |||
| 8ce880d108 | |||
| 34304526b1 | |||
| a16c4c1294 | |||
| d1c4ac20e3 | |||
| 0195119a86 | |||
| 48a577e792 | |||
| bf7a75dd9f | |||
| d316aa4401 | |||
| f1b2cc35b3 | |||
| d2fabc998d | |||
| 7185047eb7 | |||
| 7121fbe50a | |||
| 36cf3a69fc | |||
| c34a01a173 | |||
| 9ac147a140 | |||
| 20f79ac655 | |||
| 6f21f1cc1f | |||
| af76548482 | |||
| 13638d0f04 | |||
| edceebec7e | |||
| 7599565e73 | |||
| 08c9113405 | |||
| 1014d4674c | |||
| 39b40c58bb | |||
| 1861e10b2a | |||
| 964e3c4150 | |||
| e05f31d8c6 | |||
| cc3b902d1e | |||
| 6c1f2372ed | |||
| 7da69862e1 | |||
| 612e73bb80 | |||
| a360a259f5 | |||
| 149293f4d3 | |||
| a8a5e1c6f1 | |||
| 4ede21eda9 | |||
| e275e9162e | |||
| 60a6dc5fab | |||
| 705c5bc1c9 | |||
| 8d56544c1d | |||
| ca527ab6ff | |||
| 439fa17292 | |||
| 096c04486c | |||
| c9e1079076 | |||
| e29a86a85f | |||
| f9dedd979e | |||
| 1ba0eb0c2e | |||
| d7dc10993e | |||
| 2a5d3975e8 | |||
| 9f3356ddb4 | |||
| f5674f5bf8 | |||
| 17a617e585 | |||
| f50eea9e05 | |||
| 81ee8f653a | |||
| 9507745cc0 | |||
| d33e164876 | |||
| 7e6e815375 | |||
| 8ab139e222 | |||
| 55c04b1323 | |||
| 29fc8bfa97 | |||
| e90d8c0ac4 | |||
| 98d09f187e | |||
| f96a6a509b | |||
| df27b8b748 | |||
| 5c915bc1b9 | |||
| a188ed3914 | |||
| 0094b67c13 | |||
| 0b9dc3a1a2 | |||
| f8628269b9 | |||
| 345023f090 | |||
| 3352f7a1c9 | |||
| 9e1406e6c8 | |||
| 798bfc2b92 | |||
| 9d07903e71 | |||
| 4baf77c740 | |||
| 0adfa51174 | |||
| 7a25a9f5b4 | |||
| 3d81b98f48 | |||
| 9b4a37eebf | |||
| a467920410 | |||
| e62bb7593a | |||
| 4af0bdc27b | |||
| b9da9e6367 | |||
| 1ea1f6b603 | |||
| 677fedbbc0 | |||
| 234862e5b6 | |||
| b8bfee7f87 | |||
| 2a3ae89f5e | |||
| c22e006e30 | |||
| 66ef8a0a5f | |||
| d29de8fcba | |||
| 42eeade121 | |||
| 8e893e6c64 | |||
| e0c5273eb3 | |||
| a1ebb804fe | |||
| b2218efce6 | |||
| b027d21589 | |||
| 2d0874d499 | |||
| e92ba584c0 | |||
| 53b612534d | |||
| 7b6ece0b65 | |||
| 3b7dcaca7a | |||
| 0d0383f84a | |||
| 331b12c7d8 | |||
| 7c534d62b6 | |||
| df648eccf6 | |||
| fca10c135a | |||
| 03969b8f45 | |||
| d8e08558cc | |||
| 293ad3862a | |||
| 6cc646c974 | |||
| d00ec952a9 | |||
| 74461c860e | |||
| 9d09e51cf7 | |||
| e3aadf1908 | |||
| 69d3286aaf | |||
| 0b1c1e8b8c | |||
| 5435e1dac4 | |||
| 999e5fabd3 | |||
| 6a2098d522 | |||
| 5d3e05536e | |||
| 3e461f642e | |||
| 7a62f47e43 | |||
| daf700429d | |||
| 781bf5e116 | |||
| 66190434a7 | |||
| 2b42ef7829 | |||
| 97374f736e | |||
| 28fc58d898 | |||
| 32a14be564 | |||
| e874b2c459 | |||
| 660bc3cd00 | |||
| c98548fa51 | |||
| 0d4d60953e | |||
| f7079f51de | |||
| 15a1a5d0aa | |||
| d99e0bf4dd | |||
| 20acc8bce5 | |||
| 5ef431b9e9 | |||
| 3439b758df | |||
| 97f9e8ad25 | |||
| 5faa319b69 | |||
| 42e8320866 | |||
| b0c6b1338d | |||
| 309a411718 | |||
| ef65e0934e | |||
| 84dd6458aa | |||
| 37e5c52cbe | |||
| 49914c5d92 | |||
| 9df4398c8f | |||
| a83a742bf3 | |||
| 713aa5fd58 | |||
| b210c48eaa | |||
| d70c865dc7 | |||
| df2221a4bd | |||
| 831584550b | |||
| c688311580 | |||
| b9c62cc515 | |||
| 605931861b | |||
| 4e8d37bff7 | |||
| be35709cea | |||
| 6c3230648a | |||
| 756d276f47 | |||
| 1d5ab71bd5 | |||
| 9880c71dba | |||
| 33c3a4ed4e | |||
| 3689a82ec5 | |||
| b818d661fd | |||
| 1302d705e7 | |||
| 685a4c0b69 | |||
| b58f2b236f | |||
| 6350a8ddd3 | |||
| 46e1bed5e9 | |||
| 8aba7b08cf | |||
| 9eeac50642 | |||
| 2db4c448d4 | |||
| c89f2e302b | |||
| 1c25ab4303 | |||
| 46ac272f3f | |||
| 9818e3c3ba | |||
| 20320639ce | |||
| 88f387dd83 | |||
| 752f90c330 | |||
| 0fc043d0ad | |||
| 13b94ed3be | |||
| 7747929cdf | |||
| 06fd561bb1 | |||
| 62fb117ecf | |||
| 8713d3e1aa | |||
| 76038f6db6 | |||
| a511f4db40 | |||
| 95a944c4e5 | |||
| 6d6cf18108 | |||
| 32ed0c7285 | |||
| 923466b4fa | |||
| d5163322fb | |||
| 714849883e | |||
| 407ce3f425 | |||
| 49a189fcbf | |||
| 7e8d3b7162 | |||
| 24010af265 | |||
| 33192ce4d1 | |||
| 02a695c6af | |||
| e5f51fd7be | |||
| 620e4c4835 | |||
| 125c23e2c0 | |||
| 51e005701d | |||
| c04dd63db8 | |||
| 4fd06b00a0 | |||
| 1f9335ad5d | |||
| 2cd3c27ae9 | |||
| 53ae08cec4 | |||
| 8aab8dd2a5 | |||
| e8bec0ae03 | |||
| 389a69484e | |||
| f656e624f7 | |||
| f5635f6645 | |||
| 81a04d0777 | |||
| b63c22a7df | |||
| 05ad6d812c | |||
| aa579977e3 | |||
| 2788323e01 | |||
| 3b74425d35 | |||
| edbc98aea7 | |||
| 60f5ab304a | |||
| 8291c6d835 | |||
| 7928d117b3 | |||
| eec4e21751 | |||
| 343a84d6bc | |||
| 89416fef47 | |||
| 74d72f1494 | |||
| a24dbe365a | |||
| 3b753ecfbf | |||
| 7184b7d4b2 | |||
| 5c36ca3986 | |||
| 3a3f3ab7d4 | |||
| 1779a8a950 | |||
| a51a4b3e87 | |||
| 034d55d7cb | |||
| eeb7f00d05 | |||
| 1326d14a00 | |||
| 59f843f8a0 | |||
| fe807ae2a6 | |||
| a4eb0bfea1 | |||
| ce9ba60902 | |||
| 744ebab15a | |||
| 17da1d5b3c | |||
| f7613d9375 | |||
| a43ad106f2 | |||
| 0e26c5023b | |||
| f4a4530481 | |||
| 00dc3fae11 | |||
| 1da23f8888 | |||
| bee4e4639c | |||
| bd5b27ad51 | |||
| b391abfd5c | |||
| 21a6657e00 | |||
| d348ad5556 | |||
| 5d8b7b9b99 | |||
| f5fa39b97e | |||
| 0a3a90c4e9 | |||
| f440df343a | |||
| 4ec282b2f3 | |||
| c039e638a6 | |||
| 65ffc63da4 | |||
| 5ba120567f | |||
| 8a335789b3 | |||
| d420311507 | |||
| a01ace12e8 | |||
| 24c022f837 | |||
| ecd81eb7fa | |||
| f2e4a96154 | |||
| 08ba24c252 | |||
| ff55270b52 | |||
| f78819d81a | |||
| 79e02483ad | |||
| f25ed46dbc | |||
| 7ad09c0d0d | |||
| a212d42495 | |||
| 51095e3ac5 | |||
| a897fe6115 | |||
| a0d9f06a35 | |||
| f1d0fb95f4 | |||
| 4bc494e009 | |||
| 110bdce38c | |||
| b9e700243e | |||
| bc39addfa8 | |||
| 2532934cdf | |||
| f4b5a589b6 | |||
| 105562bdcb | |||
| 16359e21a2 | |||
| 9451958193 | |||
| 1a810790cd | |||
| e426c89cb2 | |||
| 325a0aeedf | |||
| a8293b7b5c | |||
| 54bd25da39 | |||
| e4c440b265 | |||
| e39f0fee77 | |||
| 5b48e45536 | |||
| 3a7f76e33e | |||
| a54c84a138 | |||
| 8ba26f01e3 | |||
| 9bc88eba72 | |||
| b741618251 | |||
| c0328ab63f | |||
| 425bcf8958 | |||
| 26d4058457 | |||
| ccaac28f08 | |||
| a1a348e22d | |||
| ad29bb6ec2 | |||
| aa2e0e81c6 | |||
| 3750cdab44 | |||
| 6cf448ba80 | |||
| 3e64647d0d | |||
| dde00fc380 | |||
| f4ad3dae35 | |||
| 85a8ec8ba9 | |||
| c68525aa59 | |||
| 91d6365275 | |||
| 35a7445a09 | |||
| 4607b15a85 | |||
| 4eae1a5c14 | |||
| 5381b13813 | |||
| 66ae8e1fff | |||
| 1aa05eaa8d | |||
| 4f13c25ca2 | |||
| 83599cee37 | |||
| 1c4e95d8e3 | |||
| 97f1105cf4 | |||
| c65026353a | |||
| 8872dc178c | |||
| fa0c2ec5e3 | |||
| f9eda8e95d | |||
| 5b2b0db686 | |||
| 6576731842 | |||
| ec7bf9fd2f | |||
| bc053744fc | |||
| af87614cb0 | |||
| b2484da2af | |||
| 5d3c05d291 | |||
| 40accfbf60 | |||
| 3f0558d077 | |||
| 7ae3d7d906 | |||
| 6877ebe027 | |||
| 51e881d831 | |||
| 80bbb752b6 | |||
| 3c9945ec35 | |||
| 4d8c358b33 | |||
| 4b82659a48 | |||
| d77c562c84 | |||
| 37ea75be3e | |||
| 82158ed34d | |||
| 9ab98c9a63 | |||
| ca7d3f8cb3 | |||
| 66448ff6c2 | |||
| 31d47efb1e | |||
| 33802f554a | |||
| 38265fd921 | |||
| ca2efc5c68 | |||
| dfcb422294 | |||
| 47470e2343 | |||
| bac9dd5c31 | |||
| 65dab84e7f | |||
| 3a0da19ea8 | |||
| 5e460e6b4f | |||
| 9299f04f74 | |||
| 2746133252 | |||
| bde192c1e7 | |||
| 99646f887b | |||
| 542ccc4479 | |||
| 9910c0e602 | |||
| 4f0d707905 | |||
| a86fe46b7b | |||
| 139c06b63d | |||
| 999dc7d360 | |||
| dc74d3057a | |||
| ac833ef265 | |||
| 00e31f399e | |||
| 8001e5d24a | |||
| cfb9534e06 | |||
| c2894260dc | |||
| 582f493f3f | |||
| 8335f40238 | |||
| f6f0921560 | |||
| c739c67616 | |||
| dc8148ae51 | |||
| 3307f62183 | |||
| 2b36381f8d | |||
| de2579401c | |||
| 5c3b7acd54 | |||
| 44f8590fe8 | |||
| 945406adc5 | |||
| 1e7522d173 | |||
| 2b72b4888c | |||
| 8b1cc949c0 | |||
| a70018f70a | |||
| 71b87895eb | |||
| 354407cd12 | |||
| 766fd00be5 | |||
| c31e970172 | |||
| c56def9c97 | |||
| aa558b3a8c | |||
| 11082f25d7 | |||
| 00ce8cad1b | |||
| dc756e2bbb | |||
| fb06cf8e55 | |||
| 69ba901535 | |||
| 4667cb525f | |||
| 54229b0dcd | |||
| 6b42c9d142 | |||
| 7665b38b79 | |||
| d5de5b8ad7 | |||
| 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 | |||
| 0a401843f8 | |||
| 9e8c3f1525 | |||
| 611b0b3113 | |||
| 27dd20b75d | |||
| 3142818cf2 | |||
| d8465ac251 | |||
| c33b41d082 | |||
| 3eea875932 | |||
| f5f21ef195 | |||
| 464d58daaa | |||
| 50b0a5d61c |
@@ -0,0 +1,21 @@
|
||||
# Dockerfile for DevContainer
|
||||
FROM node:24.4.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@10.22.0 --activate
|
||||
|
||||
# Create workspace directory
|
||||
WORKDIR /workspaces/dokploy
|
||||
|
||||
# Set up user permissions
|
||||
USER node
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -13,6 +13,17 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set tag and version
|
||||
id: meta-cloud
|
||||
run: |
|
||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -25,8 +36,7 @@ jobs:
|
||||
context: .
|
||||
file: ./Dockerfile.cloud
|
||||
push: true
|
||||
tags: |
|
||||
siumauricio/cloud:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
|
||||
tags: ${{ steps.meta-cloud.outputs.tags }}
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }}
|
||||
@@ -40,6 +50,16 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set tag and version
|
||||
id: meta-schedule
|
||||
run: |
|
||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -52,8 +72,7 @@ jobs:
|
||||
context: .
|
||||
file: ./Dockerfile.schedule
|
||||
push: true
|
||||
tags: |
|
||||
siumauricio/schedule:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
|
||||
tags: ${{ steps.meta-schedule.outputs.tags }}
|
||||
platforms: linux/amd64
|
||||
|
||||
build-and-push-server-image:
|
||||
@@ -63,6 +82,16 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set tag and version
|
||||
id: meta-server
|
||||
run: |
|
||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -75,6 +104,5 @@ jobs:
|
||||
context: .
|
||||
file: ./Dockerfile.server
|
||||
push: true
|
||||
tags: |
|
||||
siumauricio/server:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
|
||||
tags: ${{ steps.meta-server.outputs.tags }}
|
||||
platforms: linux/amd64
|
||||
|
||||
@@ -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
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.16.0
|
||||
node-version: 24.4.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Nixpacks
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.16.0
|
||||
node-version: 24.4.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
+1
-4
@@ -43,7 +43,4 @@ yarn-error.log*
|
||||
*.pem
|
||||
|
||||
|
||||
.db
|
||||
|
||||
# Development environment
|
||||
.devcontainer
|
||||
.db
|
||||
+6
-3
@@ -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
|
||||
|
||||
@@ -52,7 +53,7 @@ feat: add new feature
|
||||
|
||||
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
|
||||
|
||||
We use Node v20.16.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.16.0 && nvm use` in the root directory.
|
||||
We use Node v24.4.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 24.4.0 && nvm use` in the root directory.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dokploy/dokploy.git
|
||||
@@ -162,11 +163,13 @@ curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.
|
||||
- If your pull request fixes an open issue, please reference the issue in the pull request description.
|
||||
- Once your pull request is merged, you will be automatically added as a contributor to the project.
|
||||
|
||||
**Important Considerations for Pull Requests:**
|
||||
### Important Considerations for Pull Requests
|
||||
|
||||
- **Testing is Mandatory:** All Pull Requests **must be tested** by the PR author 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 by their creator will be rejected.** This policy keeps the PR history clean and values contributors who submit verified, working code. Untested PRs are often recognizable by disproportionately large or scattered changes for simple tasks—please test first.
|
||||
- **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`).
|
||||
- **Large Features:** Pull Requests that introduce very large or broad features **will not be accepted** unless the idea is first outlined and discussed in a GitHub issue. Large features should be designed together with the Dokploy team so the project stays coherent and moves in the same direction. Open an issue to propose and align on the design before implementing.
|
||||
|
||||
Thank you for your contribution!
|
||||
|
||||
|
||||
+8
-4
@@ -1,9 +1,9 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:20.16.0-slim AS base
|
||||
FROM node:24.4.0-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@9.12.0 --activate
|
||||
RUN corepack prepare pnpm@10.22.0 --activate
|
||||
|
||||
FROM base AS build
|
||||
COPY . /usr/src/app
|
||||
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
|
||||
RUN pnpm --filter=@dokploy/server build
|
||||
RUN pnpm --filter=./apps/dokploy run build
|
||||
|
||||
RUN pnpm --filter=./apps/dokploy --prod deploy /prod/dokploy
|
||||
RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy
|
||||
|
||||
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
|
||||
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist
|
||||
@@ -65,4 +65,8 @@ RUN curl -sSL https://railpack.com/install.sh | bash
|
||||
COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
||||
|
||||
EXPOSE 3000
|
||||
CMD [ "pnpm", "start" ]
|
||||
|
||||
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
|
||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||
|
||||
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
||||
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:20.16.0-slim AS base
|
||||
FROM node:24.4.0-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@9.12.0 --activate
|
||||
RUN corepack prepare pnpm@10.22.0 --activate
|
||||
|
||||
FROM base AS build
|
||||
COPY . /usr/src/app
|
||||
@@ -29,7 +29,7 @@ ENV NODE_ENV=production
|
||||
RUN pnpm --filter=@dokploy/server build
|
||||
RUN pnpm --filter=./apps/dokploy run build
|
||||
|
||||
RUN pnpm --filter=./apps/dokploy --prod deploy /prod/dokploy
|
||||
RUN pnpm --filter=./apps/dokploy --prod deploy --legacy /prod/dokploy
|
||||
|
||||
RUN cp -R /usr/src/app/apps/dokploy/.next /prod/dokploy/.next
|
||||
RUN cp -R /usr/src/app/apps/dokploy/dist /prod/dokploy/dist
|
||||
|
||||
+5
-4
@@ -1,9 +1,9 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:20.16.0-slim AS base
|
||||
FROM node:24.4.0-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@9.12.0 --activate
|
||||
RUN corepack prepare pnpm@10.22.0 --activate
|
||||
|
||||
FROM base AS build
|
||||
COPY . /usr/src/app
|
||||
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
|
||||
RUN pnpm --filter=@dokploy/server build
|
||||
RUN pnpm --filter=./apps/schedules run build
|
||||
|
||||
RUN pnpm --filter=./apps/schedules --prod deploy /prod/schedules
|
||||
RUN pnpm --filter=./apps/schedules --prod deploy --legacy /prod/schedules
|
||||
|
||||
RUN cp -R /usr/src/app/apps/schedules/dist /prod/schedules/dist
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+5
-4
@@ -1,9 +1,9 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:20.16.0-slim AS base
|
||||
FROM node:24.4.0-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@9.12.0 --activate
|
||||
RUN corepack prepare pnpm@10.22.0 --activate
|
||||
|
||||
FROM base AS build
|
||||
COPY . /usr/src/app
|
||||
@@ -20,7 +20,7 @@ ENV NODE_ENV=production
|
||||
RUN pnpm --filter=@dokploy/server build
|
||||
RUN pnpm --filter=./apps/api run build
|
||||
|
||||
RUN pnpm --filter=./apps/api --prod deploy /prod/api
|
||||
RUN pnpm --filter=./apps/api --prod deploy --legacy /prod/api
|
||||
|
||||
RUN cp -R /usr/src/app/apps/api/dist /prod/api/dist
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+8
-11
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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,72 +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>
|
||||
<a href="https://awesome.tools/" target="_blank">
|
||||
<img src=".github/sponsors/awesome.png" width="200" height="150" />
|
||||
</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">
|
||||
|
||||
+10
-1
@@ -1,2 +1,11 @@
|
||||
LEMON_SQUEEZY_API_KEY=""
|
||||
LEMON_SQUEEZY_STORE_ID=""
|
||||
LEMON_SQUEEZY_STORE_ID=""
|
||||
|
||||
# Inngest (for GET /jobs - list deployment queue). Self-hosted example:
|
||||
# INNGEST_BASE_URL="http://localhost:8288"
|
||||
# Production: INNGEST_BASE_URL="https://dev-inngest.dokploy.com"
|
||||
# INNGEST_SIGNING_KEY="your-signing-key"
|
||||
# Optional: only events after this RFC3339 timestamp. If unset, no date filter is applied.
|
||||
# INNGEST_EVENTS_RECEIVED_AFTER="2024-01-01T00:00:00Z"
|
||||
# Max events to fetch when listing jobs (paginates with cursor). Default 100, max 10000.
|
||||
# INNGEST_JOBS_MAX_EVENTS=100
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "PORT=4000 tsx watch src/index.ts",
|
||||
"build": "tsc --project tsconfig.json",
|
||||
"build": "rimraf dist && tsc --project tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
@@ -12,26 +12,27 @@
|
||||
"inngest": "3.40.1",
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/zod-validator": "0.3.0",
|
||||
"@hono/zod-validator": "0.7.6",
|
||||
"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": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.17.51",
|
||||
"@types/node": "^24.4.0",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"rimraf": "6.1.3",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"packageManager": "pnpm@10.22.0",
|
||||
"engines": {
|
||||
"node": "^20.16.0",
|
||||
"pnpm": ">=9.12.0"
|
||||
"node": "^24.4.0",
|
||||
"pnpm": ">=10.22.0"
|
||||
}
|
||||
}
|
||||
|
||||
+24
-1
@@ -10,6 +10,7 @@ import {
|
||||
type DeployJob,
|
||||
deployJobSchema,
|
||||
} from "./schema.js";
|
||||
import { fetchDeploymentJobs } from "./service.js";
|
||||
import { deploy } from "./utils.js";
|
||||
|
||||
const app = new Hono();
|
||||
@@ -118,7 +119,6 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
|
||||
200,
|
||||
);
|
||||
} catch (error) {
|
||||
console.log("error", error);
|
||||
logger.error("Failed to send deployment event", error);
|
||||
return c.json(
|
||||
{
|
||||
@@ -176,6 +176,29 @@ app.get("/health", async (c) => {
|
||||
return c.json({ status: "ok" });
|
||||
});
|
||||
|
||||
// List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI
|
||||
app.get("/jobs", async (c) => {
|
||||
const serverId = c.req.query("serverId");
|
||||
if (!serverId) {
|
||||
return c.json({ message: "serverId is required" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await fetchDeploymentJobs(serverId);
|
||||
return c.json(rows);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("INNGEST_BASE_URL")) {
|
||||
return c.json(
|
||||
{ message: "INNGEST_BASE_URL is required to list deployment jobs" },
|
||||
503,
|
||||
);
|
||||
}
|
||||
logger.error("Failed to fetch jobs from Inngest", { serverId, error });
|
||||
return c.json([], 200);
|
||||
}
|
||||
});
|
||||
|
||||
// Serve Inngest functions endpoint
|
||||
app.on(
|
||||
["GET", "POST", "PUT"],
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { logger } from "./logger.js";
|
||||
|
||||
const baseUrl = process.env.INNGEST_BASE_URL ?? "";
|
||||
const signingKey = process.env.INNGEST_SIGNING_KEY ?? "";
|
||||
|
||||
const DEFAULT_MAX_EVENTS = 500;
|
||||
const MAX_EVENTS = DEFAULT_MAX_EVENTS;
|
||||
|
||||
/** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */
|
||||
type InngestEventRow = {
|
||||
internal_id?: string;
|
||||
accountID?: string;
|
||||
environmentID?: string;
|
||||
source?: string;
|
||||
sourceID?: string | null;
|
||||
/** RFC3339 timestamp – API uses receivedAt, dev server may use received_at */
|
||||
receivedAt?: string;
|
||||
received_at?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
data: Record<string, unknown>;
|
||||
user?: unknown;
|
||||
ts: number;
|
||||
v?: string | null;
|
||||
metadata?: {
|
||||
fetchedAt: string;
|
||||
cachedUntil: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
/** Run shape from GET /v1/events/{eventId}/runs – the actual job execution */
|
||||
type InngestRun = {
|
||||
run_id: string;
|
||||
event_id: string;
|
||||
status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"?
|
||||
run_started_at?: string;
|
||||
ended_at?: string | null;
|
||||
output?: unknown;
|
||||
// dev server / API may use different casing
|
||||
run_started_at_ms?: number;
|
||||
};
|
||||
|
||||
function getEventReceivedAt(ev: InngestEventRow): string | undefined {
|
||||
return ev.receivedAt ?? ev.received_at;
|
||||
}
|
||||
|
||||
/** Map Inngest run status to BullMQ-style state for the UI */
|
||||
function runStatusToState(
|
||||
status: string,
|
||||
): "pending" | "active" | "completed" | "failed" | "cancelled" {
|
||||
const s = status.toLowerCase();
|
||||
if (s === "running") return "active";
|
||||
if (s === "completed") return "completed";
|
||||
if (s === "failed") return "failed";
|
||||
if (s === "cancelled") return "cancelled";
|
||||
if (s === "queued") return "pending";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export const fetchInngestEvents = async () => {
|
||||
const maxEvents = MAX_EVENTS;
|
||||
const all: InngestEventRow[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const params = new URLSearchParams({ limit: "100" });
|
||||
if (cursor) {
|
||||
params.set("cursor", cursor);
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}/v1/events?${params}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${signingKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
logger.warn("Inngest API error", {
|
||||
status: res.status,
|
||||
body: await res.text(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data?: InngestEventRow[];
|
||||
cursor?: string;
|
||||
nextCursor?: string;
|
||||
};
|
||||
const data = Array.isArray(body.data) ? body.data : [];
|
||||
all.push(...data);
|
||||
|
||||
// Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs)
|
||||
const nextCursor =
|
||||
body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id;
|
||||
const hasMore = data.length === 100 && nextCursor && all.length < maxEvents;
|
||||
cursor = hasMore ? nextCursor : undefined;
|
||||
} while (cursor);
|
||||
|
||||
return all.slice(0, maxEvents);
|
||||
};
|
||||
|
||||
/** Fetch runs for a single event (GET /v1/events/{eventId}/runs) – runs are the actual jobs */
|
||||
export const fetchInngestRunsForEvent = async (
|
||||
eventId: string,
|
||||
): Promise<InngestRun[]> => {
|
||||
const res = await fetch(
|
||||
`${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${signingKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
logger.warn("Inngest runs API error", {
|
||||
eventId,
|
||||
status: res.status,
|
||||
body: await res.text(),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
const body = (await res.json()) as { data?: InngestRun[] };
|
||||
return Array.isArray(body.data) ? body.data : [];
|
||||
};
|
||||
|
||||
/** One row for the queue UI (BullMQ-compatible shape) */
|
||||
export type DeploymentJobRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
data: Record<string, unknown>;
|
||||
timestamp: number;
|
||||
processedOn?: number;
|
||||
finishedOn?: number;
|
||||
failedReason?: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
/** Build queue rows from events + their runs (one row per run, or pending if no run yet) */
|
||||
function buildDeploymentRowsFromRuns(
|
||||
events: InngestEventRow[],
|
||||
runsByEventId: Map<string, InngestRun[]>,
|
||||
serverId: string,
|
||||
): DeploymentJobRow[] {
|
||||
const requested = events.filter(
|
||||
(e) =>
|
||||
e.name === "deployment/requested" &&
|
||||
(e.data as Record<string, unknown>)?.serverId === serverId,
|
||||
);
|
||||
const rows: DeploymentJobRow[] = [];
|
||||
|
||||
for (const ev of requested) {
|
||||
const data = (ev.data ?? {}) as Record<string, unknown>;
|
||||
const runs = runsByEventId.get(ev.id) ?? [];
|
||||
|
||||
if (runs.length === 0) {
|
||||
// Queued: event received but no run yet
|
||||
rows.push({
|
||||
id: ev.id,
|
||||
name: ev.name,
|
||||
data,
|
||||
timestamp: ev.ts,
|
||||
processedOn: ev.ts,
|
||||
finishedOn: undefined,
|
||||
failedReason: undefined,
|
||||
state: "pending",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const run of runs) {
|
||||
const state = runStatusToState(run.status);
|
||||
const runStartedMs =
|
||||
run.run_started_at_ms ??
|
||||
(run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts);
|
||||
const endedMs = run.ended_at
|
||||
? new Date(run.ended_at).getTime()
|
||||
: undefined;
|
||||
const failedReason =
|
||||
state === "failed" &&
|
||||
run.output &&
|
||||
typeof run.output === "object" &&
|
||||
"error" in run.output
|
||||
? String((run.output as { error?: unknown }).error)
|
||||
: undefined;
|
||||
|
||||
rows.push({
|
||||
id: run.run_id,
|
||||
name: ev.name,
|
||||
data,
|
||||
timestamp: runStartedMs,
|
||||
processedOn: runStartedMs,
|
||||
finishedOn:
|
||||
state === "completed" || state === "failed" || state === "cancelled"
|
||||
? endedMs
|
||||
: undefined,
|
||||
failedReason,
|
||||
state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0));
|
||||
}
|
||||
|
||||
/** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */
|
||||
export const fetchDeploymentJobs = async (
|
||||
serverId: string,
|
||||
): Promise<DeploymentJobRow[]> => {
|
||||
if (!signingKey) {
|
||||
logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list");
|
||||
return [];
|
||||
}
|
||||
if (!baseUrl) {
|
||||
throw new Error("INNGEST_BASE_URL is required to list deployment jobs");
|
||||
}
|
||||
|
||||
const events = await fetchInngestEvents();
|
||||
|
||||
const requestedForServer = events.filter(
|
||||
(e) =>
|
||||
e.name === "deployment/requested" &&
|
||||
(e.data as Record<string, unknown>)?.serverId === serverId,
|
||||
);
|
||||
// Limit to avoid too many run fetches
|
||||
const toFetch = requestedForServer.slice(0, 50);
|
||||
const runsByEventId = new Map<string, InngestRun[]>();
|
||||
|
||||
await Promise.all(
|
||||
toFetch.map(async (ev) => {
|
||||
const runs = await fetchInngestRunsForEvent(ev.id);
|
||||
runsByEventId.set(ev.id, runs);
|
||||
}),
|
||||
);
|
||||
|
||||
return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId);
|
||||
};
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
updateCompose,
|
||||
updatePreviewDeployment,
|
||||
} from "@dokploy/server";
|
||||
import type { DeployJob } from "./schema";
|
||||
import type { DeployJob } from "./schema.js";
|
||||
|
||||
export const deploy = async (job: DeployJob) => {
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy"
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
@@ -1 +0,0 @@
|
||||
20.16.0
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,13 +14,18 @@ vi.mock("@dokploy/server/db", () => {
|
||||
set: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
returning: vi.fn().mockResolvedValue([{}] as any),
|
||||
from: vi.fn(() => chain),
|
||||
innerJoin: vi.fn(() => chain),
|
||||
then: (resolve: (v: any) => void) => {
|
||||
resolve([]);
|
||||
},
|
||||
} as any;
|
||||
return chain;
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
select: vi.fn(() => createChainableMock()),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(() => createChainableMock()),
|
||||
delete: vi.fn(),
|
||||
@@ -28,6 +33,12 @@ vi.mock("@dokploy/server/db", () => {
|
||||
applications: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
patch: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
member: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,13 +15,18 @@ vi.mock("@dokploy/server/db", () => {
|
||||
set: vi.fn(() => chain),
|
||||
where: vi.fn(() => chain),
|
||||
returning: vi.fn().mockResolvedValue([{}]),
|
||||
from: vi.fn(() => chain),
|
||||
innerJoin: vi.fn(() => chain),
|
||||
then: (resolve: (v: any) => void) => {
|
||||
resolve([]);
|
||||
},
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
select: vi.fn(() => createChainableMock()),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(() => createChainableMock()),
|
||||
delete: vi.fn(),
|
||||
@@ -29,6 +34,12 @@ vi.mock("@dokploy/server/db", () => {
|
||||
applications: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
patch: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
member: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
});
|
||||
@@ -29,6 +33,7 @@ const baseApp: ApplicationNested = {
|
||||
applicationId: "",
|
||||
previewLabels: [],
|
||||
createEnvFile: true,
|
||||
bitbucketRepositorySlug: "",
|
||||
herokuVersion: "",
|
||||
giteaBranch: "",
|
||||
buildServerId: "",
|
||||
@@ -146,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 () => {
|
||||
@@ -164,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 {
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/etc/passwd
|
||||
Binary file not shown.
@@ -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
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (
|
||||
role: string,
|
||||
overrides: Record<string, boolean> = {},
|
||||
) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [] as string[],
|
||||
accessedServices: [] as string[],
|
||||
accessedEnvironments: [] as string[],
|
||||
canCreateProjects: overrides.canCreateProjects ?? false,
|
||||
canDeleteProjects: overrides.canDeleteProjects ?? false,
|
||||
canCreateServices: overrides.canCreateServices ?? false,
|
||||
canDeleteServices: overrides.canDeleteServices ?? false,
|
||||
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
|
||||
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
|
||||
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
|
||||
canAccessToDocker: overrides.canAccessToDocker ?? false,
|
||||
canAccessToAPI: overrides.canAccessToAPI ?? false,
|
||||
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
|
||||
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
||||
mockMemberData("member");
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
|
||||
const { checkPermission } = await import("@dokploy/server/services/permission");
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("static roles bypass enterprise resources", () => {
|
||||
it("owner bypasses deployment.read", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkPermission(ctx, { deployment: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("admin bypasses backup.create", async () => {
|
||||
memberToReturn = mockMemberData("admin");
|
||||
await expect(
|
||||
checkPermission(ctx, { backup: ["create"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member bypasses schedule.delete", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { schedule: ["delete"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member bypasses multiple enterprise permissions at once", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, {
|
||||
deployment: ["read"],
|
||||
backup: ["create"],
|
||||
domain: ["delete"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("static roles validate free-tier resources", () => {
|
||||
it("owner passes project.create", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
await expect(
|
||||
checkPermission(ctx, { project: ["create"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member fails project.create (no legacy override)", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { project: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("member passes service.read", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { service: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member fails service.create", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(
|
||||
checkPermission(ctx, { service: ["create"] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy boolean overrides for member", () => {
|
||||
it("member passes project.create with canCreateProjects=true", async () => {
|
||||
memberToReturn = mockMemberData("member", { canCreateProjects: true });
|
||||
await expect(
|
||||
checkPermission(ctx, { project: ["create"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member passes docker.read with canAccessToDocker=true", async () => {
|
||||
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
|
||||
await expect(
|
||||
checkPermission(ctx, { docker: ["read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member fails docker.read with canAccessToDocker=false", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
await expect(checkPermission(ctx, { docker: ["read"] })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
enterpriseOnlyResources,
|
||||
statements,
|
||||
} from "@dokploy/server/lib/access-control";
|
||||
|
||||
const FREE_TIER_RESOURCES = [
|
||||
"organization",
|
||||
"member",
|
||||
"invitation",
|
||||
"team",
|
||||
"ac",
|
||||
"project",
|
||||
"service",
|
||||
"environment",
|
||||
"docker",
|
||||
"sshKeys",
|
||||
"gitProviders",
|
||||
"traefikFiles",
|
||||
"api",
|
||||
];
|
||||
|
||||
const ENTERPRISE_RESOURCES = [
|
||||
"volume",
|
||||
"deployment",
|
||||
"envVars",
|
||||
"projectEnvVars",
|
||||
"environmentEnvVars",
|
||||
"server",
|
||||
"registry",
|
||||
"certificate",
|
||||
"backup",
|
||||
"volumeBackup",
|
||||
"schedule",
|
||||
"domain",
|
||||
"destination",
|
||||
"notification",
|
||||
"logs",
|
||||
"monitoring",
|
||||
"auditLog",
|
||||
];
|
||||
|
||||
describe("enterpriseOnlyResources set", () => {
|
||||
it("contains all enterprise resources", () => {
|
||||
for (const resource of ENTERPRISE_RESOURCES) {
|
||||
expect(enterpriseOnlyResources.has(resource)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does NOT contain free-tier resources", () => {
|
||||
for (const resource of FREE_TIER_RESOURCES) {
|
||||
expect(enterpriseOnlyResources.has(resource)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("every resource in statements is either free or enterprise", () => {
|
||||
const allResources = Object.keys(statements);
|
||||
for (const resource of allResources) {
|
||||
const isFree = FREE_TIER_RESOURCES.includes(resource);
|
||||
const isEnterprise = enterpriseOnlyResources.has(resource);
|
||||
expect(isFree || isEnterprise).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("free and enterprise sets don't overlap", () => {
|
||||
for (const resource of FREE_TIER_RESOURCES) {
|
||||
expect(enterpriseOnlyResources.has(resource)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("all statement resources are accounted for", () => {
|
||||
const allResources = Object.keys(statements);
|
||||
const categorized = [...FREE_TIER_RESOURCES, ...ENTERPRISE_RESOURCES];
|
||||
for (const resource of allResources) {
|
||||
expect(categorized).toContain(resource);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (
|
||||
role: string,
|
||||
overrides: Record<string, boolean> = {},
|
||||
) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [] as string[],
|
||||
accessedServices: [] as string[],
|
||||
accessedEnvironments: [] as string[],
|
||||
canCreateProjects: overrides.canCreateProjects ?? false,
|
||||
canDeleteProjects: overrides.canDeleteProjects ?? false,
|
||||
canCreateServices: overrides.canCreateServices ?? false,
|
||||
canDeleteServices: overrides.canDeleteServices ?? false,
|
||||
canCreateEnvironments: overrides.canCreateEnvironments ?? false,
|
||||
canDeleteEnvironments: overrides.canDeleteEnvironments ?? false,
|
||||
canAccessToTraefikFiles: overrides.canAccessToTraefikFiles ?? false,
|
||||
canAccessToDocker: overrides.canAccessToDocker ?? false,
|
||||
canAccessToAPI: overrides.canAccessToAPI ?? false,
|
||||
canAccessToSSHKeys: overrides.canAccessToSSHKeys ?? false,
|
||||
canAccessToGitProviders: overrides.canAccessToGitProviders ?? false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
||||
mockMemberData("member");
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
|
||||
const { resolvePermissions } = await import(
|
||||
"@dokploy/server/services/permission"
|
||||
);
|
||||
const { enterpriseOnlyResources, statements } = await import(
|
||||
"@dokploy/server/lib/access-control"
|
||||
);
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("enterprise resources for static roles", () => {
|
||||
it("owner gets true for all enterprise resources", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
for (const resource of enterpriseOnlyResources) {
|
||||
const actions = statements[resource as keyof typeof statements];
|
||||
for (const action of actions) {
|
||||
expect((perms as any)[resource][action]).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("admin gets true for all enterprise resources", async () => {
|
||||
memberToReturn = mockMemberData("admin");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
for (const resource of enterpriseOnlyResources) {
|
||||
const actions = statements[resource as keyof typeof statements];
|
||||
for (const action of actions) {
|
||||
expect((perms as any)[resource][action]).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("member gets true for service-level enterprise resources", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
expect(perms.deployment.read).toBe(true);
|
||||
expect(perms.deployment.create).toBe(true);
|
||||
expect(perms.domain.read).toBe(true);
|
||||
expect(perms.backup.read).toBe(true);
|
||||
expect(perms.logs.read).toBe(true);
|
||||
expect(perms.monitoring.read).toBe(true);
|
||||
});
|
||||
|
||||
it("member gets false for org-level enterprise resources", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
|
||||
expect(perms.server.read).toBe(false);
|
||||
expect(perms.registry.read).toBe(false);
|
||||
expect(perms.certificate.read).toBe(false);
|
||||
expect(perms.destination.read).toBe(false);
|
||||
expect(perms.notification.read).toBe(false);
|
||||
expect(perms.auditLog.read).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("free-tier resources for member", () => {
|
||||
it("member gets service.read=true", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.service.read).toBe(true);
|
||||
});
|
||||
|
||||
it("member gets project.create=false without legacy override", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.project.create).toBe(false);
|
||||
});
|
||||
|
||||
it("member gets project.create=true with canCreateProjects", async () => {
|
||||
memberToReturn = mockMemberData("member", { canCreateProjects: true });
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.project.create).toBe(true);
|
||||
});
|
||||
|
||||
it("member gets docker.read=false without legacy override", async () => {
|
||||
memberToReturn = mockMemberData("member");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.docker.read).toBe(false);
|
||||
});
|
||||
|
||||
it("member gets docker.read=true with canAccessToDocker", async () => {
|
||||
memberToReturn = mockMemberData("member", { canAccessToDocker: true });
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.docker.read).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("free-tier resources for owner", () => {
|
||||
it("owner gets all free-tier permissions as true", async () => {
|
||||
memberToReturn = mockMemberData("owner");
|
||||
const perms = await resolvePermissions(ctx);
|
||||
expect(perms.project.create).toBe(true);
|
||||
expect(perms.project.delete).toBe(true);
|
||||
expect(perms.service.create).toBe(true);
|
||||
expect(perms.service.read).toBe(true);
|
||||
expect(perms.service.delete).toBe(true);
|
||||
expect(perms.docker.read).toBe(true);
|
||||
expect(perms.traefikFiles.read).toBe(true);
|
||||
expect(perms.traefikFiles.write).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockMemberData = (
|
||||
role: string,
|
||||
accessedServices: string[] = [],
|
||||
accessedProjects: string[] = [],
|
||||
) => ({
|
||||
id: "member-1",
|
||||
role,
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects,
|
||||
accessedServices,
|
||||
accessedEnvironments: [] as string[],
|
||||
canCreateProjects: false,
|
||||
canDeleteProjects: false,
|
||||
canCreateServices: false,
|
||||
canDeleteServices: false,
|
||||
canCreateEnvironments: false,
|
||||
canDeleteEnvironments: false,
|
||||
canAccessToTraefikFiles: false,
|
||||
canAccessToDocker: false,
|
||||
canAccessToAPI: false,
|
||||
canAccessToSSHKeys: false,
|
||||
canAccessToGitProviders: false,
|
||||
user: { id: "user-1", email: "test@test.com" },
|
||||
});
|
||||
|
||||
let memberToReturn: ReturnType<typeof mockMemberData> =
|
||||
mockMemberData("member");
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
organizationRole: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(() => Promise.resolve([])),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
|
||||
hasValidLicense: vi.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
|
||||
const { checkServicePermissionAndAccess, checkServiceAccess } = await import(
|
||||
"@dokploy/server/services/permission"
|
||||
);
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("checkServicePermissionAndAccess", () => {
|
||||
it("owner bypasses accessedServices check", async () => {
|
||||
memberToReturn = mockMemberData("owner", []);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
deployment: ["read"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("admin bypasses accessedServices check", async () => {
|
||||
memberToReturn = mockMemberData("admin", []);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
backup: ["create"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member with access to service passes", async () => {
|
||||
memberToReturn = mockMemberData("member", ["service-123"]);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
deployment: ["read"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member WITHOUT access to service fails", async () => {
|
||||
memberToReturn = mockMemberData("member", ["other-service"]);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
deployment: ["read"],
|
||||
}),
|
||||
).rejects.toThrow("You don't have access to this service");
|
||||
});
|
||||
|
||||
it("member with empty accessedServices fails", async () => {
|
||||
memberToReturn = mockMemberData("member", []);
|
||||
await expect(
|
||||
checkServicePermissionAndAccess(ctx, "service-123", {
|
||||
domain: ["delete"],
|
||||
}),
|
||||
).rejects.toThrow("You don't have access to this service");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkServiceAccess", () => {
|
||||
it("member with service access passes read check", async () => {
|
||||
memberToReturn = mockMemberData("member", ["app-1"]);
|
||||
await expect(
|
||||
checkServiceAccess(ctx, "app-1", "read"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("member without service access fails read check", async () => {
|
||||
memberToReturn = mockMemberData("member", []);
|
||||
await expect(checkServiceAccess(ctx, "app-1", "read")).rejects.toThrow(
|
||||
"You don't have access to this service",
|
||||
);
|
||||
});
|
||||
|
||||
it("owner bypasses all access checks", async () => {
|
||||
memberToReturn = mockMemberData("owner", [], []);
|
||||
await expect(
|
||||
checkServiceAccess(ctx, "project-1", "create"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ type MockCreateServiceOptions = {
|
||||
TaskTemplate?: {
|
||||
ContainerSpec?: {
|
||||
StopGracePeriod?: number;
|
||||
Ulimits?: Array<{ Name: string; Soft: number; Hard: number }>;
|
||||
};
|
||||
};
|
||||
[key: string]: unknown;
|
||||
@@ -13,11 +14,11 @@ type MockCreateServiceOptions = {
|
||||
|
||||
const { inspectMock, getServiceMock, createServiceMock, getRemoteDockerMock } =
|
||||
vi.hoisted(() => {
|
||||
const inspect = vi.fn<[], Promise<never>>();
|
||||
const inspect = vi.fn<() => Promise<never>>();
|
||||
const getService = vi.fn(() => ({ inspect }));
|
||||
const createService = vi.fn<[MockCreateServiceOptions], Promise<void>>(
|
||||
async () => undefined,
|
||||
);
|
||||
const createService = vi.fn<
|
||||
(opts: MockCreateServiceOptions) => Promise<void>
|
||||
>(async () => undefined);
|
||||
const getRemoteDocker = vi.fn(async () => ({
|
||||
getService,
|
||||
createService,
|
||||
@@ -57,6 +58,7 @@ const createApplication = (
|
||||
},
|
||||
replicas: 1,
|
||||
stopGracePeriodSwarm: 0n,
|
||||
ulimitsSwarm: null,
|
||||
serverId: "server-id",
|
||||
...overrides,
|
||||
}) as unknown as ApplicationNested;
|
||||
@@ -80,7 +82,9 @@ describe("mechanizeDockerContainer", () => {
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
const call = createServiceMock.mock.calls[0] as
|
||||
| [MockCreateServiceOptions]
|
||||
| undefined;
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
@@ -97,7 +101,9 @@ describe("mechanizeDockerContainer", () => {
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
const call = createServiceMock.mock.calls[0] as
|
||||
| [MockCreateServiceOptions]
|
||||
| undefined;
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
@@ -106,4 +112,50 @@ describe("mechanizeDockerContainer", () => {
|
||||
"StopGracePeriod",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes ulimits to ContainerSpec when ulimitsSwarm is defined", async () => {
|
||||
const ulimits = [
|
||||
{ Name: "nofile", Soft: 10000, Hard: 20000 },
|
||||
{ Name: "nproc", Soft: 4096, Hard: 8192 },
|
||||
];
|
||||
const application = createApplication({ ulimitsSwarm: ulimits });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec?.Ulimits).toEqual(ulimits);
|
||||
});
|
||||
|
||||
it("omits Ulimits when ulimitsSwarm is null", async () => {
|
||||
const application = createApplication({ ulimitsSwarm: null });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
|
||||
});
|
||||
|
||||
it("omits Ulimits when ulimitsSwarm is an empty array", async () => {
|
||||
const application = createApplication({ ulimitsSwarm: [] });
|
||||
|
||||
await mechanizeDockerContainer(application);
|
||||
|
||||
expect(createServiceMock).toHaveBeenCalledTimes(1);
|
||||
const call = createServiceMock.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("createServiceMock should have been called once");
|
||||
}
|
||||
const [settings] = call;
|
||||
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty("Ulimits");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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.from = () => chain;
|
||||
chain.innerJoin = () => chain;
|
||||
chain.then = (resolve: (value: unknown) => void) => {
|
||||
resolve([]);
|
||||
};
|
||||
|
||||
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),
|
||||
};
|
||||
|
||||
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",
|
||||
};
|
||||
});
|
||||
@@ -48,6 +48,20 @@ const baseSettings: WebServerSettings = {
|
||||
urlCallback: "",
|
||||
},
|
||||
},
|
||||
whitelabelingConfig: {
|
||||
appName: null,
|
||||
appDescription: null,
|
||||
logoUrl: null,
|
||||
faviconUrl: null,
|
||||
customCss: null,
|
||||
loginLogoUrl: null,
|
||||
supportUrl: null,
|
||||
docsUrl: null,
|
||||
errorPageTitle: null,
|
||||
errorPageDescription: null,
|
||||
metaTitle: null,
|
||||
footerText: null,
|
||||
},
|
||||
cleanupCacheApplications: false,
|
||||
cleanupCacheOnCompose: false,
|
||||
cleanupCacheOnPreviews: false,
|
||||
|
||||
@@ -8,6 +8,7 @@ const baseApp: ApplicationNested = {
|
||||
applicationId: "",
|
||||
previewLabels: [],
|
||||
createEnvFile: true,
|
||||
bitbucketRepositorySlug: "",
|
||||
herokuVersion: "",
|
||||
giteaRepository: "",
|
||||
giteaOwner: "",
|
||||
@@ -124,6 +125,7 @@ const baseApp: ApplicationNested = {
|
||||
username: null,
|
||||
dockerContextPath: null,
|
||||
stopGracePeriodSwarm: null,
|
||||
ulimitsSwarm: null,
|
||||
};
|
||||
|
||||
const baseDomain: Domain = {
|
||||
@@ -273,3 +275,51 @@ test("CertificateType on websecure entrypoint", async () => {
|
||||
|
||||
expect(router.tls?.certResolver).toBe("letsencrypt");
|
||||
});
|
||||
|
||||
/** IDN/Punycode */
|
||||
|
||||
test("Internationalized domain name is converted to punycode", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, host: "тест.рф" },
|
||||
"web",
|
||||
);
|
||||
|
||||
// тест.рф in punycode is xn--e1aybc.xn--p1ai
|
||||
expect(router.rule).toContain("Host(`xn--e1aybc.xn--p1ai`)");
|
||||
expect(router.rule).not.toContain("тест.рф");
|
||||
});
|
||||
|
||||
test("ASCII domain remains unchanged", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, host: "example.com" },
|
||||
"web",
|
||||
);
|
||||
|
||||
expect(router.rule).toContain("Host(`example.com`)");
|
||||
});
|
||||
|
||||
test("Russian Cyrillic label with .ru TLD is converted to punycode", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, host: "сайт.ru" },
|
||||
"web",
|
||||
);
|
||||
|
||||
// сайт in punycode is xn--80aswg
|
||||
expect(router.rule).toContain("Host(`xn--80aswg.ru`)");
|
||||
expect(router.rule).not.toContain("сайт");
|
||||
});
|
||||
|
||||
test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{ ...baseDomain, host: "app.тест.рф" },
|
||||
"web",
|
||||
);
|
||||
|
||||
// app stays ASCII, тест.рф becomes xn--e1aybc.xn--p1ai
|
||||
expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)");
|
||||
expect(router.rule).not.toContain("тест.рф");
|
||||
});
|
||||
|
||||
@@ -7,10 +7,15 @@ export default defineConfig({
|
||||
include: ["__test__/**/*.test.ts"], // Incluir solo los archivos de test en el directorio __test__
|
||||
exclude: ["**/node_modules/**", "**/dist/**", "**/.docker/**"],
|
||||
pool: "forks",
|
||||
setupFiles: [path.resolve(__dirname, "setup.ts")],
|
||||
},
|
||||
define: {
|
||||
"process.env": {
|
||||
NODE: "test",
|
||||
GITHUB_CLIENT_ID: "test",
|
||||
GITHUB_CLIENT_SECRET: "test",
|
||||
GOOGLE_CLIENT_ID: "test",
|
||||
GOOGLE_CLIENT_SECRET: "test",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+156
-890
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
@@ -73,7 +73,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync, isLoading } = mutationMap[type]
|
||||
const { mutateAsync, isPending } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
@@ -236,7 +236,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
||||
<Button isLoading={isPending} type="submit" className="w-fit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
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>
|
||||
);
|
||||
};
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
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";
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
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>
|
||||
);
|
||||
};
|
||||
+202
@@ -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>
|
||||
);
|
||||
};
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
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>
|
||||
);
|
||||
};
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
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>
|
||||
);
|
||||
};
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
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>
|
||||
);
|
||||
};
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
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>
|
||||
);
|
||||
};
|
||||
+158
@@ -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>
|
||||
);
|
||||
};
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
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,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
@@ -50,7 +50,7 @@ export const AddCommand = ({ applicationId }: Props) => {
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync, isLoading } = api.application.update.useMutation();
|
||||
const { mutateAsync, isPending } = api.application.update.useMutation();
|
||||
|
||||
const form = useForm<AddCommand>({
|
||||
defaultValues: {
|
||||
@@ -177,7 +177,7 @@ export const AddCommand = ({ applicationId }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
||||
<Button isLoading={isPending} type="submit" className="w-fit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Code2, Globe2, HardDrive } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -69,11 +69,11 @@ export const ShowImport = ({ composeId }: Props) => {
|
||||
} | null>(null);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: processTemplate, isLoading: isLoadingTemplate } =
|
||||
const { mutateAsync: processTemplate, isPending: isLoadingTemplate } =
|
||||
api.compose.processTemplate.useMutation();
|
||||
const {
|
||||
mutateAsync: importTemplate,
|
||||
isLoading: isImporting,
|
||||
isPending: isImporting,
|
||||
isSuccess: isImportSuccess,
|
||||
} = api.compose.import.useMutation();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
@@ -35,13 +35,9 @@ import { api } from "@/utils/api";
|
||||
|
||||
const AddPortSchema = z.object({
|
||||
publishedPort: z.number().int().min(1).max(65535),
|
||||
publishMode: z.enum(["ingress", "host"], {
|
||||
required_error: "Publish mode is required",
|
||||
}),
|
||||
publishMode: z.enum(["ingress", "host"]),
|
||||
targetPort: z.number().int().min(1).max(65535),
|
||||
protocol: z.enum(["tcp", "udp"], {
|
||||
required_error: "Protocol is required",
|
||||
}),
|
||||
protocol: z.enum(["tcp", "udp"]),
|
||||
});
|
||||
|
||||
type AddPort = z.infer<typeof AddPortSchema>;
|
||||
@@ -68,7 +64,7 @@ export const HandlePorts = ({
|
||||
enabled: !!portId,
|
||||
},
|
||||
);
|
||||
const { mutateAsync, isLoading, error, isError } = portId
|
||||
const { mutateAsync, isPending, error, isError } = portId
|
||||
? api.port.update.useMutation()
|
||||
: api.port.create.useMutation();
|
||||
|
||||
@@ -270,7 +266,7 @@ export const HandlePorts = ({
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
form="hook-form-add-port"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -25,7 +25,7 @@ export const ShowPorts = ({ applicationId }: Props) => {
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const { mutateAsync: deletePort, isLoading: isRemoving } =
|
||||
const { mutateAsync: deletePort, isPending: isRemoving } =
|
||||
api.port.delete.useMutation();
|
||||
|
||||
return (
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -100,11 +100,11 @@ export const HandleRedirect = ({
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } = redirectId
|
||||
const { mutateAsync, isPending, error, isError } = redirectId
|
||||
? api.redirects.update.useMutation()
|
||||
: api.redirects.create.useMutation();
|
||||
|
||||
const form = useForm<AddRedirect>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
permanent: false,
|
||||
regex: "",
|
||||
@@ -268,7 +268,7 @@ export const HandleRedirect = ({
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
form="hook-form-add-redirect"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ShowRedirects = ({ applicationId }: Props) => {
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const { mutateAsync: deleteRedirect, isLoading: isRemoving } =
|
||||
const { mutateAsync: deleteRedirect, isPending: isRemoving } =
|
||||
api.redirects.delete.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -46,7 +46,7 @@ export const HandleSecurity = ({
|
||||
}: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data } = api.security.one.useQuery(
|
||||
const { data, refetch } = api.security.one.useQuery(
|
||||
{
|
||||
securityId: securityId ?? "",
|
||||
},
|
||||
@@ -55,7 +55,7 @@ export const HandleSecurity = ({
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } = securityId
|
||||
const { mutateAsync, isPending, error, isError } = securityId
|
||||
? api.security.update.useMutation()
|
||||
: api.security.create.useMutation();
|
||||
|
||||
@@ -88,6 +88,7 @@ export const HandleSecurity = ({
|
||||
await utils.application.readTraefikConfig.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
await refetch();
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -163,7 +164,7 @@ export const HandleSecurity = ({
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
form="hook-form-add-security"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -27,7 +27,7 @@ export const ShowSecurity = ({ applicationId }: Props) => {
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const { mutateAsync: deleteSecurity, isLoading: isRemoving } =
|
||||
const { mutateAsync: deleteSecurity, isPending: isRemoving } =
|
||||
api.security.delete.useMutation();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
@@ -74,7 +74,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
||||
const { data: buildServers } = api.server.buildServers.useQuery();
|
||||
const { data: registries } = api.registry.all.useQuery();
|
||||
|
||||
const { mutateAsync, isLoading } = api.application.update.useMutation();
|
||||
const { mutateAsync, isPending } = api.application.update.useMutation();
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
@@ -274,7 +274,7 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
||||
/>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
<Button isLoading={isPending} type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { InfoIcon, Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -21,10 +21,18 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
createConverter,
|
||||
NumberInputWithSteps,
|
||||
} from "@/components/ui/number-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -50,13 +58,36 @@ const memoryConverter = createConverter(1024 * 1024, (mb) => {
|
||||
: `${formatNumber(mb)} MB`;
|
||||
});
|
||||
|
||||
const ulimitSchema = z.object({
|
||||
Name: z.string().min(1, "Name is required"),
|
||||
Soft: z.coerce.number().int().min(-1, "Must be >= -1"),
|
||||
Hard: z.coerce.number().int().min(-1, "Must be >= -1"),
|
||||
});
|
||||
|
||||
const addResourcesSchema = z.object({
|
||||
memoryReservation: z.string().optional(),
|
||||
cpuLimit: z.string().optional(),
|
||||
memoryLimit: z.string().optional(),
|
||||
cpuReservation: z.string().optional(),
|
||||
ulimitsSwarm: z.array(ulimitSchema).optional(),
|
||||
});
|
||||
|
||||
const ULIMIT_PRESETS = [
|
||||
{ value: "nofile", label: "nofile (Open Files)" },
|
||||
{ value: "nproc", label: "nproc (Processes)" },
|
||||
{ value: "memlock", label: "memlock (Locked Memory)" },
|
||||
{ value: "stack", label: "stack (Stack Size)" },
|
||||
{ value: "core", label: "core (Core File Size)" },
|
||||
{ value: "cpu", label: "cpu (CPU Time)" },
|
||||
{ value: "data", label: "data (Data Segment)" },
|
||||
{ value: "fsize", label: "fsize (File Size)" },
|
||||
{ value: "locks", label: "locks (File Locks)" },
|
||||
{ value: "msgqueue", label: "msgqueue (Message Queues)" },
|
||||
{ value: "nice", label: "nice (Nice Priority)" },
|
||||
{ value: "rtprio", label: "rtprio (Real-time Priority)" },
|
||||
{ value: "sigpending", label: "sigpending (Pending Signals)" },
|
||||
];
|
||||
|
||||
export type ServiceType =
|
||||
| "postgres"
|
||||
| "mongo"
|
||||
@@ -97,20 +128,26 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync, isLoading } = mutationMap[type]
|
||||
const { mutateAsync, isPending } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
const form = useForm<AddResources>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
cpuLimit: "",
|
||||
cpuReservation: "",
|
||||
memoryLimit: "",
|
||||
memoryReservation: "",
|
||||
ulimitsSwarm: [],
|
||||
},
|
||||
resolver: zodResolver(addResourcesSchema),
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "ulimitsSwarm",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
@@ -118,6 +155,7 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: data?.cpuReservation || undefined,
|
||||
memoryLimit: data?.memoryLimit || undefined,
|
||||
memoryReservation: data?.memoryReservation || undefined,
|
||||
ulimitsSwarm: data?.ulimitsSwarm || [],
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
@@ -134,6 +172,10 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
cpuReservation: formData.cpuReservation || null,
|
||||
memoryLimit: formData.memoryLimit || null,
|
||||
memoryReservation: formData.memoryReservation || null,
|
||||
ulimitsSwarm:
|
||||
formData.ulimitsSwarm && formData.ulimitsSwarm.length > 0
|
||||
? formData.ulimitsSwarm
|
||||
: null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Resources Updated");
|
||||
@@ -325,8 +367,157 @@ 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}
|
||||
value={
|
||||
typeof field.value === "number"
|
||||
? field.value
|
||||
: ""
|
||||
}
|
||||
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}
|
||||
value={
|
||||
typeof field.value === "number"
|
||||
? field.value
|
||||
: ""
|
||||
}
|
||||
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">
|
||||
<Button isLoading={isPending} type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+7
-3
@@ -15,13 +15,17 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
||||
const { data, isLoading } = api.application.readTraefikConfig.useQuery(
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canRead = permissions?.traefikFiles.read ?? false;
|
||||
const { data, isPending } = api.application.readTraefikConfig.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{ enabled: !!applicationId },
|
||||
{ enabled: !!applicationId && canRead },
|
||||
);
|
||||
|
||||
if (!canRead) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between">
|
||||
@@ -35,7 +39,7 @@ export const ShowTraefikConfig = ({ applicationId }: Props) => {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{isLoading ? (
|
||||
{isPending ? (
|
||||
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center justify-center min-h-[10vh]">
|
||||
Loading...
|
||||
<Loader2 className="animate-spin" />
|
||||
|
||||
+45
-14
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const UpdateTraefikConfigSchema = z.object({
|
||||
@@ -58,7 +60,10 @@ export const validateAndFormatYAML = (yamlText: string) => {
|
||||
};
|
||||
|
||||
export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canWrite = permissions?.traefikFiles.write ?? false;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [skipYamlValidation, setSkipYamlValidation] = useState(false);
|
||||
const { data, refetch } = api.application.readTraefikConfig.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
@@ -66,7 +71,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
const { mutateAsync, isPending, error, isError } =
|
||||
api.application.updateTraefikConfig.useMutation();
|
||||
|
||||
const form = useForm<UpdateTraefikConfig>({
|
||||
@@ -85,13 +90,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,12 +123,15 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
|
||||
setOpen(open);
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setSkipYamlValidation(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button isLoading={isLoading}>Modify</Button>
|
||||
</DialogTrigger>
|
||||
{canWrite && (
|
||||
<DialogTrigger asChild>
|
||||
<Button isLoading={isPending}>Modify</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent className="sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update traefik config</DialogTitle>
|
||||
@@ -169,9 +179,30 @@ 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}
|
||||
isLoading={isPending}
|
||||
form="hook-form-update-traefik-config"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -21,6 +21,13 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowVolumes = ({ id, type }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canRead = permissions?.volume.read ?? false;
|
||||
const canCreate = permissions?.volume.create ?? false;
|
||||
const canDelete = permissions?.volume.delete ?? false;
|
||||
|
||||
if (!canRead) return null;
|
||||
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
@@ -37,7 +44,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
||||
const { data, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
const { mutateAsync: deleteVolume, isLoading: isRemoving } =
|
||||
const { mutateAsync: deleteVolume, isPending: isRemoving } =
|
||||
api.mounts.remove.useMutation();
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
@@ -50,7 +57,7 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{data && data?.mounts.length > 0 && (
|
||||
{canCreate && data && data?.mounts.length > 0 && (
|
||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||
Add Volume
|
||||
</AddVolumes>
|
||||
@@ -63,9 +70,11 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
||||
<span className="text-base text-muted-foreground">
|
||||
No volumes/mounts configured
|
||||
</span>
|
||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||
Add Volume
|
||||
</AddVolumes>
|
||||
{canCreate && (
|
||||
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
|
||||
Add Volume
|
||||
</AddVolumes>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pt-2 gap-4">
|
||||
@@ -130,38 +139,42 @@ export const ShowVolumes = ({ id, type }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-1">
|
||||
<UpdateVolume
|
||||
mountId={mount.mountId}
|
||||
type={mount.type}
|
||||
refetch={refetch}
|
||||
serviceType={type}
|
||||
/>
|
||||
<DialogAction
|
||||
title="Delete Volume"
|
||||
description="Are you sure you want to delete this volume?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteVolume({
|
||||
mountId: mount.mountId,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Volume deleted successfully");
|
||||
{canCreate && (
|
||||
<UpdateVolume
|
||||
mountId={mount.mountId}
|
||||
type={mount.type}
|
||||
refetch={refetch}
|
||||
serviceType={type}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DialogAction
|
||||
title="Delete Volume"
|
||||
description="Are you sure you want to delete this volume?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteVolume({
|
||||
mountId: mount.mountId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting volume");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Volume deleted successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting volume");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -93,7 +93,7 @@ export const UpdateVolume = ({
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
const { mutateAsync, isPending, error, isError } =
|
||||
api.mounts.update.useMutation();
|
||||
|
||||
const form = useForm<UpdateMount>({
|
||||
@@ -187,7 +187,7 @@ export const UpdateVolume = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 "
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
@@ -310,7 +310,7 @@ PORT=3000
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
isLoading={isPending}
|
||||
// form="hook-form-update-volume"
|
||||
type="submit"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Cog } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -74,12 +74,7 @@ const buildTypeDisplayMap: Record<BuildType, string> = {
|
||||
const mySchema = z.discriminatedUnion("buildType", [
|
||||
z.object({
|
||||
buildType: z.literal(BuildType.dockerfile),
|
||||
dockerfile: z
|
||||
.string({
|
||||
required_error: "Dockerfile path is required",
|
||||
invalid_type_error: "Dockerfile path is required",
|
||||
})
|
||||
.min(1, "Dockerfile required"),
|
||||
dockerfile: z.string().nullable().default(""),
|
||||
dockerContextPath: z.string().nullable().default(""),
|
||||
dockerBuildStage: z.string().nullable().default(""),
|
||||
}),
|
||||
@@ -168,14 +163,14 @@ const resetData = (data: ApplicationData): AddTemplate => {
|
||||
};
|
||||
|
||||
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
const { mutateAsync, isLoading } =
|
||||
const { mutateAsync, isPending } =
|
||||
api.application.saveBuildType.useMutation();
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{ applicationId },
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const form = useForm<AddTemplate>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
buildType: BuildType.nixpacks,
|
||||
},
|
||||
@@ -207,6 +202,11 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
// Hide builder section when Docker provider is selected
|
||||
if (data?.sourceType === "docker") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSubmit = async (data: AddTemplate) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
@@ -342,7 +342,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
<FormLabel>Docker File</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Path of your docker file"
|
||||
placeholder="Path of your docker file (default: Dockerfile)"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
@@ -528,7 +528,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
<Button isLoading={isPending} type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Paintbrush } from "lucide-react";
|
||||
import { Ban } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -20,7 +20,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const CancelQueues = ({ id, type }: Props) => {
|
||||
const { mutateAsync, isLoading } =
|
||||
const { mutateAsync, isPending } =
|
||||
type === "application"
|
||||
? api.application.cleanQueues.useMutation()
|
||||
: api.compose.cleanQueues.useMutation();
|
||||
@@ -33,9 +33,9 @@ export const CancelQueues = ({ id, type }: Props) => {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
|
||||
<Button variant="destructive" className="w-fit" isLoading={isPending}>
|
||||
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, isPending } =
|
||||
type === "application"
|
||||
? api.application.clearDeployments.useMutation()
|
||||
: api.compose.clearDeployments.useMutation();
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="w-fit" isLoading={isPending}>
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -20,7 +20,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const KillBuild = ({ id, type }: Props) => {
|
||||
const { mutateAsync, isLoading } =
|
||||
const { mutateAsync, isPending } =
|
||||
type === "application"
|
||||
? api.application.killBuild.useMutation()
|
||||
: api.compose.killBuild.useMutation();
|
||||
@@ -28,7 +28,7 @@ export const KillBuild = ({ id, type }: Props) => {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="w-fit" isLoading={isLoading}>
|
||||
<Button variant="outline" className="w-fit" isLoading={isPending}>
|
||||
Kill Build
|
||||
<Scissors className="size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -194,13 +194,21 @@ export const ShowDeployment = ({
|
||||
{" "}
|
||||
{filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((log: LogLine, index: number) => (
|
||||
<TerminalLine key={index} log={log} noTimestamp />
|
||||
<TerminalLine
|
||||
key={`${log.rawTimestamp ?? ""}-${index}`}
|
||||
log={log}
|
||||
noTimestamp
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{optionalErrors.length > 0 ? (
|
||||
optionalErrors.map((log: LogLine, index: number) => (
|
||||
<TerminalLine key={`extra-${index}`} log={log} noTimestamp />
|
||||
<TerminalLine
|
||||
key={`extra-${log.rawTimestamp ?? ""}-${index}`}
|
||||
log={log}
|
||||
noTimestamp
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||
|
||||
@@ -2,13 +2,16 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Copy,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
RocketIcon,
|
||||
Settings,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
@@ -25,6 +28,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";
|
||||
@@ -59,7 +63,7 @@ export const ShowDeployments = ({
|
||||
const [activeLog, setActiveLog] = useState<
|
||||
RouterOutputs["deployment"]["all"][number] | null
|
||||
>(null);
|
||||
const { data: deployments, isLoading: isLoadingDeployments } =
|
||||
const { data: deployments, isPending: isLoadingDeployments } =
|
||||
api.deployment.allByType.useQuery(
|
||||
{
|
||||
id,
|
||||
@@ -73,19 +77,21 @@ export const ShowDeployments = ({
|
||||
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
|
||||
const { mutateAsync: rollback, isLoading: isRollingBack } =
|
||||
const { mutateAsync: rollback, isPending: isRollingBack } =
|
||||
api.rollback.rollback.useMutation();
|
||||
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
|
||||
const { mutateAsync: killProcess, isPending: isKillingProcess } =
|
||||
api.deployment.killProcess.useMutation();
|
||||
const { mutateAsync: removeDeployment, isPending: isRemovingDeployment } =
|
||||
api.deployment.removeDeployment.useMutation();
|
||||
|
||||
// Cancel deployment mutations
|
||||
const {
|
||||
mutateAsync: cancelApplicationDeployment,
|
||||
isLoading: isCancellingApp,
|
||||
isPending: isCancellingApp,
|
||||
} = api.application.cancelDeployment.useMutation();
|
||||
const {
|
||||
mutateAsync: cancelComposeDeployment,
|
||||
isLoading: isCancellingCompose,
|
||||
isPending: isCancellingCompose,
|
||||
} = api.compose.cancelDeployment.useMutation();
|
||||
|
||||
const [url, setUrl] = React.useState("");
|
||||
@@ -93,6 +99,12 @@ export const ShowDeployments = ({
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const webhookUrl = useMemo(
|
||||
() =>
|
||||
`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`,
|
||||
[url, refreshToken, type],
|
||||
);
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH = 200;
|
||||
|
||||
const truncateDescription = (description: string): string => {
|
||||
@@ -144,6 +156,9 @@ export const ShowDeployments = ({
|
||||
</CardDescription>
|
||||
</div>
|
||||
<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} />
|
||||
)}
|
||||
@@ -217,11 +232,27 @@ export const ShowDeployments = ({
|
||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
||||
<span>Webhook URL: </span>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span className="break-all text-muted-foreground">
|
||||
{`${url}/api/deploy${
|
||||
type === "compose" ? "/compose" : ""
|
||||
}/${refreshToken}`}
|
||||
</span>
|
||||
<Badge
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Copy webhook URL to clipboard"
|
||||
className="p-2 rounded-md ml-1 mr-1 hover:border-primary hover:text-primary-foreground hover:bg-primary hover:cursor-pointer whitespace-normal break-all"
|
||||
variant="outline"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
copy(webhookUrl);
|
||||
toast.success("Copied to clipboard.");
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
copy(webhookUrl);
|
||||
toast.success("Copied to clipboard.");
|
||||
}}
|
||||
>
|
||||
{webhookUrl}
|
||||
<Copy className="h-4 w-4 ml-2" />
|
||||
</Badge>
|
||||
{(type === "application" || type === "compose") && (
|
||||
<RefreshToken id={id} type={type} />
|
||||
)}
|
||||
@@ -252,6 +283,8 @@ export const ShowDeployments = ({
|
||||
const isExpanded = expandedDescriptions.has(
|
||||
deployment.deploymentId,
|
||||
);
|
||||
const canDelete =
|
||||
deployment.status === "done" || deployment.status === "error";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -370,6 +403,33 @@ export const ShowDeployments = ({
|
||||
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" && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -159,11 +159,11 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isError, error, isLoading } = domainId
|
||||
const { mutateAsync, isError, error, isPending } = domainId
|
||||
? api.domain.update.useMutation()
|
||||
: api.domain.create.useMutation();
|
||||
|
||||
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
|
||||
const { mutateAsync: generateDomain, isPending: isLoadingGenerate } =
|
||||
api.domain.generateDomain.useMutation();
|
||||
|
||||
const { data: canGenerateTraefikMeDomains } =
|
||||
@@ -240,7 +240,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
domainType: type,
|
||||
});
|
||||
}
|
||||
}, [form, data, isLoading, domainId]);
|
||||
}, [form, data, isPending, domainId]);
|
||||
|
||||
// Separate effect for handling custom cert resolver validation
|
||||
useEffect(() => {
|
||||
@@ -730,7 +730,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button isLoading={isLoading} form="hook-form" type="submit">
|
||||
<Button isLoading={isPending} form="hook-form" type="submit">
|
||||
{dictionary.submit}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -50,6 +50,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowDomains = ({ id, type }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canCreateDomain = permissions?.domain.create ?? false;
|
||||
const canDeleteDomain = permissions?.domain.delete ?? false;
|
||||
const { data: application } =
|
||||
type === "application"
|
||||
? api.application.one.useQuery(
|
||||
@@ -97,7 +100,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
|
||||
const { mutateAsync: validateDomain } =
|
||||
api.domain.validateDomain.useMutation();
|
||||
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
||||
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
||||
api.domain.delete.useMutation();
|
||||
|
||||
const handleValidateDomain = async (host: string) => {
|
||||
@@ -149,7 +152,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
{data && data?.length > 0 && (
|
||||
{canCreateDomain && data && data?.length > 0 && (
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
@@ -173,13 +176,15 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
To access the application it is required to set at least 1
|
||||
domain
|
||||
</span>
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
</AddDomain>
|
||||
</div>
|
||||
{canCreateDomain && (
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
</AddDomain>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
||||
@@ -214,47 +219,51 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<AddDomain
|
||||
id={id}
|
||||
type={type}
|
||||
domainId={item.domainId}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
{canCreateDomain && (
|
||||
<AddDomain
|
||||
id={id}
|
||||
type={type}
|
||||
domainId={item.domainId}
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</AddDomain>
|
||||
<DialogAction
|
||||
title="Delete Domain"
|
||||
description="Are you sure you want to delete this domain?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteDomain({
|
||||
domainId: item.domainId,
|
||||
})
|
||||
.then((_data) => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Domain deleted successfully",
|
||||
);
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</AddDomain>
|
||||
)}
|
||||
{canDeleteDomain && (
|
||||
<DialogAction
|
||||
title="Delete Domain"
|
||||
description="Are you sure you want to delete this domain?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteDomain({
|
||||
domainId: item.domainId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting domain");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
.then((_data) => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Domain deleted successfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting domain");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full break-all">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import { type CSSProperties, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -36,6 +36,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canWrite = permissions?.envVars.write ?? false;
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
@@ -60,7 +62,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
mongo: () => api.mongo.update.useMutation(),
|
||||
compose: () => api.compose.update.useMutation(),
|
||||
};
|
||||
const { mutateAsync, isLoading } = mutationMap[type]
|
||||
const { mutateAsync, isPending } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.mongo.update.useMutation();
|
||||
|
||||
@@ -111,7 +113,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
@@ -121,7 +123,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [form, onSubmit, isLoading]);
|
||||
}, [form, onSubmit, isPending]);
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
@@ -185,25 +187,27 @@ PORT=3000
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
{canWrite && (
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
isLoading={isPending}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Cancel
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -31,7 +31,9 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
const { mutateAsync, isLoading } =
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canWrite = permissions?.envVars.write ?? false;
|
||||
const { mutateAsync, isPending } =
|
||||
api.application.saveEnvironment.useMutation();
|
||||
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
@@ -104,7 +106,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
@@ -114,7 +116,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [form, onSubmit, isLoading]);
|
||||
}, [form, onSubmit, isPending]);
|
||||
|
||||
return (
|
||||
<Card className="bg-background px-6 pb-6">
|
||||
@@ -201,27 +203,30 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
{canWrite && (
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{hasChanges && (
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isLoading={isPending}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
className="w-fit"
|
||||
type="submit"
|
||||
disabled={!hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
+32
-21
@@ -1,5 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -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"),
|
||||
@@ -73,15 +74,16 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
api.bitbucket.bitbucketProviders.useQuery();
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync, isLoading: isSavingBitbucketProvider } =
|
||||
const { mutateAsync, isPending: isSavingBitbucketProvider } =
|
||||
api.application.saveBitbucketProvider.useMutation();
|
||||
|
||||
const form = useForm<BitbucketProvider>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
buildPath: "/",
|
||||
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", "");
|
||||
}}
|
||||
@@ -320,7 +333,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "loading" && fetchStatus === "fetching"
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
@@ -337,7 +350,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search branch..."
|
||||
className="h-9"
|
||||
/>
|
||||
{status === "loading" && fetchStatus === "fetching" && (
|
||||
{status === "pending" && fetchStatus === "fetching" && (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Loading Branches....
|
||||
</span>
|
||||
@@ -403,10 +416,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -24,10 +24,10 @@ interface Props {
|
||||
export const SaveDragNDrop = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync, isLoading } =
|
||||
const { mutateAsync, isPending } =
|
||||
api.application.dropDeployment.useMutation();
|
||||
|
||||
const form = useForm<UploadFile>({
|
||||
const form = useForm({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(uploadFileSchema),
|
||||
});
|
||||
@@ -129,8 +129,8 @@ export const SaveDragNDrop = ({ applicationId }: Props) => {
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-fit"
|
||||
isLoading={isLoading}
|
||||
disabled={!zip || isLoading}
|
||||
isLoading={isPending}
|
||||
disabled={!zip || isPending}
|
||||
>
|
||||
Deploy{" "}
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
@@ -58,10 +58,10 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync, isLoading } =
|
||||
const { mutateAsync, isPending } =
|
||||
api.application.saveGitProvider.useMutation();
|
||||
|
||||
const form = useForm<GitProvider>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
branch: "",
|
||||
buildPath: "/",
|
||||
@@ -228,10 +228,8 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
<p>
|
||||
@@ -317,7 +315,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end">
|
||||
<Button type="submit" className="w-fit" isLoading={isLoading}>
|
||||
<Button type="submit" className="w-fit" isLoading={isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
+20
-16
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
@@ -88,10 +88,10 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync, isLoading: isSavingGiteaProvider } =
|
||||
const { mutateAsync, isPending: isSavingGiteaProvider } =
|
||||
api.application.saveGiteaProvider.useMutation();
|
||||
|
||||
const form = useForm<GiteaProvider>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
buildPath: "/",
|
||||
repository: {
|
||||
@@ -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>
|
||||
@@ -349,7 +353,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "loading" && fetchStatus === "fetching"
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
@@ -367,7 +371,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search branch..."
|
||||
className="h-9"
|
||||
/>
|
||||
{status === "loading" && fetchStatus === "fetching" && (
|
||||
{status === "pending" && fetchStatus === "fetching" && (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Loading Branches....
|
||||
</span>
|
||||
@@ -459,7 +463,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
<X
|
||||
className="size-3 cursor-pointer hover:text-destructive"
|
||||
onClick={() => {
|
||||
const newPaths = [...field.value];
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
field.onChange(newPaths);
|
||||
}}
|
||||
@@ -477,7 +481,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
const input = e.currentTarget;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...field.value, path]);
|
||||
field.onChange([...(field.value || []), path]);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
@@ -494,7 +498,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
) as HTMLInputElement;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...field.value, path]);
|
||||
field.onChange([...(field.value || []), path]);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
|
||||
+19
-15
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
@@ -72,10 +72,10 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
const { data: githubProviders } = api.github.githubProviders.useQuery();
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync, isLoading: isSavingGithubProvider } =
|
||||
const { mutateAsync, isPending: isSavingGithubProvider } =
|
||||
api.application.saveGithubProvider.useMutation();
|
||||
|
||||
const form = useForm<GithubProvider>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
buildPath: "/",
|
||||
repository: {
|
||||
@@ -94,7 +94,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
const githubId = form.watch("githubId");
|
||||
const triggerType = form.watch("triggerType");
|
||||
|
||||
const { data: repositories, isLoading: isLoadingRepositories } =
|
||||
const { data: repositories, isPending: isLoadingRepositories } =
|
||||
api.github.getGithubRepositories.useQuery(
|
||||
{
|
||||
githubId,
|
||||
@@ -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>
|
||||
@@ -316,7 +320,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "loading" && fetchStatus === "fetching"
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
@@ -333,7 +337,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search branch..."
|
||||
className="h-9"
|
||||
/>
|
||||
{status === "loading" && fetchStatus === "fetching" && (
|
||||
{status === "pending" && fetchStatus === "fetching" && (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Loading Branches....
|
||||
</span>
|
||||
@@ -455,7 +459,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
key={`${path}-${index}`}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
|
||||
+18
-14
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo } from "react";
|
||||
@@ -74,10 +74,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync, isLoading: isSavingGitlabProvider } =
|
||||
const { mutateAsync, isPending: isSavingGitlabProvider } =
|
||||
api.application.saveGitlabProvider.useMutation();
|
||||
|
||||
const form = useForm<GitlabProvider>({
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
buildPath: "/",
|
||||
repository: {
|
||||
@@ -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>
|
||||
@@ -347,7 +351,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "loading" && fetchStatus === "fetching"
|
||||
{status === "pending" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
@@ -364,7 +368,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
placeholder="Search branch..."
|
||||
className="h-9"
|
||||
/>
|
||||
{status === "loading" && fetchStatus === "fetching" && (
|
||||
{status === "pending" && fetchStatus === "fetching" && (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Loading Branches....
|
||||
</span>
|
||||
@@ -444,7 +448,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
key={`${path}-${index}`}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
|
||||
@@ -36,13 +36,13 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
const { data: githubProviders, isLoading: isLoadingGithub } =
|
||||
const { data: githubProviders, isPending: isLoadingGithub } =
|
||||
api.github.githubProviders.useQuery();
|
||||
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
|
||||
const { data: gitlabProviders, isPending: isLoadingGitlab } =
|
||||
api.gitlab.gitlabProviders.useQuery();
|
||||
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
|
||||
const { data: bitbucketProviders, isPending: isLoadingBitbucket } =
|
||||
api.bitbucket.bitbucketProviders.useQuery();
|
||||
const { data: giteaProviders, isLoading: isLoadingGitea } =
|
||||
const { data: giteaProviders, isPending: isLoadingGitea } =
|
||||
api.gitea.giteaProviders.useQuery();
|
||||
|
||||
const { data: application, refetch } = api.application.one.useQuery({
|
||||
|
||||
@@ -30,6 +30,9 @@ interface Props {
|
||||
|
||||
export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
const router = useRouter();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canDeploy = permissions?.deployment.create ?? false;
|
||||
const canUpdateService = permissions?.service.create ?? false;
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
@@ -37,14 +40,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
const { mutateAsync: update } = api.application.update.useMutation();
|
||||
const { mutateAsync: start, isLoading: isStarting } =
|
||||
const { mutateAsync: start, isPending: isStarting } =
|
||||
api.application.start.useMutation();
|
||||
const { mutateAsync: stop, isLoading: isStopping } =
|
||||
const { mutateAsync: stop, isPending: isStopping } =
|
||||
api.application.stop.useMutation();
|
||||
|
||||
const { mutateAsync: deploy } = api.application.deploy.useMutation();
|
||||
|
||||
const { mutateAsync: reload, isLoading: isReloading } =
|
||||
const { mutateAsync: reload, isPending: isReloading } =
|
||||
api.application.reload.useMutation();
|
||||
|
||||
const { mutateAsync: redeploy } = api.application.redeploy.useMutation();
|
||||
@@ -57,128 +60,135 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-row gap-4 flex-wrap">
|
||||
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
|
||||
<DialogAction
|
||||
title="Deploy Application"
|
||||
description="Are you sure you want to deploy this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await deploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
||||
);
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Deploy Application"
|
||||
description="Are you sure you want to deploy this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await deploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
.then(() => {
|
||||
toast.success("Application deployed successfully");
|
||||
refetch();
|
||||
router.push(
|
||||
`/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/application/${applicationId}?tab=deployments`,
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deploying application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Downloads the source code and performs a complete build
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Reload Application"
|
||||
description="Are you sure you want to reload this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
applicationId: applicationId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application reloaded successfully");
|
||||
refetch();
|
||||
<Button
|
||||
variant="default"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Rocket className="size-4 mr-1" />
|
||||
Deploy
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Downloads the source code and performs a complete
|
||||
build
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Reload Application"
|
||||
description="Are you sure you want to reload this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await reload({
|
||||
applicationId: applicationId,
|
||||
appName: data?.appName || "",
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
.then(() => {
|
||||
toast.success("Application reloaded successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error reloading application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Reload the application without rebuilding it</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Rebuild Application"
|
||||
description="Are you sure you want to rebuild this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Application rebuilt successfully");
|
||||
refetch();
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isReloading}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<RefreshCcw className="size-4 mr-1" />
|
||||
Reload
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>Reload the application without rebuilding it</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Rebuild Application"
|
||||
description="Are you sure you want to rebuild this application?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeploy({
|
||||
applicationId: applicationId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error rebuilding application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
.then(() => {
|
||||
toast.success("Application rebuilt successfully");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error rebuilding application");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Hammer className="size-4 mr-1" />
|
||||
Rebuild
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Only rebuilds the application without downloading new
|
||||
code
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={data?.applicationStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Hammer className="size-4 mr-1" />
|
||||
Rebuild
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Only rebuilds the application without downloading new
|
||||
code
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
|
||||
{data?.applicationStatus === "idle" ? (
|
||||
{canDeploy && data?.applicationStatus === "idle" ? (
|
||||
<DialogAction
|
||||
title="Start Application"
|
||||
description="Are you sure you want to start this application?"
|
||||
@@ -219,7 +229,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
) : (
|
||||
) : canDeploy ? (
|
||||
<DialogAction
|
||||
title="Stop Application"
|
||||
description="Are you sure you want to stop this application?"
|
||||
@@ -256,7 +266,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
) : null}
|
||||
</TooltipProvider>
|
||||
<DockerTerminalModal
|
||||
appName={data?.appName || ""}
|
||||
@@ -270,49 +280,53 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
|
||||
Open Terminal
|
||||
</Button>
|
||||
</DockerTerminalModal>
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle autodeploy"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
{canUpdateService && (
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Autodeploy</span>
|
||||
<Switch
|
||||
aria-label="Toggle autodeploy"
|
||||
checked={data?.autoDeploy || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
autoDeploy: enabled,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
.then(async () => {
|
||||
toast.success("Auto Deploy Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Auto Deploy");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Clean Cache</span>
|
||||
<Switch
|
||||
aria-label="Toggle clean cache"
|
||||
checked={data?.cleanCache || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
cleanCache: enabled,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Clean Cache Updated");
|
||||
await refetch();
|
||||
{canUpdateService && (
|
||||
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
|
||||
<span className="text-sm font-medium">Clean Cache</span>
|
||||
<Switch
|
||||
aria-label="Toggle clean cache"
|
||||
checked={data?.cleanCache || false}
|
||||
onCheckedChange={async (enabled) => {
|
||||
await update({
|
||||
applicationId,
|
||||
cleanCache: enabled,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Clean Cache");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
.then(async () => {
|
||||
toast.success("Clean Cache Updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Clean Cache");
|
||||
});
|
||||
}}
|
||||
className="flex flex-row gap-2 items-center data-[state=checked]:bg-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ShowProviderForm applicationId={applicationId} />
|
||||
|
||||
@@ -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":
|
||||
@@ -55,7 +56,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
const [containerId, setContainerId] = useState<string | undefined>();
|
||||
const [option, setOption] = useState<"swarm" | "native">("native");
|
||||
|
||||
const { data: services, isLoading: servicesLoading } =
|
||||
const { data: services, isPending: servicesLoading } =
|
||||
api.docker.getServiceContainersByAppName.useQuery(
|
||||
{
|
||||
appName,
|
||||
@@ -66,7 +67,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
|
||||
},
|
||||
);
|
||||
|
||||
const { data: containers, isLoading: containersLoading } =
|
||||
const { data: containers, isPending: containersLoading } =
|
||||
api.docker.getContainersByAppNameMatch.useQuery(
|
||||
{
|
||||
appName,
|
||||
@@ -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"}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { FilePlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface Props {
|
||||
folderPath: string;
|
||||
onCreate: (filename: string, content: string) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
alwaysVisible?: boolean;
|
||||
}
|
||||
|
||||
export const CreateFileDialog = ({
|
||||
folderPath,
|
||||
onCreate,
|
||||
onOpenChange,
|
||||
alwaysVisible = false,
|
||||
}: Props) => {
|
||||
const [filename, setFilename] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!filename.trim()) return;
|
||||
onCreate(filename.trim(), content);
|
||||
setFilename("");
|
||||
setContent("");
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
className={`h-6 w-6 ${alwaysVisible ? "" : "opacity-0 group-hover:opacity-100"}`}
|
||||
title="Create file"
|
||||
>
|
||||
<FilePlus className="h-3 w-3" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create file</DialogTitle>
|
||||
<DialogDescription>
|
||||
{folderPath ? `New file in ${folderPath}/` : "New file in root"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="filename">Filename</Label>
|
||||
<Input
|
||||
id="filename"
|
||||
placeholder="e.g. .env.example"
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Content</Label>
|
||||
<div className="h-[200px] rounded-md border">
|
||||
<CodeEditor
|
||||
value={content}
|
||||
onChange={(v) => setContent(v ?? "")}
|
||||
className="h-full"
|
||||
wrapperClassName="h-[200px]"
|
||||
lineWrapping
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button type="submit" disabled={!filename.trim()}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Loader2, Pencil } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
patchId: string;
|
||||
entityId: string;
|
||||
type: "application" | "compose";
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const EditPatchDialog = ({
|
||||
patchId,
|
||||
entityId,
|
||||
type,
|
||||
onSuccess,
|
||||
}: Props) => {
|
||||
const { data: patch, isPending: isPatchLoading } = api.patch.one.useQuery(
|
||||
{ patchId },
|
||||
{ enabled: !!patchId },
|
||||
);
|
||||
const [content, setContent] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (patch) {
|
||||
setContent(patch.content);
|
||||
}
|
||||
}, [patch]);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const updatePatch = api.patch.update.useMutation();
|
||||
|
||||
const handleSave = () => {
|
||||
updatePatch
|
||||
.mutateAsync({ patchId, content })
|
||||
.then(() => {
|
||||
toast.success("Patch saved");
|
||||
utils.patch.byEntityId.invalidate({ id: entityId, type });
|
||||
onSuccess?.();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title="Edit patch">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[85vh] flex flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle>Edit Patch</DialogTitle>
|
||||
<DialogDescription>
|
||||
{patch ? `Editing: ${patch.filePath}` : "Loading patch..."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isPatchLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center px-6 py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 px-6 overflow-hidden flex flex-col">
|
||||
<CodeEditor
|
||||
value={content}
|
||||
onChange={(value) => setContent(value ?? "")}
|
||||
className="h-[400px] w-full"
|
||||
wrapperClassName="h-[400px]"
|
||||
lineWrapping
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="px-6 ">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button onClick={handleSave} isLoading={updatePatch.isPending}>
|
||||
{updatePatch.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./show-patches";
|
||||
export * from "./patch-editor";
|
||||
@@ -0,0 +1,368 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
File,
|
||||
Folder,
|
||||
Loader2,
|
||||
Save,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { api } from "@/utils/api";
|
||||
import { CreateFileDialog } from "./create-file-dialog";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "application" | "compose";
|
||||
repoPath: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type DirectoryEntry = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "directory";
|
||||
children?: DirectoryEntry[];
|
||||
};
|
||||
|
||||
export const PatchEditor = ({ id, type, repoPath, onClose }: Props) => {
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState<string>("");
|
||||
const [createFolderPath, setCreateFolderPath] = useState<string | null>(null);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data: directories, isPending: isDirLoading } =
|
||||
api.patch.readRepoDirectories.useQuery(
|
||||
{ id: id, type, repoPath },
|
||||
{ enabled: !!repoPath },
|
||||
);
|
||||
|
||||
const { data: patches } = api.patch.byEntityId.useQuery(
|
||||
{ id, type },
|
||||
{ enabled: !!id },
|
||||
);
|
||||
|
||||
const { mutateAsync: saveAsPatch, isPending: isSavingPatch } =
|
||||
api.patch.saveFileAsPatch.useMutation();
|
||||
|
||||
const { mutateAsync: markForDeletion, isPending: isMarkingDeletion } =
|
||||
api.patch.markFileForDeletion.useMutation();
|
||||
|
||||
const updatePatch = api.patch.update.useMutation();
|
||||
|
||||
const { data: fileData, isFetching: isFileLoading } =
|
||||
api.patch.readRepoFile.useQuery(
|
||||
{
|
||||
id,
|
||||
type,
|
||||
filePath: selectedFile || "",
|
||||
},
|
||||
{
|
||||
enabled: !!selectedFile,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (fileData !== undefined) {
|
||||
setFileContent(fileData);
|
||||
}
|
||||
}, [fileData]);
|
||||
|
||||
const handleFileSelect = (filePath: string) => {
|
||||
setSelectedFile(filePath);
|
||||
};
|
||||
|
||||
const toggleFolder = (path: string) => {
|
||||
setExpandedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!selectedFile) return;
|
||||
saveAsPatch({
|
||||
id,
|
||||
type,
|
||||
filePath: selectedFile,
|
||||
content: fileContent,
|
||||
patchType: "update",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Patch saved");
|
||||
utils.patch.byEntityId.invalidate({ id, type });
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to save patch");
|
||||
});
|
||||
};
|
||||
|
||||
const handleMarkForDeletion = () => {
|
||||
if (!selectedFile) return;
|
||||
markForDeletion({ id, type, filePath: selectedFile })
|
||||
.then(() => {
|
||||
toast.success("File marked for deletion");
|
||||
utils.patch.byEntityId.invalidate({ id, type });
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to mark file for deletion");
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateFile = useCallback(
|
||||
(folderPath: string, filename: string, content: string) => {
|
||||
const filePath = folderPath ? `${folderPath}/${filename}` : filename;
|
||||
saveAsPatch({
|
||||
id,
|
||||
type,
|
||||
filePath,
|
||||
content,
|
||||
patchType: "create",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("File created");
|
||||
utils.patch.byEntityId.invalidate({ id, type });
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to create file");
|
||||
});
|
||||
},
|
||||
[id, type, saveAsPatch, utils],
|
||||
);
|
||||
|
||||
const selectedFilePatch = patches?.find(
|
||||
(p) => p.filePath === selectedFile && p.type === "delete",
|
||||
);
|
||||
|
||||
const handleUnmarkDeletion = () => {
|
||||
if (!selectedFilePatch) return;
|
||||
updatePatch
|
||||
.mutateAsync({
|
||||
patchId: selectedFilePatch.patchId,
|
||||
type: "update",
|
||||
content: fileData || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Deletion unmarked");
|
||||
utils.patch.byEntityId.invalidate({ id, type });
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Failed to unmark deletion");
|
||||
});
|
||||
};
|
||||
|
||||
const hasChanges = fileData !== undefined && fileContent !== fileData;
|
||||
|
||||
const renderTree = useCallback(
|
||||
(entries: DirectoryEntry[], depth = 0) => {
|
||||
return entries
|
||||
.sort((a, b) => {
|
||||
// Directories first, then alphabetically
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
.map((entry) => {
|
||||
const isExpanded = expandedFolders.has(entry.path);
|
||||
const isSelected = selectedFile === entry.path;
|
||||
|
||||
if (entry.type === "directory") {
|
||||
return (
|
||||
<div key={entry.path}>
|
||||
<div className="group flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFolder(entry.path)}
|
||||
className={
|
||||
"flex-1 flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors text-left min-w-0"
|
||||
}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 shrink-0 transition-transform ${
|
||||
isExpanded ? "rotate-90" : ""
|
||||
}`}
|
||||
/>
|
||||
<Folder className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
<CreateFileDialog
|
||||
folderPath={entry.path}
|
||||
onCreate={(filename, content) =>
|
||||
handleCreateFile(entry.path, filename, content)
|
||||
}
|
||||
onOpenChange={(open) =>
|
||||
setCreateFolderPath(open ? entry.path : null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && entry.children && (
|
||||
<div>{renderTree(entry.children, depth + 1)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isMarkedForDeletion = patches?.some(
|
||||
(p) => p.filePath === entry.path && p.type === "delete",
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.path}
|
||||
onClick={() => handleFileSelect(entry.path)}
|
||||
className={`w-full flex items-center gap-2 px-2 py-1.5 text-sm hover:bg-muted/50 rounded-md transition-colors ${
|
||||
isSelected ? "bg-muted" : ""
|
||||
} ${isMarkedForDeletion ? "text-destructive" : ""}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 28}px` }}
|
||||
>
|
||||
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{entry.name}</span>
|
||||
{isMarkedForDeletion && (
|
||||
<Trash2 className="h-3 w-3 shrink-0 text-destructive ml-auto" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
},
|
||||
[expandedFolders, selectedFile, patches, handleCreateFile],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="bg-background overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<CardTitle>Edit File</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedFile
|
||||
? `Editing: ${selectedFile}`
|
||||
: "Select a file from the tree to edit"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{selectedFile && (
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedFilePatch ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleUnmarkDeletion}
|
||||
disabled={updatePatch.isPending}
|
||||
>
|
||||
{updatePatch.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Unmark deletion
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleMarkForDeletion}
|
||||
disabled={isMarkingDeletion}
|
||||
>
|
||||
{isMarkingDeletion && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Mark for deletion
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSavingPatch || !hasChanges}
|
||||
>
|
||||
{isSavingPatch && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Patch
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="grid grid-cols-[250px_1fr] border-t h-[600px]">
|
||||
<div className="border-r h-full overflow-hidden">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2 space-y-1">
|
||||
<div className="group flex items-center gap-2 px-2 py-1.5 mb-1">
|
||||
<CreateFileDialog
|
||||
folderPath=""
|
||||
alwaysVisible
|
||||
onCreate={(filename, content) =>
|
||||
handleCreateFile("", filename, content)
|
||||
}
|
||||
onOpenChange={(open) =>
|
||||
setCreateFolderPath(open ? "" : null)
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
New file in root
|
||||
</span>
|
||||
</div>
|
||||
{isDirLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : directories ? (
|
||||
renderTree(directories)
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground p-4">
|
||||
No files found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="h-full overflow-hidden relative">
|
||||
{isFileLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : selectedFile ? (
|
||||
<CodeEditor
|
||||
value={fileData || ""}
|
||||
onChange={(value) => setFileContent(value || "")}
|
||||
className="h-full w-full"
|
||||
wrapperClassName="h-full"
|
||||
lineWrapping
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Select a file to edit
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
import { File, FilePlus2, Loader2, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/utils/api";
|
||||
import { EditPatchDialog } from "./edit-patch-dialog";
|
||||
import { PatchEditor } from "./patch-editor";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "application" | "compose";
|
||||
}
|
||||
|
||||
export const ShowPatches = ({ id, type }: Props) => {
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [repoPath, setRepoPath] = useState<string | null>(null);
|
||||
const [isLoadingRepo, setIsLoadingRepo] = useState(false);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: patches, isPending: isPatchesLoading } =
|
||||
api.patch.byEntityId.useQuery({ id, type }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
application: () => api.patch.delete.useMutation(),
|
||||
compose: () => api.patch.delete.useMutation(),
|
||||
};
|
||||
|
||||
const ensureRepo = api.patch.ensureRepo.useMutation();
|
||||
|
||||
const togglePatch = api.patch.toggleEnabled.useMutation();
|
||||
|
||||
const { mutateAsync } = mutationMap[type]
|
||||
? mutationMap[type]()
|
||||
: api.patch.delete.useMutation();
|
||||
|
||||
const handleCloseEditor = () => {
|
||||
setSelectedFile(null);
|
||||
setRepoPath(null);
|
||||
};
|
||||
|
||||
if (repoPath) {
|
||||
return (
|
||||
<PatchEditor
|
||||
id={id}
|
||||
type={type}
|
||||
repoPath={repoPath || ""}
|
||||
onClose={handleCloseEditor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleOpenEditor = async () => {
|
||||
setIsLoadingRepo(true);
|
||||
await ensureRepo
|
||||
.mutateAsync({ id, type })
|
||||
.then((result) => {
|
||||
setRepoPath(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingRepo(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Patches</CardTitle>
|
||||
<CardDescription>
|
||||
Apply code patches to your repository during build. Patches are
|
||||
applied after cloning the repository and before building.
|
||||
</CardDescription>
|
||||
</div>
|
||||
{patches && patches?.length > 0 && (
|
||||
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
|
||||
{isLoadingRepo && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<FilePlus2 className="mr-2 h-4 w-4" />
|
||||
Create Patch
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isPatchesLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : patches?.length === 0 ? (
|
||||
<div className="flex min-h-[40vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<FilePlus2 className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<p className="text-sm font-medium">No patches yet</p>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
Add file patches to modify your repo before each build—configs,
|
||||
env, or code. Create your first patch to get started.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleOpenEditor} disabled={isLoadingRepo}>
|
||||
{isLoadingRepo && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
<FilePlus2 className="mr-2 h-4 w-4" />
|
||||
Create Patch
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>File Path</TableHead>
|
||||
<TableHead className="w-[80px]">Type</TableHead>
|
||||
<TableHead className="w-[100px]">Enabled</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{patches?.map((patch) => (
|
||||
<TableRow key={patch.patchId}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
{patch.filePath}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
patch.type === "delete"
|
||||
? "destructive"
|
||||
: patch.type === "create"
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
className="font-normal"
|
||||
>
|
||||
{patch.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={patch.enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
togglePatch
|
||||
.mutateAsync({
|
||||
patchId: patch.patchId,
|
||||
enabled: checked,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Patch updated");
|
||||
utils.patch.byEntityId.invalidate({
|
||||
id,
|
||||
type,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingRepo(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
{(patch.type === "update" || patch.type === "create") && (
|
||||
<EditPatchDialog
|
||||
patchId={patch.patchId}
|
||||
entityId={id}
|
||||
type={type}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
mutateAsync({ patchId: patch.patchId })
|
||||
.then(() => {
|
||||
toast.success("Patch deleted");
|
||||
utils.patch.byEntityId.invalidate({
|
||||
id,
|
||||
type,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
title="Delete patch"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { Dices } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -75,11 +75,11 @@ export const AddPreviewDomain = ({
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isError, error, isLoading } = domainId
|
||||
const { mutateAsync, isError, error, isPending } = domainId
|
||||
? api.domain.update.useMutation()
|
||||
: api.domain.create.useMutation();
|
||||
|
||||
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
|
||||
const { mutateAsync: generateDomain, isPending: isLoadingGenerate } =
|
||||
api.domain.generateDomain.useMutation();
|
||||
|
||||
const form = useForm<Domain>({
|
||||
@@ -103,7 +103,7 @@ export const AddPreviewDomain = ({
|
||||
if (!domainId) {
|
||||
form.reset({});
|
||||
}
|
||||
}, [form, form.reset, data, isLoading]);
|
||||
}, [form, form.reset, data, isPending]);
|
||||
|
||||
const dictionary = {
|
||||
success: domainId ? "Domain Updated" : "Domain Created",
|
||||
@@ -301,7 +301,7 @@ export const AddPreviewDomain = ({
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button isLoading={isLoading} form="hook-form" type="submit">
|
||||
<Button isLoading={isPending} form="hook-form" type="submit">
|
||||
{dictionary.submit}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user