mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Compare commits
482 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2619cb49d1 | ||
|
|
46d12fa9d8 | ||
|
|
51ee46496c | ||
|
|
a13e24dab0 | ||
|
|
4aac3476b6 | ||
|
|
037343a796 | ||
|
|
274d80ea7c | ||
|
|
629889f1a8 | ||
|
|
3e74ce05a7 | ||
|
|
d05218e848 | ||
|
|
0fbad4f75e | ||
|
|
c3cbaf2a57 | ||
|
|
560d493d56 | ||
|
|
27b2106630 | ||
|
|
609954c366 | ||
|
|
84faa9747e | ||
|
|
4b370ef43e | ||
|
|
b94a6bff92 | ||
|
|
276b754377 | ||
|
|
f3b3798362 | ||
|
|
461acc354e | ||
|
|
dfc75a9116 | ||
|
|
e1580bad23 | ||
|
|
b567ec1d83 | ||
|
|
9c73b8dc36 | ||
|
|
7348526873 | ||
|
|
6fc83f2db3 | ||
|
|
43d22c2bd4 | ||
|
|
38a5313967 | ||
|
|
ba3645933f | ||
|
|
17a26353b6 | ||
|
|
e2c163c6d5 | ||
|
|
616e11722c | ||
|
|
91a44706df | ||
|
|
748de47a6d | ||
|
|
cbf9aef0df | ||
|
|
e2befc24a5 | ||
|
|
0f48f2c830 | ||
|
|
5dfa7645f3 | ||
|
|
7fe163dd33 | ||
|
|
19b56771b8 | ||
|
|
cff01ed438 | ||
|
|
10fa3c8cf1 | ||
|
|
6c5497ed21 | ||
|
|
380656efee | ||
|
|
c64d2245ce | ||
|
|
a985998b93 | ||
|
|
4f3ba16dfa | ||
|
|
6c788429f1 | ||
|
|
3176a9d7e3 | ||
|
|
94a6a9587e | ||
|
|
911681f389 | ||
|
|
5992688e85 | ||
|
|
425061e481 | ||
|
|
08c0bf8a21 | ||
|
|
64a2c9e0a1 | ||
|
|
21e46f5382 | ||
|
|
52b2158309 | ||
|
|
178d84d438 | ||
|
|
80016b57a8 | ||
|
|
b4b2d12f6e | ||
|
|
294378d95b | ||
|
|
c52812f9d3 | ||
|
|
82f7c5d5f3 | ||
|
|
3d2ae52259 | ||
|
|
bf115c7895 | ||
|
|
c2c29dbaba | ||
|
|
d4032f34bf | ||
|
|
136570b36c | ||
|
|
7d0075c230 | ||
|
|
19b4edee8d | ||
|
|
7f04eb856e | ||
|
|
5156b45ffc | ||
|
|
80e6f21840 | ||
|
|
5b519151e8 | ||
|
|
aa475e6123 | ||
|
|
66756c34fe | ||
|
|
946a5739dc | ||
|
|
6c817a9e5d | ||
|
|
6aea937e86 | ||
|
|
19612d4b66 | ||
|
|
47dd003461 | ||
|
|
def99225fc | ||
|
|
32405fc61a | ||
|
|
25e1a9af57 | ||
|
|
1fcb1f2c5e | ||
|
|
fdaba7e752 | ||
|
|
c1640cba29 | ||
|
|
3bd54ff61e | ||
|
|
5853d18bc1 | ||
|
|
f575317906 | ||
|
|
e6028e73ac | ||
|
|
bcbed151e8 | ||
|
|
c708f7ba62 | ||
|
|
95a538f261 | ||
|
|
f854457d69 | ||
|
|
cd998c37f1 | ||
|
|
d46a61098b | ||
|
|
8f14d854a0 | ||
|
|
388399b370 | ||
|
|
a8b4bb9c41 | ||
|
|
ebc8c2f73d | ||
|
|
1227d2b5fc | ||
|
|
314438b84c | ||
|
|
cc5574e08a | ||
|
|
11a8fcc476 | ||
|
|
c50229a33c | ||
|
|
0609d74d2b | ||
|
|
fce8eca894 | ||
|
|
3de0d674ed | ||
|
|
7faab54a65 | ||
|
|
40d9db7ccf | ||
|
|
c7c01f57d4 | ||
|
|
45cf295be0 | ||
|
|
79372527e6 | ||
|
|
edcfc7d670 | ||
|
|
6277ebaaec | ||
|
|
2b081166f9 | ||
|
|
d8f12f1780 | ||
|
|
95d949f112 | ||
|
|
1ec0c8e8b3 | ||
|
|
b9ac25ef42 | ||
|
|
9fe2460b88 | ||
|
|
44af0ec975 | ||
|
|
b84b4549a0 | ||
|
|
c110fae965 | ||
|
|
86b56e2597 | ||
|
|
7e365e1947 | ||
|
|
d458536803 | ||
|
|
9cb5b9a7d0 | ||
|
|
7e9fccfcb0 | ||
|
|
1c73dab719 | ||
|
|
3ec339fc89 | ||
|
|
c13a68dab4 | ||
|
|
eb5ba2f219 | ||
|
|
1eda6513df | ||
|
|
4bb60b9f7e | ||
|
|
9948dd7f19 | ||
|
|
e3ec8f1589 | ||
|
|
9aa56870b0 | ||
|
|
06c9e43143 | ||
|
|
e09447d4b4 | ||
|
|
2fa0c7dfd2 | ||
|
|
27521decbd | ||
|
|
4bec311ad0 | ||
|
|
432f616896 | ||
|
|
4575f16be4 | ||
|
|
d6f5f6e6cb | ||
|
|
96b1df2199 | ||
|
|
614b9d25a8 | ||
|
|
66dd890448 | ||
|
|
557c89ac6d | ||
|
|
b3e2af3b40 | ||
|
|
a8159e5f99 | ||
|
|
89306a7619 | ||
|
|
0690f07262 | ||
|
|
e437903ef8 | ||
|
|
50aeeb2fb8 | ||
|
|
d85fc2e513 | ||
|
|
cfba9a7d79 | ||
|
|
4a8cadc6ee | ||
|
|
b2d938d2fc | ||
|
|
9277427153 | ||
|
|
ccb141339b | ||
|
|
c87af312ca | ||
|
|
a5fb5532fd | ||
|
|
43ab1aa7b8 | ||
|
|
3072795232 | ||
|
|
98d0f1d5bf | ||
|
|
49e55961db | ||
|
|
4ee220c1d8 | ||
|
|
c69992c4f0 | ||
|
|
1f6ba45c12 | ||
|
|
ef02ba22b5 | ||
|
|
d7daa6d8e0 | ||
|
|
fafa14c10a | ||
|
|
2c90103823 | ||
|
|
f2bb01c800 | ||
|
|
442f051457 | ||
|
|
e84ce38994 | ||
|
|
0ea264ea42 | ||
|
|
d4064805eb | ||
|
|
d5c77fded3 | ||
|
|
d853b1d326 | ||
|
|
d57e347fdc | ||
|
|
25f3980492 | ||
|
|
c8e2f4bfdc | ||
|
|
4afc6ac250 | ||
|
|
52a660add3 | ||
|
|
3ad5982f39 | ||
|
|
8ba4ac22cc | ||
|
|
bcebcfdfdf | ||
|
|
77b1ec4733 | ||
|
|
cfae5f7e6c | ||
|
|
0f67e9e222 | ||
|
|
c4045795ee | ||
|
|
24f3be3c00 | ||
|
|
5055994bd3 | ||
|
|
ddcb22dff9 | ||
|
|
77d7dc1f22 | ||
|
|
19bf4f27b6 | ||
|
|
6b9765a26c | ||
|
|
8d91053c3a | ||
|
|
7c2eb63625 | ||
|
|
2ea2605ab1 | ||
|
|
7ae3ff22ee | ||
|
|
fa6baa0c1a | ||
|
|
5b43df92c1 | ||
|
|
f3032bc94f | ||
|
|
eef874ecd4 | ||
|
|
d6daa5677a | ||
|
|
dc03ba73b3 | ||
|
|
5c2159f7b2 | ||
|
|
ffcdbcf046 | ||
|
|
bb5c6bebff | ||
|
|
144d48057c | ||
|
|
55dc08b6ba | ||
|
|
56f525803b | ||
|
|
91bcd1238f | ||
|
|
120646c77b | ||
|
|
c0b35efaca | ||
|
|
22dee88e51 | ||
|
|
1645f7e932 | ||
|
|
b4aeb6577e | ||
|
|
fdd330ca19 | ||
|
|
33de620893 | ||
|
|
6518407c0c | ||
|
|
6f47999a2e | ||
|
|
fe69d5d405 | ||
|
|
a6880fd38c | ||
|
|
5d25de13dd | ||
|
|
5611dcccfd | ||
|
|
e2a1882fe3 | ||
|
|
ceb16ae9f7 | ||
|
|
1911b5b674 | ||
|
|
6b818bbb51 | ||
|
|
79796185d6 | ||
|
|
461d7c530a | ||
|
|
ade4b8dd1b | ||
|
|
f49a67f8df | ||
|
|
c3986d7a08 | ||
|
|
0bf4e5560c | ||
|
|
79d55d8d34 | ||
|
|
d4c6e5b048 | ||
|
|
cd4eed3507 | ||
|
|
a650bd16fb | ||
|
|
4e5b5f219e | ||
|
|
dfda934726 | ||
|
|
e6d0b7b4ee | ||
|
|
d0dbc1837f | ||
|
|
2b5af1897f | ||
|
|
11b9cee73d | ||
|
|
bc17991580 | ||
|
|
459c94929a | ||
|
|
8d28a50a17 | ||
|
|
08bbeceeba | ||
|
|
b7bf09bf21 | ||
|
|
546c6ade82 | ||
|
|
db2e3691a5 | ||
|
|
a6dca144a8 | ||
|
|
43a17e7e75 | ||
|
|
da60c4f3a8 | ||
|
|
e14f2780af | ||
|
|
33ab87f3db | ||
|
|
571d73a5b6 | ||
|
|
a630909612 | ||
|
|
8eaa006f0f | ||
|
|
8e8bc3e71e | ||
|
|
f4cd617107 | ||
|
|
48cfe66a6b | ||
|
|
bdc10cacef | ||
|
|
8fbad8a26e | ||
|
|
0f36bcb04e | ||
|
|
f4054453b4 | ||
|
|
dbd36fc024 | ||
|
|
850d06a32c | ||
|
|
dfd3dc180d | ||
|
|
3d42bfc81b | ||
|
|
764f8ec993 | ||
|
|
d2eaa4b40b | ||
|
|
7d7f2b4b1f | ||
|
|
8c36e48fe7 | ||
|
|
8e97c63faa | ||
|
|
74ec8f4594 | ||
|
|
76c0bff13a | ||
|
|
9b5cd0f5fe | ||
|
|
efee798880 | ||
|
|
1c470b8ba7 | ||
|
|
692864ced1 | ||
|
|
9ca61476d2 | ||
|
|
773a610be1 | ||
|
|
37f9e073f0 | ||
|
|
d335a9515d | ||
|
|
7a5a3de43d | ||
|
|
ef7918a33a | ||
|
|
ee6ad07c0a | ||
|
|
48fe26b204 | ||
|
|
3ede89fe8a | ||
|
|
fa698d173e | ||
|
|
1279fac137 | ||
|
|
0e1f0b42ee | ||
|
|
05f43ad06b | ||
|
|
af4511040f | ||
|
|
8f0697b0e9 | ||
|
|
4b15177260 | ||
|
|
61a20f13e2 | ||
|
|
f5cffca37c | ||
|
|
148b1ff2db | ||
|
|
1beceb7ee7 | ||
|
|
bea0316bbd | ||
|
|
b2a8572d10 | ||
|
|
2352939e87 | ||
|
|
e5ee06b67e | ||
|
|
48ec0a74ad | ||
|
|
bca6af77fd | ||
|
|
b3bd9ba1ce | ||
|
|
5a9c763c4f | ||
|
|
4b51744d0d | ||
|
|
e5a3e56e13 | ||
|
|
42fa4008ab | ||
|
|
1605aedd6e | ||
|
|
14bc26e065 | ||
|
|
6c8eb3b711 | ||
|
|
cb20950dd9 | ||
|
|
350bed217c | ||
|
|
7ac7481343 | ||
|
|
d9c34c4524 | ||
|
|
e83efa3379 | ||
|
|
5863e45c13 | ||
|
|
2c09b63bf9 | ||
|
|
eff2657e70 | ||
|
|
36172491a4 | ||
|
|
d43b098a7a | ||
|
|
8479f20205 | ||
|
|
6cb4159d54 | ||
|
|
1bbbdfba60 | ||
|
|
031d0ce315 | ||
|
|
131a1acbbe | ||
|
|
9a839de022 | ||
|
|
b9de05015f | ||
|
|
e176def5b6 | ||
|
|
94c947e288 | ||
|
|
fcb8a2bded | ||
|
|
116e33ce37 | ||
|
|
0bdaa81263 | ||
|
|
baf36b6fb6 | ||
|
|
d632e83799 | ||
|
|
6f52edd845 | ||
|
|
e9b92d2641 | ||
|
|
9d0f5bc8cd | ||
|
|
3dc558c260 | ||
|
|
180aa34140 | ||
|
|
96e9799afb | ||
|
|
3e07be38df | ||
|
|
ffc85b04a8 | ||
|
|
dbcfc702d4 | ||
|
|
67e85cabcb | ||
|
|
7805efc738 | ||
|
|
3910e22412 | ||
|
|
2f16034cb0 | ||
|
|
d4925dd2b7 | ||
|
|
5aba6c79a0 | ||
|
|
84f5627471 | ||
|
|
4eaf8fee0f | ||
|
|
adee87b6da | ||
|
|
e5e987fcf9 | ||
|
|
d0a6373dcc | ||
|
|
8ed44066ad | ||
|
|
befe2193a7 | ||
|
|
f20c73cdee | ||
|
|
64a77decfd | ||
|
|
16bfc09202 | ||
|
|
d54a61b2a4 | ||
|
|
60c09a6434 | ||
|
|
5361e9074f | ||
|
|
13d4dea504 | ||
|
|
ffc2d593e4 | ||
|
|
297439a348 | ||
|
|
ff3e067866 | ||
|
|
f008a45bf2 | ||
|
|
50c8503cf9 | ||
|
|
930a03de60 | ||
|
|
2d3d86e823 | ||
|
|
7bab166e1b | ||
|
|
7a6e1dbc1b | ||
|
|
17a859d26d | ||
|
|
d793c6a2ec | ||
|
|
3adb9d54f4 | ||
|
|
7144adbf0c | ||
|
|
55328468d1 | ||
|
|
6968cb6930 | ||
|
|
a431e4c58e | ||
|
|
fe967239b4 | ||
|
|
c5b4b85470 | ||
|
|
b1ef9d25b1 | ||
|
|
74f7c51530 | ||
|
|
4ba2b9fe8d | ||
|
|
413eda50f4 | ||
|
|
9f09681708 | ||
|
|
8eb174812d | ||
|
|
be77f114eb | ||
|
|
ca42708035 | ||
|
|
8b03454a87 | ||
|
|
fa7f749f84 | ||
|
|
3daecd7d71 | ||
|
|
0666b5b292 | ||
|
|
b288ddd826 | ||
|
|
beadcf871a | ||
|
|
ee49dadf0b | ||
|
|
46de83a1de | ||
|
|
fee5024b7d | ||
|
|
1f28a21835 | ||
|
|
0114b371f5 | ||
|
|
66d6cb5710 | ||
|
|
5927c7c3c5 | ||
|
|
84afcf0de5 | ||
|
|
e3527f7d69 | ||
|
|
cc5a3e6873 | ||
|
|
39f4a35cc8 | ||
|
|
e0433e9f7b | ||
|
|
d29ff881fc | ||
|
|
568c3a1d06 | ||
|
|
e9fd280fa2 | ||
|
|
5d5913f39d | ||
|
|
f04c8a36af | ||
|
|
d5137d5d3a | ||
|
|
9535fca28f | ||
|
|
dd62d603e0 | ||
|
|
8d227e2a2c | ||
|
|
048c8ffc11 | ||
|
|
b59597630c | ||
|
|
707463f973 | ||
|
|
4b3e0805a4 | ||
|
|
148c30f604 | ||
|
|
95f79f2afb | ||
|
|
a067abd3e4 | ||
|
|
9359ee7a04 | ||
|
|
fc7eff94b6 | ||
|
|
ff3d444b89 | ||
|
|
530ad31aaa | ||
|
|
68d0a48843 | ||
|
|
a4e4d1c467 | ||
|
|
56d8defebe | ||
|
|
997e755b6f | ||
|
|
852011dde8 | ||
|
|
d7ef201adb | ||
|
|
b1d1763988 | ||
|
|
91183056f0 | ||
|
|
03bd4398d0 | ||
|
|
8c260eff72 | ||
|
|
4eef65f1b7 | ||
|
|
a7535c6862 | ||
|
|
b5d199057d | ||
|
|
6e28196b0e | ||
|
|
18bacae175 | ||
|
|
f2be5a378e | ||
|
|
aef24296b9 | ||
|
|
7123b9b109 | ||
|
|
891dc840f5 | ||
|
|
bc78100613 | ||
|
|
ff22404b3b | ||
|
|
bfb6baf572 | ||
|
|
17330ca71a | ||
|
|
2898a5e575 | ||
|
|
fac8ea7a30 | ||
|
|
9a11d0db97 | ||
|
|
3cdf4c426c | ||
|
|
7cb184dc97 | ||
|
|
fe57333f84 | ||
|
|
04fd77c3a9 | ||
|
|
371c6317aa | ||
|
|
1f81794904 | ||
|
|
d5d3831d54 | ||
|
|
cf28640188 | ||
|
|
856399550a | ||
|
|
ea39b152f4 | ||
|
|
027406547e | ||
|
|
2974a8183e | ||
|
|
86b8b0987b | ||
|
|
ac0922d742 | ||
|
|
0dac1fefe6 | ||
|
|
633ba899e0 |
BIN
.github/sponsors/agentdock.png
vendored
Normal file
BIN
.github/sponsors/agentdock.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
.github/sponsors/american-cloud.png
vendored
Normal file
BIN
.github/sponsors/american-cloud.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
2
.github/workflows/dokploy.yml
vendored
2
.github/workflows/dokploy.yml
vendored
@@ -2,7 +2,7 @@ name: Dokploy Docker Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, canary, "feat/better-auth-2"]
|
||||
branches: [main, canary, "1061-custom-docker-service-hostname"]
|
||||
|
||||
env:
|
||||
IMAGE_NAME: dokploy/dokploy
|
||||
|
||||
22
.github/workflows/format.yml
vendored
Normal file
22
.github/workflows/format.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: autofix.ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [canary]
|
||||
pull_request:
|
||||
branches: [canary]
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup biomeJs
|
||||
uses: biomejs/setup-biome@v2
|
||||
|
||||
- name: Run Biome formatter
|
||||
run: biome format . --write
|
||||
|
||||
- uses: autofix-ci/action@551dded8c6cc8a1054039c8bc0b8b48c51dfc6ef
|
||||
6
.github/workflows/pull-request.yml
vendored
6
.github/workflows/pull-request.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.9.0
|
||||
node-version: 20.16.0
|
||||
cache: "pnpm"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm run server:build
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.9.0
|
||||
node-version: 20.16.0
|
||||
cache: "pnpm"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm run server:build
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.9.0
|
||||
node-version: 20.16.0
|
||||
cache: "pnpm"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm run server:build
|
||||
|
||||
@@ -52,7 +52,7 @@ feat: add new feature
|
||||
|
||||
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
|
||||
|
||||
We use Node v20.9.0
|
||||
We use Node v20.16.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.16.0 && nvm use` in the root directory.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dokploy/dokploy.git
|
||||
@@ -61,9 +61,9 @@ pnpm install
|
||||
cp apps/dokploy/.env.example apps/dokploy/.env
|
||||
```
|
||||
|
||||
## Development
|
||||
## Requirements
|
||||
|
||||
Is required to have **Docker** installed on your machine.
|
||||
- [Docker](/GUIDES.md#docker)
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -87,6 +87,8 @@ pnpm run dokploy:dev
|
||||
|
||||
Go to http://localhost:3000 to see the development server
|
||||
|
||||
Note: this project uses Biome. If your editor is configured to use another formatter such as Prettier, it's recommended to either change it to use Biome or turn it off.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
@@ -145,11 +147,9 @@ curl -sSL https://railpack.com/install.sh | sh
|
||||
|
||||
```bash
|
||||
# Install Buildpacks
|
||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.32.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Pull Request
|
||||
|
||||
- The `main` branch is the source of truth and should always reflect the latest stable release.
|
||||
@@ -167,7 +167,6 @@ Thank you for your contribution!
|
||||
|
||||
To add a new template, go to `https://github.com/Dokploy/templates` repository and read the README.md file.
|
||||
|
||||
|
||||
### Recommendations
|
||||
|
||||
- Use the same name of the folder as the id of the template.
|
||||
|
||||
@@ -29,7 +29,7 @@ WORKDIR /app
|
||||
# Set production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apt-get update && apt-get install -y curl unzip apache2-utils iproute2 && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy only the necessary files
|
||||
COPY --from=build /prod/dokploy/.next ./.next
|
||||
@@ -49,18 +49,18 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm
|
||||
# Install Nixpacks and tsx
|
||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||
|
||||
ARG NIXPACKS_VERSION=1.29.1
|
||||
ARG NIXPACKS_VERSION=1.39.0
|
||||
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
||||
&& chmod +x install.sh \
|
||||
&& ./install.sh \
|
||||
&& pnpm install -g tsx
|
||||
|
||||
# Install Railpack
|
||||
ARG RAILPACK_VERSION=0.0.37
|
||||
ARG RAILPACK_VERSION=0.0.64
|
||||
RUN curl -sSL https://railpack.com/install.sh | bash
|
||||
|
||||
# Install buildpacks
|
||||
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
|
||||
|
||||
EXPOSE 3000
|
||||
CMD [ "pnpm", "start" ]
|
||||
CMD [ "pnpm", "start" ]
|
||||
|
||||
49
GUIDES.md
Normal file
49
GUIDES.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Docker
|
||||
|
||||
Here's how to install docker on different operating systems:
|
||||
|
||||
## macOS
|
||||
|
||||
1. Visit [Docker Desktop for Mac](https://www.docker.com/products/docker-desktop)
|
||||
2. Download the Docker Desktop installer
|
||||
3. Double-click the downloaded `.dmg` file
|
||||
4. Drag Docker to your Applications folder
|
||||
5. Open Docker Desktop from Applications
|
||||
6. Follow the onboarding tutorial if desired
|
||||
|
||||
## Linux
|
||||
|
||||
### Ubuntu
|
||||
|
||||
```bash
|
||||
# Update package index
|
||||
sudo apt-get update
|
||||
|
||||
# Install prerequisites
|
||||
sudo apt-get install \
|
||||
apt-transport-https \
|
||||
ca-certificates \
|
||||
curl \
|
||||
gnupg \
|
||||
lsb-release
|
||||
|
||||
# Add Docker's official GPG key
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
|
||||
|
||||
# Set up stable repository
|
||||
echo \
|
||||
"deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \
|
||||
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
|
||||
# Install Docker Engine
|
||||
sudo apt-get update
|
||||
sudo apt-get install docker-ce docker-ce-cli containerd.io
|
||||
```
|
||||
|
||||
## Windows
|
||||
|
||||
1. Enable WSL2 if not already enabled
|
||||
2. Visit [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop)
|
||||
3. Download the installer
|
||||
4. Run the installer and follow the prompts
|
||||
5. Start Docker Desktop from the Start menu
|
||||
@@ -19,8 +19,8 @@ See the License for the specific language governing permissions and limitations
|
||||
|
||||
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
||||
|
||||
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
|
||||
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
||||
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
||||
- **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.
|
||||
|
||||
36
README.md
36
README.md
@@ -80,12 +80,30 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||
|
||||
### Premium Supporters 🥇
|
||||
|
||||
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
|
||||
<a href="https://supafort.com/?ref=dokploy" target="_blank"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="190"/></a>
|
||||
<div style="display: flex; align-items: center; gap: 20px;">
|
||||
<a href="https://supafort.com/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 20px;">
|
||||
<img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" height="50"/>
|
||||
</a>
|
||||
|
||||
<a href="https://agentdock.ai/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 50px;">
|
||||
<img src=".github/sponsors/agentdock.png" alt="agentdock.ai" height="70"/>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
### Elite Contributors 🥈
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 20px;">
|
||||
|
||||
<a href="https://americancloud.com/?ref=dokploy" target="_blank" style="display: inline-block; padding: 10px; border-radius: 10px;">
|
||||
<img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" height="70"/>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
<!-- Elite Contributors 🥈 -->
|
||||
|
||||
|
||||
|
||||
<!-- Add Elite Contributors here -->
|
||||
|
||||
### Supporting Members 🥉
|
||||
@@ -97,6 +115,7 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||
<a href="https://itsdb-center.com?ref=dokploy "><img src=".github/sponsors/its.png" width="65px" alt="Itsdb-center"/></a>
|
||||
<a href="https://openalternative.co/?ref=dokploy "><img src=".github/sponsors/openalternative.png" width="65px" alt="Openalternative"/></a>
|
||||
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -129,19 +148,6 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
||||
<img src="https://dokploy.com/banner.png" alt="Watch the video" width="400" style="border-radius:20px;"/>
|
||||
</a>
|
||||
|
||||
<!-- ## Supported OS
|
||||
|
||||
- Ubuntu 24.04 LTS
|
||||
- Ubuntu 23.10
|
||||
- Ubuntu 22.04 LTS
|
||||
- Ubuntu 20.04 LTS
|
||||
- Ubuntu 18.04 LTS
|
||||
- Debian 12
|
||||
- Debian 11
|
||||
- Fedora 40
|
||||
- Centos 9
|
||||
- Centos 8 -->
|
||||
|
||||
## Contributing
|
||||
|
||||
Check out the [Contributing Guide](CONTRIBUTING.md) for more information.
|
||||
|
||||
@@ -1 +1 @@
|
||||
20.9.0
|
||||
20.16.0
|
||||
@@ -1,26 +0,0 @@
|
||||
FROM node:18-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
FROM base AS build
|
||||
COPY . /usr/src/app
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
|
||||
RUN apt-get update && apt-get install -y python3 make g++ git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dependencies
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
|
||||
# Build only the dokploy app
|
||||
RUN pnpm run dokploy:build
|
||||
|
||||
# Deploy only the dokploy app
|
||||
RUN pnpm deploy --filter=dokploy --prod /prod/dokploy
|
||||
|
||||
FROM base AS dokploy
|
||||
COPY --from=build /prod/dokploy /prod/dokploy
|
||||
WORKDIR /prod/dokploy
|
||||
EXPOSE 3000
|
||||
CMD [ "pnpm", "start" ]
|
||||
@@ -1006,7 +1006,7 @@ services:
|
||||
|
||||
volumes:
|
||||
db-config-testhash:
|
||||
`) as ComposeSpecification;
|
||||
`);
|
||||
|
||||
test("Expect to change the suffix in all the possible places (4 Try)", () => {
|
||||
const composeData = load(composeFileComplex) as ComposeSpecification;
|
||||
@@ -1115,3 +1115,60 @@ test("Expect to change the suffix in all the possible places (5 Try)", () => {
|
||||
|
||||
expect(updatedComposeData).toEqual(expectedDockerComposeExample1);
|
||||
});
|
||||
|
||||
const composeFileBackrest = `
|
||||
services:
|
||||
backrest:
|
||||
image: garethgeorge/backrest:v1.7.3
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 9898
|
||||
environment:
|
||||
- BACKREST_PORT=9898
|
||||
- BACKREST_DATA=/data
|
||||
- BACKREST_CONFIG=/config/config.json
|
||||
- XDG_CACHE_HOME=/cache
|
||||
- TZ=\${TZ}
|
||||
volumes:
|
||||
- backrest/data:/data
|
||||
- backrest/config:/config
|
||||
- backrest/cache:/cache
|
||||
- /:/userdata:ro
|
||||
|
||||
volumes:
|
||||
backrest:
|
||||
backrest-cache:
|
||||
`;
|
||||
|
||||
const expectedDockerComposeBackrest = load(`
|
||||
services:
|
||||
backrest:
|
||||
image: garethgeorge/backrest:v1.7.3
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 9898
|
||||
environment:
|
||||
- BACKREST_PORT=9898
|
||||
- BACKREST_DATA=/data
|
||||
- BACKREST_CONFIG=/config/config.json
|
||||
- XDG_CACHE_HOME=/cache
|
||||
- TZ=\${TZ}
|
||||
volumes:
|
||||
- backrest-testhash/data:/data
|
||||
- backrest-testhash/config:/config
|
||||
- backrest-testhash/cache:/cache
|
||||
- /:/userdata:ro
|
||||
|
||||
volumes:
|
||||
backrest-testhash:
|
||||
backrest-cache-testhash:
|
||||
`) as ComposeSpecification;
|
||||
|
||||
test("Should handle volume paths with subdirectories correctly", () => {
|
||||
const composeData = load(composeFileBackrest) as ComposeSpecification;
|
||||
const suffix = "testhash";
|
||||
|
||||
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
|
||||
|
||||
expect(updatedComposeData).toEqual(expectedDockerComposeBackrest);
|
||||
});
|
||||
|
||||
@@ -27,9 +27,16 @@ if (typeof window === "undefined") {
|
||||
const baseApp: ApplicationNested = {
|
||||
applicationId: "",
|
||||
herokuVersion: "",
|
||||
giteaBranch: "",
|
||||
giteaBuildPath: "",
|
||||
giteaId: "",
|
||||
giteaOwner: "",
|
||||
giteaRepository: "",
|
||||
cleanCache: false,
|
||||
watchPaths: [],
|
||||
enableSubmodules: false,
|
||||
applicationStatus: "done",
|
||||
triggerType: "push",
|
||||
appName: "",
|
||||
autoDeploy: true,
|
||||
serverId: "",
|
||||
@@ -98,6 +105,7 @@ const baseApp: ApplicationNested = {
|
||||
ports: [],
|
||||
projectId: "",
|
||||
publishDirectory: null,
|
||||
isStaticSpa: null,
|
||||
redirects: [],
|
||||
refreshToken: "",
|
||||
registry: null,
|
||||
@@ -142,67 +150,68 @@ describe("unzipDrop using real zip files", () => {
|
||||
} finally {
|
||||
}
|
||||
});
|
||||
|
||||
it("should correctly extract a zip with a single root folder and a subfolder", async () => {
|
||||
baseApp.appName = "folderwithfile";
|
||||
// const appName = "folderwithfile";
|
||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||
const zip = new AdmZip("./__test__/drop/zips/folder-with-file.zip");
|
||||
|
||||
const zipBuffer = zip.toBuffer();
|
||||
const file = new File([zipBuffer], "single.zip");
|
||||
await unzipDrop(file, baseApp);
|
||||
|
||||
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||
expect(files.some((f) => f.name === "folder1.txt")).toBe(true);
|
||||
});
|
||||
|
||||
it("should correctly extract a zip with multiple root folders", async () => {
|
||||
baseApp.appName = "two-folders";
|
||||
// const appName = "two-folders";
|
||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||
const zip = new AdmZip("./__test__/drop/zips/two-folders.zip");
|
||||
|
||||
const zipBuffer = zip.toBuffer();
|
||||
const file = new File([zipBuffer], "single.zip");
|
||||
await unzipDrop(file, baseApp);
|
||||
|
||||
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||
|
||||
expect(files.some((f) => f.name === "folder1")).toBe(true);
|
||||
expect(files.some((f) => f.name === "folder2")).toBe(true);
|
||||
});
|
||||
|
||||
it("should correctly extract a zip with a single root with a file", async () => {
|
||||
baseApp.appName = "nested";
|
||||
// const appName = "nested";
|
||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||
const zip = new AdmZip("./__test__/drop/zips/nested.zip");
|
||||
|
||||
const zipBuffer = zip.toBuffer();
|
||||
const file = new File([zipBuffer], "single.zip");
|
||||
await unzipDrop(file, baseApp);
|
||||
|
||||
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||
|
||||
expect(files.some((f) => f.name === "folder1")).toBe(true);
|
||||
expect(files.some((f) => f.name === "folder2")).toBe(true);
|
||||
expect(files.some((f) => f.name === "folder3")).toBe(true);
|
||||
});
|
||||
|
||||
it("should correctly extract a zip with a single root with a folder", async () => {
|
||||
baseApp.appName = "folder-with-sibling-file";
|
||||
// const appName = "folder-with-sibling-file";
|
||||
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||
const zip = new AdmZip("./__test__/drop/zips/folder-with-sibling-file.zip");
|
||||
|
||||
const zipBuffer = zip.toBuffer();
|
||||
const file = new File([zipBuffer], "single.zip");
|
||||
await unzipDrop(file, baseApp);
|
||||
|
||||
const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||
|
||||
expect(files.some((f) => f.name === "folder1")).toBe(true);
|
||||
expect(files.some((f) => f.name === "test.txt")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// it("should correctly extract a zip with a single root folder and a subfolder", async () => {
|
||||
// baseApp.appName = "folderwithfile";
|
||||
// // const appName = "folderwithfile";
|
||||
// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||
// const zip = new AdmZip("./__test__/drop/zips/folder-with-file.zip");
|
||||
|
||||
// const zipBuffer = zip.toBuffer();
|
||||
// const file = new File([zipBuffer], "single.zip");
|
||||
// await unzipDrop(file, baseApp);
|
||||
|
||||
// const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||
// expect(files.some((f) => f.name === "folder1.txt")).toBe(true);
|
||||
// });
|
||||
|
||||
// it("should correctly extract a zip with multiple root folders", async () => {
|
||||
// baseApp.appName = "two-folders";
|
||||
// // const appName = "two-folders";
|
||||
// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||
// const zip = new AdmZip("./__test__/drop/zips/two-folders.zip");
|
||||
|
||||
// const zipBuffer = zip.toBuffer();
|
||||
// const file = new File([zipBuffer], "single.zip");
|
||||
// await unzipDrop(file, baseApp);
|
||||
|
||||
// const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||
|
||||
// expect(files.some((f) => f.name === "folder1")).toBe(true);
|
||||
// expect(files.some((f) => f.name === "folder2")).toBe(true);
|
||||
// });
|
||||
|
||||
// it("should correctly extract a zip with a single root with a file", async () => {
|
||||
// baseApp.appName = "nested";
|
||||
// // const appName = "nested";
|
||||
// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||
// const zip = new AdmZip("./__test__/drop/zips/nested.zip");
|
||||
|
||||
// const zipBuffer = zip.toBuffer();
|
||||
// const file = new File([zipBuffer], "single.zip");
|
||||
// await unzipDrop(file, baseApp);
|
||||
|
||||
// const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||
|
||||
// expect(files.some((f) => f.name === "folder1")).toBe(true);
|
||||
// expect(files.some((f) => f.name === "folder2")).toBe(true);
|
||||
// expect(files.some((f) => f.name === "folder3")).toBe(true);
|
||||
// });
|
||||
|
||||
// it("should correctly extract a zip with a single root with a folder", async () => {
|
||||
// baseApp.appName = "folder-with-sibling-file";
|
||||
// // const appName = "folder-with-sibling-file";
|
||||
// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
|
||||
// const zip = new AdmZip("./__test__/drop/zips/folder-with-sibling-file.zip");
|
||||
|
||||
// const zipBuffer = zip.toBuffer();
|
||||
// const file = new File([zipBuffer], "single.zip");
|
||||
// await unzipDrop(file, baseApp);
|
||||
|
||||
// const files = await fs.readdir(outputPath, { withFileTypes: true });
|
||||
|
||||
// expect(files.some((f) => f.name === "folder1")).toBe(true);
|
||||
// expect(files.some((f) => f.name === "test.txt")).toBe(true);
|
||||
// });
|
||||
// });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Schema } from "@dokploy/server/templates";
|
||||
import type { CompleteTemplate } from "@dokploy/server/templates/processors";
|
||||
import { processTemplate } from "@dokploy/server/templates/processors";
|
||||
import type { Schema } from "@dokploy/server/templates";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("processTemplate", () => {
|
||||
// Mock schema for testing
|
||||
@@ -51,6 +51,35 @@ describe("processTemplate", () => {
|
||||
expect(result.domains).toHaveLength(0);
|
||||
expect(result.mounts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should allow creation of real jwt secret", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {
|
||||
jwt_secret: "cQsdycq1hDLopQonF6jUTqgQc5WEZTwWLL02J6XJ",
|
||||
anon_payload: JSON.stringify({
|
||||
role: "tester",
|
||||
iss: "dockploy",
|
||||
iat: "${timestamps:2025-01-01T00:00:00Z}",
|
||||
exp: "${timestamps:2030-01-01T00:00:00Z}",
|
||||
}),
|
||||
anon_key: "${jwt:jwt_secret:anon_payload}",
|
||||
},
|
||||
config: {
|
||||
domains: [],
|
||||
env: {
|
||||
ANON_KEY: "${anon_key}",
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = processTemplate(template, mockSchema);
|
||||
expect(result.envs).toHaveLength(1);
|
||||
expect(result.envs).toContain(
|
||||
"ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIxNzM1Njg5NjAwIiwiZXhwIjoiMTg5MzQ1NjAwMCIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJkb2NrcGxveSJ9.BG5JoxL2_NaTFbPgyZdm3kRWenf_O3su_HIRKGCJ_kY",
|
||||
);
|
||||
expect(result.mounts).toHaveLength(0);
|
||||
expect(result.domains).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("domains processing", () => {
|
||||
@@ -233,6 +262,49 @@ describe("processTemplate", () => {
|
||||
expect(base64Value.length).toBeGreaterThanOrEqual(42);
|
||||
expect(base64Value.length).toBeLessThanOrEqual(44);
|
||||
});
|
||||
|
||||
it("should handle boolean values in env vars when provided as an array", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {},
|
||||
config: {
|
||||
domains: [],
|
||||
env: [
|
||||
"ENABLE_USER_SIGN_UP=false",
|
||||
"DEBUG_MODE=true",
|
||||
"SOME_NUMBER=42",
|
||||
],
|
||||
mounts: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = processTemplate(template, mockSchema);
|
||||
expect(result.envs).toHaveLength(3);
|
||||
expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false");
|
||||
expect(result.envs).toContain("DEBUG_MODE=true");
|
||||
expect(result.envs).toContain("SOME_NUMBER=42");
|
||||
});
|
||||
|
||||
it("should handle boolean values in env vars when provided as an object", () => {
|
||||
const template: CompleteTemplate = {
|
||||
metadata: {} as any,
|
||||
variables: {},
|
||||
config: {
|
||||
domains: [],
|
||||
env: {
|
||||
ENABLE_USER_SIGN_UP: false,
|
||||
DEBUG_MODE: true,
|
||||
SOME_NUMBER: 42,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = processTemplate(template, mockSchema);
|
||||
expect(result.envs).toHaveLength(3);
|
||||
expect(result.envs).toContain("ENABLE_USER_SIGN_UP=false");
|
||||
expect(result.envs).toContain("DEBUG_MODE=true");
|
||||
expect(result.envs).toContain("SOME_NUMBER=42");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mounts processing", () => {
|
||||
|
||||
232
apps/dokploy/__test__/templates/helpers.template.test.ts
Normal file
232
apps/dokploy/__test__/templates/helpers.template.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import type { Schema } from "@dokploy/server/templates";
|
||||
import { processValue } from "@dokploy/server/templates/processors";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("helpers functions", () => {
|
||||
// Mock schema for testing
|
||||
const mockSchema: Schema = {
|
||||
projectName: "test",
|
||||
serverIp: "127.0.0.1",
|
||||
};
|
||||
// some helpers to test jwt
|
||||
type JWTParts = [string, string, string];
|
||||
const jwtMatchExp = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/;
|
||||
const jwtBase64Decode = (str: string) => {
|
||||
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const decoded = Buffer.from(base64 + padding, "base64").toString("utf-8");
|
||||
return JSON.parse(decoded);
|
||||
};
|
||||
const jwtCheckHeader = (jwtHeader: string) => {
|
||||
const decodedHeader = jwtBase64Decode(jwtHeader);
|
||||
expect(decodedHeader).toHaveProperty("alg");
|
||||
expect(decodedHeader).toHaveProperty("typ");
|
||||
expect(decodedHeader.alg).toEqual("HS256");
|
||||
expect(decodedHeader.typ).toEqual("JWT");
|
||||
};
|
||||
|
||||
describe("${domain}", () => {
|
||||
it("should generate a random domain", () => {
|
||||
const domain = processValue("${domain}", {}, mockSchema);
|
||||
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
|
||||
expect(
|
||||
domain.endsWith(
|
||||
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("${base64}", () => {
|
||||
it("should generate a base64 string", () => {
|
||||
const base64 = processValue("${base64}", {}, mockSchema);
|
||||
expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/);
|
||||
});
|
||||
it.each([
|
||||
[4, 8],
|
||||
[8, 12],
|
||||
[16, 24],
|
||||
[32, 44],
|
||||
[64, 88],
|
||||
[128, 172],
|
||||
])(
|
||||
"should generate a base64 string from parameter %d bytes length",
|
||||
(length, finalLength) => {
|
||||
const base64 = processValue(`\${base64:${length}}`, {}, mockSchema);
|
||||
expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/);
|
||||
expect(base64.length).toBe(finalLength);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("${password}", () => {
|
||||
it("should generate a password string", () => {
|
||||
const password = processValue("${password}", {}, mockSchema);
|
||||
expect(password).toMatch(/^[A-Za-z0-9]+$/);
|
||||
});
|
||||
it.each([6, 8, 12, 16, 32])(
|
||||
"should generate a password string respecting parameter %d length",
|
||||
(length) => {
|
||||
const password = processValue(`\${password:${length}}`, {}, mockSchema);
|
||||
expect(password).toMatch(/^[A-Za-z0-9]+$/);
|
||||
expect(password.length).toBe(length);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("${hash}", () => {
|
||||
it("should generate a hash string", () => {
|
||||
const hash = processValue("${hash}", {}, mockSchema);
|
||||
expect(hash).toMatch(/^[A-Za-z0-9]+$/);
|
||||
});
|
||||
it.each([6, 8, 12, 16, 32])(
|
||||
"should generate a hash string respecting parameter %d length",
|
||||
(length) => {
|
||||
const hash = processValue(`\${hash:${length}}`, {}, mockSchema);
|
||||
expect(hash).toMatch(/^[A-Za-z0-9]+$/);
|
||||
expect(hash.length).toBe(length);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("${uuid}", () => {
|
||||
it("should generate a UUID string", () => {
|
||||
const uuid = processValue("${uuid}", {}, mockSchema);
|
||||
expect(uuid).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("${timestamp}", () => {
|
||||
it("should generate a timestamp string in milliseconds", () => {
|
||||
const timestamp = processValue("${timestamp}", {}, mockSchema);
|
||||
const nowLength = Math.floor(Date.now()).toString().length;
|
||||
expect(timestamp).toMatch(/^\d+$/);
|
||||
expect(timestamp.length).toBe(nowLength);
|
||||
});
|
||||
});
|
||||
describe("${timestampms}", () => {
|
||||
it("should generate a timestamp string in milliseconds", () => {
|
||||
const timestamp = processValue("${timestampms}", {}, mockSchema);
|
||||
const nowLength = Date.now().toString().length;
|
||||
expect(timestamp).toMatch(/^\d+$/);
|
||||
expect(timestamp.length).toBe(nowLength);
|
||||
});
|
||||
it("should generate a timestamp string in milliseconds from parameter", () => {
|
||||
const timestamp = processValue(
|
||||
"${timestampms:2025-01-01}",
|
||||
{},
|
||||
mockSchema,
|
||||
);
|
||||
expect(timestamp).toEqual("1735689600000");
|
||||
});
|
||||
});
|
||||
describe("${timestamps}", () => {
|
||||
it("should generate a timestamp string in seconds", () => {
|
||||
const timestamps = processValue("${timestamps}", {}, mockSchema);
|
||||
const nowLength = Math.floor(Date.now() / 1000).toString().length;
|
||||
expect(timestamps).toMatch(/^\d+$/);
|
||||
expect(timestamps.length).toBe(nowLength);
|
||||
});
|
||||
it("should generate a timestamp string in seconds from parameter", () => {
|
||||
const timestamps = processValue(
|
||||
"${timestamps:2025-01-01}",
|
||||
{},
|
||||
mockSchema,
|
||||
);
|
||||
expect(timestamps).toEqual("1735689600");
|
||||
});
|
||||
});
|
||||
|
||||
describe("${randomPort}", () => {
|
||||
it("should generate a random port string", () => {
|
||||
const randomPort = processValue("${randomPort}", {}, mockSchema);
|
||||
expect(randomPort).toMatch(/^\d+$/);
|
||||
expect(Number(randomPort)).toBeLessThan(65536);
|
||||
});
|
||||
});
|
||||
|
||||
describe("${username}", () => {
|
||||
it("should generate a username string", () => {
|
||||
const username = processValue("${username}", {}, mockSchema);
|
||||
expect(username).toMatch(/^[a-zA-Z0-9._-]{3,}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("${email}", () => {
|
||||
it("should generate an email string", () => {
|
||||
const email = processValue("${email}", {}, mockSchema);
|
||||
expect(email).toMatch(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("${jwt}", () => {
|
||||
it("should generate a JWT string", () => {
|
||||
const jwt = processValue("${jwt}", {}, mockSchema);
|
||||
expect(jwt).toMatch(jwtMatchExp);
|
||||
const parts = jwt.split(".") as JWTParts;
|
||||
const decodedPayload = jwtBase64Decode(parts[1]);
|
||||
jwtCheckHeader(parts[0]);
|
||||
expect(decodedPayload).toHaveProperty("iat");
|
||||
expect(decodedPayload).toHaveProperty("iss");
|
||||
expect(decodedPayload).toHaveProperty("exp");
|
||||
expect(decodedPayload.iss).toEqual("dokploy");
|
||||
});
|
||||
it.each([6, 8, 12, 16, 32])(
|
||||
"should generate a random hex string from parameter %d byte length",
|
||||
(length) => {
|
||||
const jwt = processValue(`\${jwt:${length}}`, {}, mockSchema);
|
||||
expect(jwt).toMatch(/^[A-Za-z0-9-_.]+$/);
|
||||
expect(jwt.length).toBeGreaterThanOrEqual(length); // bytes translated to hex can take up to 2x the length
|
||||
expect(jwt.length).toBeLessThanOrEqual(length * 2);
|
||||
},
|
||||
);
|
||||
});
|
||||
describe("${jwt:secret}", () => {
|
||||
it("should generate a JWT string respecting parameter secret from variable", () => {
|
||||
const jwt = processValue(
|
||||
"${jwt:secret}",
|
||||
{ secret: "mysecret" },
|
||||
mockSchema,
|
||||
);
|
||||
expect(jwt).toMatch(jwtMatchExp);
|
||||
const parts = jwt.split(".") as JWTParts;
|
||||
const decodedPayload = jwtBase64Decode(parts[1]);
|
||||
jwtCheckHeader(parts[0]);
|
||||
expect(decodedPayload).toHaveProperty("iat");
|
||||
expect(decodedPayload).toHaveProperty("iss");
|
||||
expect(decodedPayload).toHaveProperty("exp");
|
||||
expect(decodedPayload.iss).toEqual("dokploy");
|
||||
});
|
||||
});
|
||||
describe("${jwt:secret:payload}", () => {
|
||||
it("should generate a JWT string respecting parameters secret and payload from variables", () => {
|
||||
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
|
||||
const expiry = iat + 3600;
|
||||
const jwt = processValue(
|
||||
"${jwt:secret:payload}",
|
||||
{
|
||||
secret: "mysecret",
|
||||
payload: `{"iss": "test-issuer", "iat": ${iat}, "exp": ${expiry}, "customprop": "customvalue"}`,
|
||||
},
|
||||
mockSchema,
|
||||
);
|
||||
expect(jwt).toMatch(jwtMatchExp);
|
||||
const parts = jwt.split(".") as JWTParts;
|
||||
jwtCheckHeader(parts[0]);
|
||||
const decodedPayload = jwtBase64Decode(parts[1]);
|
||||
expect(decodedPayload).toHaveProperty("iat");
|
||||
expect(decodedPayload.iat).toEqual(iat);
|
||||
expect(decodedPayload).toHaveProperty("iss");
|
||||
expect(decodedPayload.iss).toEqual("test-issuer");
|
||||
expect(decodedPayload).toHaveProperty("exp");
|
||||
expect(decodedPayload.exp).toEqual(expiry);
|
||||
expect(decodedPayload).toHaveProperty("customprop");
|
||||
expect(decodedPayload.customprop).toEqual("customvalue");
|
||||
expect(jwt).toEqual(
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
const baseAdmin: User = {
|
||||
https: false,
|
||||
enablePaidFeatures: false,
|
||||
allowImpersonation: false,
|
||||
role: "user",
|
||||
metricsConfig: {
|
||||
containers: {
|
||||
refreshRate: 20,
|
||||
@@ -73,7 +76,6 @@ beforeEach(() => {
|
||||
|
||||
test("Should read the configuration file", () => {
|
||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||
|
||||
expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe(
|
||||
"dokploy-service-app",
|
||||
);
|
||||
@@ -83,6 +85,7 @@ test("Should apply redirect-to-https", () => {
|
||||
updateServerTraefik(
|
||||
{
|
||||
...baseAdmin,
|
||||
https: true,
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
"example.com",
|
||||
|
||||
@@ -7,10 +7,16 @@ import { expect, test } from "vitest";
|
||||
const baseApp: ApplicationNested = {
|
||||
applicationId: "",
|
||||
herokuVersion: "",
|
||||
giteaRepository: "",
|
||||
giteaOwner: "",
|
||||
giteaBranch: "",
|
||||
giteaBuildPath: "",
|
||||
giteaId: "",
|
||||
cleanCache: false,
|
||||
applicationStatus: "done",
|
||||
appName: "",
|
||||
autoDeploy: true,
|
||||
enableSubmodules: false,
|
||||
serverId: "",
|
||||
branch: null,
|
||||
dockerBuildStage: "",
|
||||
@@ -19,6 +25,7 @@ const baseApp: ApplicationNested = {
|
||||
buildArgs: null,
|
||||
isPreviewDeploymentsActive: false,
|
||||
previewBuildArgs: null,
|
||||
triggerType: "push",
|
||||
previewCertificateType: "none",
|
||||
previewEnv: null,
|
||||
previewHttps: false,
|
||||
@@ -78,6 +85,7 @@ const baseApp: ApplicationNested = {
|
||||
ports: [],
|
||||
projectId: "",
|
||||
publishDirectory: null,
|
||||
isStaticSpa: null,
|
||||
redirects: [],
|
||||
refreshToken: "",
|
||||
registry: null,
|
||||
|
||||
61
apps/dokploy/__test__/utils/backups.test.ts
Normal file
61
apps/dokploy/__test__/utils/backups.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
describe("normalizeS3Path", () => {
|
||||
test("should handle empty and whitespace-only prefix", () => {
|
||||
expect(normalizeS3Path("")).toBe("");
|
||||
expect(normalizeS3Path("/")).toBe("");
|
||||
expect(normalizeS3Path(" ")).toBe("");
|
||||
expect(normalizeS3Path("\t")).toBe("");
|
||||
expect(normalizeS3Path("\n")).toBe("");
|
||||
expect(normalizeS3Path(" \n \t ")).toBe("");
|
||||
});
|
||||
|
||||
test("should trim whitespace from prefix", () => {
|
||||
expect(normalizeS3Path(" prefix")).toBe("prefix/");
|
||||
expect(normalizeS3Path("prefix ")).toBe("prefix/");
|
||||
expect(normalizeS3Path(" prefix ")).toBe("prefix/");
|
||||
expect(normalizeS3Path("\tprefix\t")).toBe("prefix/");
|
||||
expect(normalizeS3Path(" prefix/nested ")).toBe("prefix/nested/");
|
||||
});
|
||||
|
||||
test("should remove leading slashes", () => {
|
||||
expect(normalizeS3Path("/prefix")).toBe("prefix/");
|
||||
expect(normalizeS3Path("///prefix")).toBe("prefix/");
|
||||
});
|
||||
|
||||
test("should remove trailing slashes", () => {
|
||||
expect(normalizeS3Path("prefix/")).toBe("prefix/");
|
||||
expect(normalizeS3Path("prefix///")).toBe("prefix/");
|
||||
});
|
||||
|
||||
test("should remove both leading and trailing slashes", () => {
|
||||
expect(normalizeS3Path("/prefix/")).toBe("prefix/");
|
||||
expect(normalizeS3Path("///prefix///")).toBe("prefix/");
|
||||
});
|
||||
|
||||
test("should handle nested paths", () => {
|
||||
expect(normalizeS3Path("prefix/nested")).toBe("prefix/nested/");
|
||||
expect(normalizeS3Path("/prefix/nested/")).toBe("prefix/nested/");
|
||||
expect(normalizeS3Path("///prefix/nested///")).toBe("prefix/nested/");
|
||||
});
|
||||
|
||||
test("should preserve middle slashes", () => {
|
||||
expect(normalizeS3Path("prefix/nested/deep")).toBe("prefix/nested/deep/");
|
||||
expect(normalizeS3Path("/prefix/nested/deep/")).toBe("prefix/nested/deep/");
|
||||
});
|
||||
|
||||
test("should handle special characters", () => {
|
||||
expect(normalizeS3Path("prefix-with-dashes")).toBe("prefix-with-dashes/");
|
||||
expect(normalizeS3Path("prefix_with_underscores")).toBe(
|
||||
"prefix_with_underscores/",
|
||||
);
|
||||
expect(normalizeS3Path("prefix.with.dots")).toBe("prefix.with.dots/");
|
||||
});
|
||||
|
||||
test("should handle the cases from the bug report", () => {
|
||||
expect(normalizeS3Path("instance-backups/")).toBe("instance-backups/");
|
||||
expect(normalizeS3Path("/instance-backups/")).toBe("instance-backups/");
|
||||
expect(normalizeS3Path("instance-backups")).toBe("instance-backups/");
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const AddRedirectchema = z.object({
|
||||
replicas: z.number(),
|
||||
replicas: z.number().min(1, "Replicas must be at least 1"),
|
||||
registryId: z.string(),
|
||||
});
|
||||
|
||||
@@ -130,9 +130,11 @@ export const ShowClusterSettings = ({ applicationId }: Props) => {
|
||||
placeholder="1"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(Number(e.target.value));
|
||||
const value = e.target.value;
|
||||
field.onChange(value === "" ? 0 : Number(value));
|
||||
}}
|
||||
type="number"
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -32,7 +33,6 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
|
||||
const ImportSchema = z.object({
|
||||
base64: z.string(),
|
||||
@@ -263,7 +263,7 @@ export const ShowImport = ({ composeId }: Props) => {
|
||||
{templateInfo.template.envs.map((env, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border bg-card p-2 font-mono text-sm"
|
||||
className="rounded-lg truncate border bg-card p-2 font-mono text-sm"
|
||||
>
|
||||
{env}
|
||||
</div>
|
||||
@@ -328,7 +328,7 @@ export const ShowImport = ({ composeId }: Props) => {
|
||||
<DialogDescription>Mount File Content</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-[25vh] pr-4">
|
||||
<ScrollArea className="h-[45vh] pr-4">
|
||||
<CodeEditor
|
||||
language="yaml"
|
||||
value={selectedMount?.content || ""}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -20,7 +22,7 @@ import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
enum BuildType {
|
||||
export enum BuildType {
|
||||
dockerfile = "dockerfile",
|
||||
heroku_buildpacks = "heroku_buildpacks",
|
||||
paketo_buildpacks = "paketo_buildpacks",
|
||||
@@ -29,9 +31,18 @@ enum BuildType {
|
||||
railpack = "railpack",
|
||||
}
|
||||
|
||||
const buildTypeDisplayMap: Record<BuildType, string> = {
|
||||
[BuildType.dockerfile]: "Dockerfile",
|
||||
[BuildType.railpack]: "Railpack",
|
||||
[BuildType.nixpacks]: "Nixpacks",
|
||||
[BuildType.heroku_buildpacks]: "Heroku Buildpacks",
|
||||
[BuildType.paketo_buildpacks]: "Paketo Buildpacks",
|
||||
[BuildType.static]: "Static",
|
||||
};
|
||||
|
||||
const mySchema = z.discriminatedUnion("buildType", [
|
||||
z.object({
|
||||
buildType: z.literal("dockerfile"),
|
||||
buildType: z.literal(BuildType.dockerfile),
|
||||
dockerfile: z
|
||||
.string({
|
||||
required_error: "Dockerfile path is required",
|
||||
@@ -42,39 +53,92 @@ const mySchema = z.discriminatedUnion("buildType", [
|
||||
dockerBuildStage: z.string().nullable().default(""),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("heroku_buildpacks"),
|
||||
buildType: z.literal(BuildType.heroku_buildpacks),
|
||||
herokuVersion: z.string().nullable().default(""),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("paketo_buildpacks"),
|
||||
buildType: z.literal(BuildType.paketo_buildpacks),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("nixpacks"),
|
||||
buildType: z.literal(BuildType.nixpacks),
|
||||
publishDirectory: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("static"),
|
||||
buildType: z.literal(BuildType.railpack),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal("railpack"),
|
||||
buildType: z.literal(BuildType.static),
|
||||
isStaticSpa: z.boolean().default(false),
|
||||
}),
|
||||
]);
|
||||
|
||||
type AddTemplate = z.infer<typeof mySchema>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
interface ApplicationData {
|
||||
buildType: BuildType;
|
||||
dockerfile?: string | null;
|
||||
dockerContextPath?: string | null;
|
||||
dockerBuildStage?: string | null;
|
||||
herokuVersion?: string | null;
|
||||
publishDirectory?: string | null;
|
||||
isStaticSpa?: boolean | null;
|
||||
}
|
||||
|
||||
function isValidBuildType(value: string): value is BuildType {
|
||||
return Object.values(BuildType).includes(value as BuildType);
|
||||
}
|
||||
|
||||
const resetData = (data: ApplicationData): AddTemplate => {
|
||||
switch (data.buildType) {
|
||||
case BuildType.dockerfile:
|
||||
return {
|
||||
buildType: BuildType.dockerfile,
|
||||
dockerfile: data.dockerfile || "",
|
||||
dockerContextPath: data.dockerContextPath || "",
|
||||
dockerBuildStage: data.dockerBuildStage || "",
|
||||
};
|
||||
case BuildType.heroku_buildpacks:
|
||||
return {
|
||||
buildType: BuildType.heroku_buildpacks,
|
||||
herokuVersion: data.herokuVersion || "",
|
||||
};
|
||||
case BuildType.nixpacks:
|
||||
return {
|
||||
buildType: BuildType.nixpacks,
|
||||
publishDirectory: data.publishDirectory || undefined,
|
||||
};
|
||||
case BuildType.paketo_buildpacks:
|
||||
return {
|
||||
buildType: BuildType.paketo_buildpacks,
|
||||
};
|
||||
case BuildType.static:
|
||||
return {
|
||||
buildType: BuildType.static,
|
||||
isStaticSpa: data.isStaticSpa ?? false,
|
||||
};
|
||||
case BuildType.railpack:
|
||||
return {
|
||||
buildType: BuildType.railpack,
|
||||
};
|
||||
default: {
|
||||
const buildType = data.buildType as BuildType;
|
||||
return {
|
||||
buildType,
|
||||
} as AddTemplate;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
const { mutateAsync, isLoading } =
|
||||
api.application.saveBuildType.useMutation();
|
||||
const { data, refetch } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
},
|
||||
{ applicationId },
|
||||
{ enabled: !!applicationId },
|
||||
);
|
||||
|
||||
const form = useForm<AddTemplate>({
|
||||
@@ -85,46 +149,38 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
});
|
||||
|
||||
const buildType = form.watch("buildType");
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
if (data.buildType === "dockerfile") {
|
||||
form.reset({
|
||||
buildType: data.buildType,
|
||||
...(data.buildType && {
|
||||
dockerfile: data.dockerfile || "",
|
||||
dockerContextPath: data.dockerContextPath || "",
|
||||
dockerBuildStage: data.dockerBuildStage || "",
|
||||
}),
|
||||
});
|
||||
} else if (data.buildType === "heroku_buildpacks") {
|
||||
form.reset({
|
||||
buildType: data.buildType,
|
||||
...(data.buildType && {
|
||||
herokuVersion: data.herokuVersion || "",
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
buildType: data.buildType,
|
||||
publishDirectory: data.publishDirectory || undefined,
|
||||
});
|
||||
}
|
||||
const typedData: ApplicationData = {
|
||||
...data,
|
||||
buildType: isValidBuildType(data.buildType)
|
||||
? (data.buildType as BuildType)
|
||||
: BuildType.nixpacks, // fallback
|
||||
};
|
||||
|
||||
form.reset(resetData(typedData));
|
||||
}
|
||||
}, [form.formState.isSubmitSuccessful, form.reset, data, form]);
|
||||
}, [data, form]);
|
||||
|
||||
const onSubmit = async (data: AddTemplate) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
buildType: data.buildType,
|
||||
publishDirectory:
|
||||
data.buildType === "nixpacks" ? data.publishDirectory : null,
|
||||
dockerfile: data.buildType === "dockerfile" ? data.dockerfile : null,
|
||||
data.buildType === BuildType.nixpacks ? data.publishDirectory : null,
|
||||
dockerfile:
|
||||
data.buildType === BuildType.dockerfile ? data.dockerfile : null,
|
||||
dockerContextPath:
|
||||
data.buildType === "dockerfile" ? data.dockerContextPath : null,
|
||||
data.buildType === BuildType.dockerfile ? data.dockerContextPath : null,
|
||||
dockerBuildStage:
|
||||
data.buildType === "dockerfile" ? data.dockerBuildStage : null,
|
||||
data.buildType === BuildType.dockerfile ? data.dockerBuildStage : null,
|
||||
herokuVersion:
|
||||
data.buildType === "heroku_buildpacks" ? data.herokuVersion : null,
|
||||
data.buildType === BuildType.heroku_buildpacks
|
||||
? data.herokuVersion
|
||||
: null,
|
||||
isStaticSpa:
|
||||
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Build type saved");
|
||||
@@ -152,6 +208,22 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<AlertBlock>
|
||||
Builders can consume significant memory and CPU resources
|
||||
(recommended: 4+ GB RAM and 2+ CPU cores). For production
|
||||
environments, please review our{" "}
|
||||
<a
|
||||
href="https://docs.dokploy.com/docs/core/applications/going-production"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Production Guide
|
||||
</a>{" "}
|
||||
for best practices and optimization recommendations. Builders are
|
||||
suitable for development and prototyping purposes when you have
|
||||
sufficient resources available.
|
||||
</AlertBlock>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4 p-2"
|
||||
@@ -160,193 +232,167 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
control={form.control}
|
||||
name="buildType"
|
||||
defaultValue={form.control._defaultValues.buildType}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>Build Type</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
className="flex flex-col space-y-1"
|
||||
>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="dockerfile" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Dockerfile
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="railpack" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Railpack{" "}
|
||||
<Badge className="ml-1 text-xs px-1">New</Badge>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="nixpacks" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Nixpacks
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="heroku_buildpacks" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Heroku Buildpacks
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="paketo_buildpacks" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
Paketo Buildpacks
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center space-x-3 space-y-0">
|
||||
<FormControl>
|
||||
<RadioGroupItem value="static" />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">Static</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<FormLabel>Build Type</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
className="flex flex-col space-y-1"
|
||||
>
|
||||
{Object.entries(buildTypeDisplayMap).map(
|
||||
([value, label]) => (
|
||||
<FormItem
|
||||
key={value}
|
||||
className="flex items-center space-x-3 space-y-0"
|
||||
>
|
||||
<FormControl>
|
||||
<RadioGroupItem value={value} />
|
||||
</FormControl>
|
||||
<FormLabel className="font-normal">
|
||||
{label}
|
||||
{value === BuildType.railpack && (
|
||||
<Badge className="ml-2 px-1 text-xs">New</Badge>
|
||||
)}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
),
|
||||
)}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{buildType === "heroku_buildpacks" && (
|
||||
{buildType === BuildType.heroku_buildpacks && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="herokuVersion"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Heroku Version (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"Heroku Version (Default: 24)"}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Heroku Version (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Heroku Version (Default: 24)"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{buildType === "dockerfile" && (
|
||||
{buildType === BuildType.dockerfile && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dockerfile"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Docker File</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"Path of your docker file"}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dockerContextPath"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Docker Context Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
"Path of your docker context default: ."
|
||||
}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dockerBuildStage"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Docker Build Stage</FormLabel>
|
||||
<FormDescription>
|
||||
Allows you to target a specific stage in a
|
||||
Multi-stage Dockerfile. If empty, Docker defaults to
|
||||
build the last defined stage.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"E.g. production"}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{buildType === "nixpacks" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="publishDirectory"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Publish Directory</FormLabel>
|
||||
<FormDescription>
|
||||
Allows you to serve a single directory via NGINX after
|
||||
the build phase. Useful if the final build assets
|
||||
should be served as a static site.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormLabel>Docker File</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={"Publish Directory"}
|
||||
placeholder="Path of your docker file"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dockerContextPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Docker Context Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Path of your docker context (default: .)"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dockerBuildStage"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Docker Build Stage</FormLabel>
|
||||
<FormDescription>
|
||||
Allows you to target a specific stage in a Multi-stage
|
||||
Dockerfile. If empty, Docker defaults to build the
|
||||
last defined stage.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="E.g. production"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{buildType === BuildType.nixpacks && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="publishDirectory"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Publish Directory</FormLabel>
|
||||
<FormDescription>
|
||||
Allows you to serve a single directory via NGINX after
|
||||
the build phase. Useful if the final build assets should
|
||||
be served as a static site.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Publish Directory"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{buildType === BuildType.static && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isStaticSpa"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-x-2 p-2">
|
||||
<Checkbox
|
||||
id="checkboxIsStaticSpa"
|
||||
value={String(field.value)}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<FormLabel htmlFor="checkboxIsStaticSpa">
|
||||
Single Page Application (SPA)
|
||||
</FormLabel>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex w-full justify-end">
|
||||
|
||||
@@ -15,11 +15,15 @@ import { Paintbrush } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
id: string;
|
||||
type: "application" | "compose";
|
||||
}
|
||||
|
||||
export const CancelQueues = ({ applicationId }: Props) => {
|
||||
const { mutateAsync, isLoading } = api.application.cleanQueues.useMutation();
|
||||
export const CancelQueues = ({ id, type }: Props) => {
|
||||
const { mutateAsync, isLoading } =
|
||||
type === "application"
|
||||
? api.application.cleanQueues.useMutation()
|
||||
: api.compose.cleanQueues.useMutation();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
|
||||
if (isCloud) {
|
||||
@@ -48,7 +52,8 @@ export const CancelQueues = ({ applicationId }: Props) => {
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
applicationId: id || "",
|
||||
composeId: id || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Queues are being cleaned");
|
||||
|
||||
@@ -14,10 +14,14 @@ import { RefreshCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
id: string;
|
||||
type: "application" | "compose";
|
||||
}
|
||||
export const RefreshToken = ({ applicationId }: Props) => {
|
||||
const { mutateAsync } = api.application.refreshToken.useMutation();
|
||||
export const RefreshToken = ({ id, type }: Props) => {
|
||||
const { mutateAsync } =
|
||||
type === "application"
|
||||
? api.application.refreshToken.useMutation()
|
||||
: api.compose.refreshToken.useMutation();
|
||||
const utils = api.useUtils();
|
||||
return (
|
||||
<AlertDialog>
|
||||
@@ -37,12 +41,19 @@ export const RefreshToken = ({ applicationId }: Props) => {
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
applicationId: id || "",
|
||||
composeId: id || "",
|
||||
})
|
||||
.then(() => {
|
||||
utils.application.one.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
if (type === "application") {
|
||||
utils.application.one.invalidate({
|
||||
applicationId: id,
|
||||
});
|
||||
} else {
|
||||
utils.compose.one.invalidate({
|
||||
composeId: id,
|
||||
});
|
||||
}
|
||||
toast.success("Refresh updated");
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
|
||||
import type { RouterOutputs } from "@/utils/api";
|
||||
import { useState } from "react";
|
||||
import { ShowDeployment } from "../deployments/show-deployment";
|
||||
import { ShowDeployments } from "./show-deployments";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type:
|
||||
| "application"
|
||||
| "compose"
|
||||
| "schedule"
|
||||
| "server"
|
||||
| "backup"
|
||||
| "previewDeployment";
|
||||
serverId?: string;
|
||||
refreshToken?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const formatDuration = (seconds: number) => {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
};
|
||||
|
||||
export const ShowDeploymentsModal = ({
|
||||
id,
|
||||
type,
|
||||
serverId,
|
||||
refreshToken,
|
||||
children,
|
||||
}: Props) => {
|
||||
const [activeLog, setActiveLog] = useState<
|
||||
RouterOutputs["deployment"]["all"][number] | null
|
||||
>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<Button className="sm:w-auto w-full" size="sm" variant="outline">
|
||||
View Logs
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl p-0">
|
||||
<ShowDeployments
|
||||
id={id}
|
||||
type={type}
|
||||
serverId={serverId}
|
||||
refreshToken={refreshToken}
|
||||
/>
|
||||
</DialogContent>
|
||||
<ShowDeployment
|
||||
serverId={serverId || ""}
|
||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
||||
onClose={() => setActiveLog(null)}
|
||||
logPath={activeLog?.logPath || ""}
|
||||
errorMessage={activeLog?.errorMessage || ""}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -9,28 +10,52 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { type RouterOutputs, api } from "@/utils/api";
|
||||
import { RocketIcon } from "lucide-react";
|
||||
import { Clock, Loader2, RocketIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { CancelQueues } from "./cancel-queues";
|
||||
import { RefreshToken } from "./refresh-token";
|
||||
import { ShowDeployment } from "./show-deployment";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
id: string;
|
||||
type:
|
||||
| "application"
|
||||
| "compose"
|
||||
| "schedule"
|
||||
| "server"
|
||||
| "backup"
|
||||
| "previewDeployment";
|
||||
refreshToken?: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export const ShowDeployments = ({ applicationId }: Props) => {
|
||||
export const formatDuration = (seconds: number) => {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
};
|
||||
|
||||
export const ShowDeployments = ({
|
||||
id,
|
||||
type,
|
||||
refreshToken,
|
||||
serverId,
|
||||
}: Props) => {
|
||||
const [activeLog, setActiveLog] = useState<
|
||||
RouterOutputs["deployment"]["all"][number] | null
|
||||
>(null);
|
||||
const { data } = api.application.one.useQuery({ applicationId });
|
||||
const { data: deployments } = api.deployment.all.useQuery(
|
||||
{ applicationId },
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
refetchInterval: 1000,
|
||||
},
|
||||
);
|
||||
const { data: deployments, isLoading: isLoadingDeployments } =
|
||||
api.deployment.allByType.useQuery(
|
||||
{
|
||||
id,
|
||||
type,
|
||||
},
|
||||
{
|
||||
enabled: !!id,
|
||||
refetchInterval: 1000,
|
||||
},
|
||||
);
|
||||
|
||||
const [url, setUrl] = React.useState("");
|
||||
useEffect(() => {
|
||||
@@ -38,34 +63,48 @@ export const ShowDeployments = ({ applicationId }: Props) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<Card className="bg-background border-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="text-xl">Deployments</CardTitle>
|
||||
<CardDescription>
|
||||
See all the 10 last deployments for this application
|
||||
See all the 10 last deployments for this {type}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<CancelQueues applicationId={applicationId} />
|
||||
{(type === "application" || type === "compose") && (
|
||||
<CancelQueues id={id} type={type} />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<span>
|
||||
If you want to re-deploy this application use this URL in the config
|
||||
of your git provider or docker
|
||||
</span>
|
||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
||||
<span>Webhook URL: </span>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span className="break-all text-muted-foreground">
|
||||
{`${url}/api/deploy/${data?.refreshToken}`}
|
||||
</span>
|
||||
<RefreshToken applicationId={applicationId} />
|
||||
{refreshToken && (
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<span>
|
||||
If you want to re-deploy this application use this URL in the
|
||||
config of your git provider or docker
|
||||
</span>
|
||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
||||
<span>Webhook URL: </span>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span className="break-all text-muted-foreground">
|
||||
{`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`}
|
||||
</span>
|
||||
{(type === "application" || type === "compose") && (
|
||||
<RefreshToken id={id} type={type} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{data?.deployments?.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
)}
|
||||
|
||||
{isLoadingDeployments ? (
|
||||
<div className="flex w-full flex-row items-center justify-center gap-3 pt-10 min-h-[25vh]">
|
||||
<Loader2 className="size-6 text-muted-foreground animate-spin" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
Loading deployments...
|
||||
</span>
|
||||
</div>
|
||||
) : deployments?.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10 min-h-[25vh]">
|
||||
<RocketIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No deployments found
|
||||
@@ -96,8 +135,23 @@ export const ShowDeployments = ({ applicationId }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="text-sm capitalize text-muted-foreground">
|
||||
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
||||
<DateTooltip date={deployment.createdAt} />
|
||||
{deployment.startedAt && deployment.finishedAt && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] gap-1 flex items-center"
|
||||
>
|
||||
<Clock className="size-3" />
|
||||
{formatDuration(
|
||||
Math.floor(
|
||||
(new Date(deployment.finishedAt).getTime() -
|
||||
new Date(deployment.startedAt).getTime()) /
|
||||
1000,
|
||||
),
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@@ -113,7 +167,7 @@ export const ShowDeployments = ({ applicationId }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
<ShowDeployment
|
||||
serverId={data?.serverId || ""}
|
||||
serverId={serverId}
|
||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
||||
onClose={() => setActiveLog(null)}
|
||||
logPath={activeLog?.logPath || ""}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Copy, HelpCircle, Server } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
domain: {
|
||||
host: string;
|
||||
https: boolean;
|
||||
path?: string;
|
||||
};
|
||||
serverIp?: string;
|
||||
}
|
||||
|
||||
export const DnsHelperModal = ({ domain, serverIp }: Props) => {
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success("Copied to clipboard!");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<Button variant="ghost" size="icon" className="group">
|
||||
<HelpCircle className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Server className="size-5" />
|
||||
DNS Configuration Guide
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Follow these steps to configure your DNS records for {domain.host}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<AlertBlock type="info">
|
||||
To make your domain accessible, you need to configure your DNS
|
||||
records with your domain provider (e.g., Cloudflare, GoDaddy,
|
||||
NameCheap).
|
||||
</AlertBlock>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="font-medium mb-2">1. Add A Record</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create an A record that points your domain to the server's IP
|
||||
address:
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2 bg-muted p-3 rounded-md">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Type: A</p>
|
||||
<p className="text-sm">
|
||||
Name: @ or {domain.host.split(".")[0]}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Value: {serverIp || "Your server IP"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyToClipboard(serverIp || "")}
|
||||
disabled={!serverIp}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-4">
|
||||
<h3 className="font-medium mb-2">2. Verify Configuration</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
After configuring your DNS records:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||
<li>Wait for DNS propagation (usually 15-30 minutes)</li>
|
||||
<li>
|
||||
Test your domain by visiting:{" "}
|
||||
{domain.https ? "https://" : "http://"}
|
||||
{domain.host}
|
||||
{domain.path || "/"}
|
||||
</li>
|
||||
<li>Use a DNS lookup tool to verify your records</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -38,26 +38,67 @@ import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { domain } from "@/server/db/validations/domain";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Dices } from "lucide-react";
|
||||
import type z from "zod";
|
||||
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import z from "zod";
|
||||
|
||||
export type CacheType = "fetch" | "cache";
|
||||
|
||||
export const domain = z
|
||||
.object({
|
||||
host: z.string().min(1, { message: "Add a hostname" }),
|
||||
path: z.string().min(1).optional(),
|
||||
port: z
|
||||
.number()
|
||||
.min(1, { message: "Port must be at least 1" })
|
||||
.max(65535, { message: "Port must be 65535 or below" })
|
||||
.optional(),
|
||||
https: z.boolean().optional(),
|
||||
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
|
||||
customCertResolver: z.string().optional(),
|
||||
serviceName: z.string().optional(),
|
||||
domainType: z.enum(["application", "compose", "preview"]).optional(),
|
||||
})
|
||||
.superRefine((input, ctx) => {
|
||||
if (input.https && !input.certificateType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["certificateType"],
|
||||
message: "Required",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.certificateType === "custom" && !input.customCertResolver) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["customCertResolver"],
|
||||
message: "Required",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.domainType === "compose" && !input.serviceName) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["serviceName"],
|
||||
message: "Required",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type Domain = z.infer<typeof domain>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
id: string;
|
||||
type: "application" | "compose";
|
||||
domainId?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AddDomain = ({
|
||||
applicationId,
|
||||
domainId = "",
|
||||
children,
|
||||
}: Props) => {
|
||||
export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||
|
||||
const utils = api.useUtils();
|
||||
const { data, refetch } = api.domain.one.useQuery(
|
||||
{
|
||||
@@ -68,14 +109,24 @@ export const AddDomain = ({
|
||||
},
|
||||
);
|
||||
|
||||
const { data: application } = api.application.one.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
},
|
||||
);
|
||||
const { data: application } =
|
||||
type === "application"
|
||||
? api.application.one.useQuery(
|
||||
{
|
||||
applicationId: id,
|
||||
},
|
||||
{
|
||||
enabled: !!id,
|
||||
},
|
||||
)
|
||||
: api.compose.one.useQuery(
|
||||
{
|
||||
composeId: id,
|
||||
},
|
||||
{
|
||||
enabled: !!id,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isError, error, isLoading } = domainId
|
||||
? api.domain.update.useMutation()
|
||||
@@ -89,7 +140,22 @@ export const AddDomain = ({
|
||||
serverId: application?.serverId || "",
|
||||
});
|
||||
|
||||
console.log("canGenerateTraefikMeDomains", canGenerateTraefikMeDomains);
|
||||
const {
|
||||
data: services,
|
||||
isFetching: isLoadingServices,
|
||||
error: errorServices,
|
||||
refetch: refetchServices,
|
||||
} = api.compose.loadServices.useQuery(
|
||||
{
|
||||
composeId: id,
|
||||
type: cacheType,
|
||||
},
|
||||
{
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: type === "compose" && !!id,
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm<Domain>({
|
||||
resolver: zodResolver(domain),
|
||||
@@ -100,12 +166,15 @@ export const AddDomain = ({
|
||||
https: false,
|
||||
certificateType: undefined,
|
||||
customCertResolver: undefined,
|
||||
serviceName: undefined,
|
||||
domainType: type,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const certificateType = form.watch("certificateType");
|
||||
const https = form.watch("https");
|
||||
const domainType = form.watch("domainType");
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -116,6 +185,8 @@ export const AddDomain = ({
|
||||
port: data?.port || undefined,
|
||||
certificateType: data?.certificateType || undefined,
|
||||
customCertResolver: data?.customCertResolver || undefined,
|
||||
serviceName: data?.serviceName || undefined,
|
||||
domainType: data?.domainType || type,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -127,6 +198,7 @@ export const AddDomain = ({
|
||||
https: false,
|
||||
certificateType: undefined,
|
||||
customCertResolver: undefined,
|
||||
domainType: type,
|
||||
});
|
||||
}
|
||||
}, [form, data, isLoading, domainId]);
|
||||
@@ -150,22 +222,37 @@ export const AddDomain = ({
|
||||
const onSubmit = async (data: Domain) => {
|
||||
await mutateAsync({
|
||||
domainId,
|
||||
applicationId,
|
||||
...(data.domainType === "application" && {
|
||||
applicationId: id,
|
||||
}),
|
||||
...(data.domainType === "compose" && {
|
||||
composeId: id,
|
||||
}),
|
||||
...data,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(dictionary.success);
|
||||
await utils.domain.byApplicationId.invalidate({
|
||||
applicationId,
|
||||
});
|
||||
await utils.application.readTraefikConfig.invalidate({ applicationId });
|
||||
|
||||
if (data.domainType === "application") {
|
||||
await utils.domain.byApplicationId.invalidate({
|
||||
applicationId: id,
|
||||
});
|
||||
await utils.application.readTraefikConfig.invalidate({
|
||||
applicationId: id,
|
||||
});
|
||||
} else if (data.domainType === "compose") {
|
||||
await utils.domain.byComposeId.invalidate({
|
||||
composeId: id,
|
||||
});
|
||||
}
|
||||
|
||||
if (domainId) {
|
||||
refetch();
|
||||
}
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((e) => {
|
||||
console.log(e);
|
||||
toast.error(dictionary.error);
|
||||
});
|
||||
};
|
||||
@@ -189,6 +276,119 @@ export const AddDomain = ({
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row items-end w-full gap-4">
|
||||
{domainType === "compose" && (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{errorServices && (
|
||||
<AlertBlock
|
||||
type="warning"
|
||||
className="[overflow-wrap:anywhere]"
|
||||
>
|
||||
{errorServices?.message}
|
||||
</AlertBlock>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serviceName"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Service Name</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a service name" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
||||
<SelectContent>
|
||||
{services?.map((service, index) => (
|
||||
<SelectItem
|
||||
value={service}
|
||||
key={`${service}-${index}`}
|
||||
>
|
||||
{service}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="none" disabled>
|
||||
Empty
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
isLoading={isLoadingServices}
|
||||
onClick={() => {
|
||||
if (cacheType === "fetch") {
|
||||
refetchServices();
|
||||
} else {
|
||||
setCacheType("fetch");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
className="max-w-[10rem]"
|
||||
>
|
||||
<p>
|
||||
Fetch: Will clone the repository and load
|
||||
the services
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
isLoading={isLoadingServices}
|
||||
onClick={() => {
|
||||
if (cacheType === "cache") {
|
||||
refetchServices();
|
||||
} else {
|
||||
setCacheType("cache");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
className="max-w-[10rem]"
|
||||
>
|
||||
<p>
|
||||
Cache: If you previously deployed this
|
||||
compose, it will read the services from
|
||||
the last deployment/fetch from the
|
||||
repository
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
@@ -276,6 +476,11 @@ export const AddDomain = ({
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Container Port</FormLabel>
|
||||
<FormDescription>
|
||||
The port where your application is running inside the
|
||||
container (e.g., 3000 for Node.js, 80 for Nginx, 8080
|
||||
for Java)
|
||||
</FormDescription>
|
||||
<FormControl>
|
||||
<NumberInput placeholder={"3000"} {...field} />
|
||||
</FormControl>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -7,29 +8,133 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
Loader2,
|
||||
PenBoxIcon,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AddDomain } from "./add-domain";
|
||||
import { DnsHelperModal } from "./dns-helper-modal";
|
||||
import { AddDomain } from "./handle-domain";
|
||||
|
||||
export type ValidationState = {
|
||||
isLoading: boolean;
|
||||
isValid?: boolean;
|
||||
error?: string;
|
||||
resolvedIp?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type ValidationStates = Record<string, ValidationState>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
id: string;
|
||||
type: "application" | "compose";
|
||||
}
|
||||
|
||||
export const ShowDomains = ({ applicationId }: Props) => {
|
||||
const { data, refetch } = api.domain.byApplicationId.useQuery(
|
||||
{
|
||||
applicationId,
|
||||
},
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
},
|
||||
export const ShowDomains = ({ id, type }: Props) => {
|
||||
const { data: application } =
|
||||
type === "application"
|
||||
? api.application.one.useQuery(
|
||||
{
|
||||
applicationId: id,
|
||||
},
|
||||
{
|
||||
enabled: !!id,
|
||||
},
|
||||
)
|
||||
: api.compose.one.useQuery(
|
||||
{
|
||||
composeId: id,
|
||||
},
|
||||
{
|
||||
enabled: !!id,
|
||||
},
|
||||
);
|
||||
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
||||
{},
|
||||
);
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
|
||||
const {
|
||||
data,
|
||||
refetch,
|
||||
isLoading: isLoadingDomains,
|
||||
} = type === "application"
|
||||
? api.domain.byApplicationId.useQuery(
|
||||
{
|
||||
applicationId: id,
|
||||
},
|
||||
{
|
||||
enabled: !!id,
|
||||
},
|
||||
)
|
||||
: api.domain.byComposeId.useQuery(
|
||||
{
|
||||
composeId: id,
|
||||
},
|
||||
{
|
||||
enabled: !!id,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: validateDomain } =
|
||||
api.domain.validateDomain.useMutation();
|
||||
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
||||
api.domain.delete.useMutation();
|
||||
|
||||
const handleValidateDomain = async (host: string) => {
|
||||
setValidationStates((prev) => ({
|
||||
...prev,
|
||||
[host]: { isLoading: true },
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await validateDomain({
|
||||
domain: host,
|
||||
serverIp:
|
||||
application?.server?.ipAddress?.toString() || ip?.toString() || "",
|
||||
});
|
||||
|
||||
setValidationStates((prev) => ({
|
||||
...prev,
|
||||
[host]: {
|
||||
isLoading: false,
|
||||
isValid: result.isValid,
|
||||
error: result.error,
|
||||
resolvedIp: result.resolvedIp,
|
||||
message: result.error && result.isValid ? result.error : undefined,
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
setValidationStates((prev) => ({
|
||||
...prev,
|
||||
[host]: {
|
||||
isLoading: false,
|
||||
isValid: false,
|
||||
error: error.message || "Failed to validate domain",
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
@@ -43,7 +148,7 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
||||
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
{data && data?.length > 0 && (
|
||||
<AddDomain applicationId={applicationId}>
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
@@ -52,15 +157,22 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-row gap-4">
|
||||
{data?.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3">
|
||||
{isLoadingDomains ? (
|
||||
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
Loading domains...
|
||||
</span>
|
||||
</div>
|
||||
) : data?.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 min-h-[40vh]">
|
||||
<GlobeIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
To access the application it is required to set at least 1
|
||||
domain
|
||||
</span>
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
<AddDomain applicationId={applicationId}>
|
||||
<AddDomain id={id} type={type}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
@@ -68,73 +180,215 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
|
||||
{data?.map((item) => {
|
||||
const validationState = validationStates[item.host];
|
||||
return (
|
||||
<div
|
||||
<Card
|
||||
key={item.domainId}
|
||||
className="flex w-full items-center justify-between gap-4 border p-4 md:px-6 rounded-lg flex-wrap"
|
||||
className="relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit"
|
||||
>
|
||||
<Link
|
||||
className="md:basis-1/2 flex gap-2 items-center hover:underline transition-all w-full"
|
||||
target="_blank"
|
||||
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
||||
>
|
||||
<span className="truncate max-w-full text-sm">
|
||||
{item.host}
|
||||
</span>
|
||||
<ExternalLink className="size-4 min-w-4" />
|
||||
</Link>
|
||||
|
||||
<div className="flex gap-8">
|
||||
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
|
||||
<span>{item.path}</span>
|
||||
<span>{item.port}</span>
|
||||
<span>{item.https ? "HTTPS" : "HTTP"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<AddDomain
|
||||
applicationId={applicationId}
|
||||
domainId={item.domainId}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 "
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Service & Domain Info */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-y-2">
|
||||
{item.serviceName && (
|
||||
<Badge variant="outline" className="w-fit">
|
||||
<Server className="size-3 mr-1" />
|
||||
{item.serviceName}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{!item.host.includes("traefik.me") && (
|
||||
<DnsHelperModal
|
||||
domain={{
|
||||
host: item.host,
|
||||
https: item.https,
|
||||
path: item.path || undefined,
|
||||
}}
|
||||
serverIp={
|
||||
application?.server?.ipAddress?.toString() ||
|
||||
ip?.toString()
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<AddDomain
|
||||
id={id}
|
||||
type={type}
|
||||
domainId={item.domainId}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</AddDomain>
|
||||
<DialogAction
|
||||
title="Delete Domain"
|
||||
description="Are you sure you want to delete this domain?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteDomain({
|
||||
domainId: item.domainId,
|
||||
})
|
||||
.then((_data) => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Domain deleted successfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting domain");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full break-all">
|
||||
<Link
|
||||
className="flex items-center gap-2 text-base font-medium hover:underline"
|
||||
target="_blank"
|
||||
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</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(() => {
|
||||
refetch();
|
||||
toast.success("Domain deleted successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting domain");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{item.host}
|
||||
<ExternalLink className="size-4 min-w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Domain Details */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="secondary">
|
||||
<InfoIcon className="size-3 mr-1" />
|
||||
Path: {item.path || "/"}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>URL path for this service</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="secondary">
|
||||
<InfoIcon className="size-3 mr-1" />
|
||||
Port: {item.port}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Container port exposed</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant={item.https ? "outline" : "secondary"}
|
||||
>
|
||||
{item.https ? "HTTPS" : "HTTP"}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{item.https
|
||||
? "Secure HTTPS connection"
|
||||
: "Standard HTTP connection"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{item.certificateType && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline">
|
||||
Cert: {item.certificateType}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>SSL Certificate Provider</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
validationState?.isValid
|
||||
? "bg-green-500/10 text-green-500 cursor-pointer"
|
||||
: validationState?.error
|
||||
? "bg-red-500/10 text-red-500 cursor-pointer"
|
||||
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
|
||||
}
|
||||
onClick={() =>
|
||||
handleValidateDomain(item.host)
|
||||
}
|
||||
>
|
||||
{validationState?.isLoading ? (
|
||||
<>
|
||||
<Loader2 className="size-3 mr-1 animate-spin" />
|
||||
Checking DNS...
|
||||
</>
|
||||
) : validationState?.isValid ? (
|
||||
<>
|
||||
<CheckCircle2 className="size-3 mr-1" />
|
||||
{validationState.message
|
||||
? "Behind Cloudflare"
|
||||
: "DNS Valid"}
|
||||
</>
|
||||
) : validationState?.error ? (
|
||||
<>
|
||||
<XCircle className="size-3 mr-1" />
|
||||
{validationState.error}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="size-3 mr-1" />
|
||||
Validate DNS
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
{validationState?.error ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium text-red-500">
|
||||
Error:
|
||||
</p>
|
||||
<p>{validationState.error}</p>
|
||||
</div>
|
||||
) : (
|
||||
"Click to validate DNS configuration"
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,10 @@ import { Form } from "@/components/ui/form";
|
||||
import { Secrets } from "@/components/ui/secrets";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const addEnvironmentSchema = z.object({
|
||||
env: z.string(),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -39,13 +42,11 @@ import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
const BitbucketProviderSchema = z.object({
|
||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||
@@ -58,6 +59,7 @@ const BitbucketProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
|
||||
@@ -84,6 +86,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
bitbucketId: "",
|
||||
branch: "",
|
||||
watchPaths: [],
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(BitbucketProviderSchema),
|
||||
});
|
||||
@@ -130,9 +133,10 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
buildPath: data.bitbucketBuildPath || "/",
|
||||
bitbucketId: data.bitbucketId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules || false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
}, [form.reset, data?.applicationId, form]);
|
||||
|
||||
const onSubmit = async (data: BitbucketProvider) => {
|
||||
await mutateAsync({
|
||||
@@ -143,6 +147,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
bitbucketId: data.bitbucketId,
|
||||
applicationId,
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules || false,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -430,7 +435,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -449,7 +454,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
||||
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
@@ -467,6 +472,21 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
|
||||
@@ -53,7 +53,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
||||
registryURL: data.registryUrl || "",
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
}, [form.reset, data?.applicationId, form]);
|
||||
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
@@ -115,7 +115,11 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Username" autoComplete="username" {...field} />
|
||||
<Input
|
||||
placeholder="Username"
|
||||
autoComplete="username"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -130,7 +134,12 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Password" autoComplete="one-time-code" {...field} type="password" />
|
||||
<Input
|
||||
placeholder="Password"
|
||||
autoComplete="one-time-code"
|
||||
{...field}
|
||||
type="password"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -26,15 +27,15 @@ import {
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { GitIcon } from "@/components/icons/data-tools-icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GitIcon } from "@/components/icons/data-tools-icons";
|
||||
|
||||
const GitProviderSchema = z.object({
|
||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||
@@ -44,6 +45,7 @@ const GitProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch required"),
|
||||
sshKey: z.string().optional(),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type GitProvider = z.infer<typeof GitProviderSchema>;
|
||||
@@ -67,6 +69,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
repositoryURL: "",
|
||||
sshKey: undefined,
|
||||
watchPaths: [],
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(GitProviderSchema),
|
||||
});
|
||||
@@ -79,6 +82,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
buildPath: data.customGitBuildPath || "/",
|
||||
repositoryURL: data.customGitUrl || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -91,6 +95,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
|
||||
applicationId,
|
||||
watchPaths: values.watchPaths || [],
|
||||
enableSubmodules: values.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Git Provider Saved");
|
||||
@@ -257,7 +262,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -276,7 +281,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
||||
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
@@ -294,6 +299,22 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end">
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
import { GiteaIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
interface GiteaRepository {
|
||||
name: string;
|
||||
url: string;
|
||||
id: number;
|
||||
owner: {
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GiteaBranch {
|
||||
name: string;
|
||||
commit: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
const GiteaProviderSchema = z.object({
|
||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||
repository: z
|
||||
.object({
|
||||
repo: z.string().min(1, "Repo is required"),
|
||||
owner: z.string().min(1, "Owner is required"),
|
||||
})
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||
watchPaths: z.array(z.string()).default([]),
|
||||
enableSubmodules: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
|
||||
const { data, refetch } = api.application.one.useQuery({ applicationId });
|
||||
|
||||
const { mutateAsync, isLoading: isSavingGiteaProvider } =
|
||||
api.application.saveGiteaProvider.useMutation();
|
||||
|
||||
const form = useForm<GiteaProvider>({
|
||||
defaultValues: {
|
||||
buildPath: "/",
|
||||
repository: {
|
||||
owner: "",
|
||||
repo: "",
|
||||
},
|
||||
giteaId: "",
|
||||
branch: "",
|
||||
watchPaths: [],
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(GiteaProviderSchema),
|
||||
});
|
||||
|
||||
const repository = form.watch("repository");
|
||||
const giteaId = form.watch("giteaId");
|
||||
|
||||
const { data: giteaUrl } = api.gitea.getGiteaUrl.useQuery(
|
||||
{ giteaId },
|
||||
{
|
||||
enabled: !!giteaId,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: repositories,
|
||||
isLoading: isLoadingRepositories,
|
||||
error,
|
||||
} = api.gitea.getGiteaRepositories.useQuery(
|
||||
{
|
||||
giteaId,
|
||||
},
|
||||
{
|
||||
enabled: !!giteaId,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: branches,
|
||||
fetchStatus,
|
||||
status,
|
||||
} = api.gitea.getGiteaBranches.useQuery(
|
||||
{
|
||||
owner: repository?.owner,
|
||||
repositoryName: repository?.repo,
|
||||
giteaId: giteaId,
|
||||
},
|
||||
{
|
||||
enabled: !!repository?.owner && !!repository?.repo && !!giteaId,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
branch: data.giteaBranch || "",
|
||||
repository: {
|
||||
repo: data.giteaRepository || "",
|
||||
owner: data.giteaOwner || "",
|
||||
},
|
||||
buildPath: data.giteaBuildPath || "/",
|
||||
giteaId: data.giteaId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules || false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data?.applicationId, form]);
|
||||
|
||||
const onSubmit = async (data: GiteaProvider) => {
|
||||
await mutateAsync({
|
||||
giteaBranch: data.branch,
|
||||
giteaRepository: data.repository.repo,
|
||||
giteaOwner: data.repository.owner,
|
||||
giteaBuildPath: data.buildPath,
|
||||
giteaId: data.giteaId,
|
||||
applicationId,
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules || false,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provider Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the Gitea provider");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4 py-3"
|
||||
>
|
||||
{error && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="giteaId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2 flex flex-col">
|
||||
<FormLabel>Gitea Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
repo: "",
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a Gitea Account" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{giteaProviders?.map((giteaProvider) => (
|
||||
<SelectItem
|
||||
key={giteaProvider.giteaId}
|
||||
value={giteaProvider.giteaId}
|
||||
>
|
||||
{giteaProvider.gitProvider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repository"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
{field.value.owner && field.value.repo && (
|
||||
<Link
|
||||
href={`${giteaUrl}/${field.value.owner}/${field.value.repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<GiteaIcon className="h-4 w-4" />
|
||||
<span>View Repository</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
(repo: GiteaRepository) =>
|
||||
repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
{repositories && repositories.length === 0 && (
|
||||
<CommandEmpty>
|
||||
No repositories found.
|
||||
</CommandEmpty>
|
||||
)}
|
||||
{repositories?.map((repo: GiteaRepository) => {
|
||||
return (
|
||||
<CommandItem
|
||||
value={repo.name}
|
||||
key={repo.url}
|
||||
onSelect={() => {
|
||||
form.setValue("repository", {
|
||||
owner: repo.owner.username as string,
|
||||
repo: repo.name,
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{repo.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{repo.owner.username}
|
||||
</span>
|
||||
</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
repo.name === field.value.repo
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{form.formState.errors.repository && (
|
||||
<p className={cn("text-sm font-medium text-destructive")}>
|
||||
Repository is required
|
||||
</p>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="branch"
|
||||
render={({ field }) => (
|
||||
<FormItem className="block w-full">
|
||||
<FormLabel>Branch</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
" w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "loading" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
(branch: GiteaBranch) =>
|
||||
branch.name === field.value,
|
||||
)?.name
|
||||
: "Select branch"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search branch..."
|
||||
className="h-9"
|
||||
/>
|
||||
{status === "loading" && fetchStatus === "fetching" && (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Loading Branches....
|
||||
</span>
|
||||
)}
|
||||
{!repository?.owner && (
|
||||
<span className="py-6 text-center text-sm text-muted-foreground">
|
||||
Select a repository
|
||||
</span>
|
||||
)}
|
||||
<ScrollArea className="h-96">
|
||||
<CommandEmpty>No branch found.</CommandEmpty>
|
||||
|
||||
<CommandGroup>
|
||||
{branches && branches.length === 0 && (
|
||||
<CommandItem>No branches found.</CommandItem>
|
||||
)}
|
||||
{branches?.map((branch: GiteaBranch) => (
|
||||
<CommandItem
|
||||
value={branch.name}
|
||||
key={branch.commit.id}
|
||||
onSelect={() => {
|
||||
form.setValue("branch", branch.name);
|
||||
}}
|
||||
>
|
||||
{branch.name}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
branch.name === field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
<FormMessage />
|
||||
</Popover>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="buildPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Build Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Add paths to watch for changes. When files in these
|
||||
paths change, a new deployment will be triggered.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path: string, index: number) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{path}
|
||||
<X
|
||||
className="size-3 cursor-pointer hover:text-destructive"
|
||||
onClick={() => {
|
||||
const newPaths = [...field.value];
|
||||
newPaths.splice(index, 1);
|
||||
field.onChange(newPaths);
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...field.value, path]);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder*="Enter a path"]',
|
||||
) as HTMLInputElement;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...field.value, path]);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
isLoading={isSavingGiteaProvider}
|
||||
type="submit"
|
||||
className="w-fit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -28,23 +30,22 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import Link from "next/link";
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
|
||||
const GithubProviderSchema = z.object({
|
||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||
@@ -57,6 +58,8 @@ const GithubProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
githubId: z.string().min(1, "Github Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type GithubProvider = z.infer<typeof GithubProviderSchema>;
|
||||
@@ -81,12 +84,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
},
|
||||
githubId: "",
|
||||
branch: "",
|
||||
triggerType: "push",
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(GithubProviderSchema),
|
||||
});
|
||||
|
||||
const repository = form.watch("repository");
|
||||
const githubId = form.watch("githubId");
|
||||
const triggerType = form.watch("triggerType");
|
||||
|
||||
const { data: repositories, isLoading: isLoadingRepositories } =
|
||||
api.github.getGithubRepositories.useQuery(
|
||||
@@ -124,9 +130,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
buildPath: data.buildPath || "/",
|
||||
githubId: data.githubId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
triggerType: data.triggerType || "push",
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
}, [form.reset, data?.applicationId, form]);
|
||||
|
||||
const onSubmit = async (data: GithubProvider) => {
|
||||
await mutateAsync({
|
||||
@@ -137,6 +145,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
buildPath: data.buildPath,
|
||||
githubId: data.githubId,
|
||||
watchPaths: data.watchPaths || [],
|
||||
triggerType: data.triggerType,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -381,11 +391,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
name="triggerType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<div className="flex items-center gap-2 ">
|
||||
<FormLabel>Trigger Type</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -393,68 +403,127 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Add paths to watch for changes. When files in these
|
||||
paths change, a new deployment will be triggered.
|
||||
Choose when to trigger deployments: on push to the
|
||||
selected branch or when a new tag is created.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{path}
|
||||
<X
|
||||
className="size-3 cursor-pointer hover:text-destructive"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
field.onChange(newPaths);
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a trigger type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="push">On Push</SelectItem>
|
||||
<SelectItem value="tag">On Tag</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{triggerType === "push" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Add paths to watch for changes. When files in
|
||||
these paths change, a new deployment will be
|
||||
triggered.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{path}
|
||||
<X
|
||||
className="size-3 cursor-pointer hover:text-destructive"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
field.onChange(newPaths);
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...(field.value || []), path]);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...(field.value || []), path]);
|
||||
input.value = "";
|
||||
}
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder*="Enter a path"]',
|
||||
) as HTMLInputElement;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...(field.value || []), path]);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder*="Enter a path"]',
|
||||
) as HTMLInputElement;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...(field.value || []), path]);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -29,23 +31,22 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import Link from "next/link";
|
||||
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
||||
|
||||
const GitlabProviderSchema = z.object({
|
||||
buildPath: z.string().min(1, "Path is required").default("/"),
|
||||
@@ -60,6 +61,7 @@ const GitlabProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
|
||||
@@ -86,6 +88,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
},
|
||||
gitlabId: "",
|
||||
branch: "",
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(GitlabProviderSchema),
|
||||
});
|
||||
@@ -135,9 +138,10 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
buildPath: data.gitlabBuildPath || "/",
|
||||
gitlabId: data.gitlabId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
}, [form.reset, data?.applicationId, form]);
|
||||
|
||||
const onSubmit = async (data: GitlabProvider) => {
|
||||
await mutateAsync({
|
||||
@@ -150,6 +154,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
gitlabProjectId: data.repository.id,
|
||||
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -447,7 +452,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -483,6 +488,21 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
|
||||
@@ -1,37 +1,82 @@
|
||||
import { SaveDockerProvider } from "@/components/dashboard/application/general/generic/save-docker-provider";
|
||||
import { SaveGitProvider } from "@/components/dashboard/application/general/generic/save-git-provider";
|
||||
import { SaveGiteaProvider } from "@/components/dashboard/application/general/generic/save-gitea-provider";
|
||||
import { SaveGithubProvider } from "@/components/dashboard/application/general/generic/save-github-provider";
|
||||
import {
|
||||
BitbucketIcon,
|
||||
DockerIcon,
|
||||
GitIcon,
|
||||
GiteaIcon,
|
||||
GithubIcon,
|
||||
GitlabIcon,
|
||||
} from "@/components/icons/data-tools-icons";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/utils/api";
|
||||
import { GitBranch, UploadCloud } from "lucide-react";
|
||||
import { GitBranch, Loader2, UploadCloud } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { SaveBitbucketProvider } from "./save-bitbucket-provider";
|
||||
import { SaveDragNDrop } from "./save-drag-n-drop";
|
||||
import { SaveGitlabProvider } from "./save-gitlab-provider";
|
||||
|
||||
type TabState = "github" | "docker" | "git" | "drop" | "gitlab" | "bitbucket";
|
||||
type TabState =
|
||||
| "github"
|
||||
| "docker"
|
||||
| "git"
|
||||
| "drop"
|
||||
| "gitlab"
|
||||
| "bitbucket"
|
||||
| "gitea";
|
||||
|
||||
interface Props {
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
const { data: githubProviders } = api.github.githubProviders.useQuery();
|
||||
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
|
||||
const { data: bitbucketProviders } =
|
||||
const { data: githubProviders, isLoading: isLoadingGithub } =
|
||||
api.github.githubProviders.useQuery();
|
||||
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
|
||||
api.gitlab.gitlabProviders.useQuery();
|
||||
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
|
||||
api.bitbucket.bitbucketProviders.useQuery();
|
||||
const { data: giteaProviders, isLoading: isLoadingGitea } =
|
||||
api.gitea.giteaProviders.useQuery();
|
||||
|
||||
const { data: application } = api.application.one.useQuery({ applicationId });
|
||||
const [tab, setSab] = useState<TabState>(application?.sourceType || "github");
|
||||
|
||||
const isLoading =
|
||||
isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="group relative w-full bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-start justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex flex-col space-y-0.5">Provider</span>
|
||||
<p className="flex items-center text-sm font-normal text-muted-foreground">
|
||||
Select the source of your code
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden space-y-1 text-sm font-normal md:block">
|
||||
<GitBranch className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex min-h-[25vh] items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading providers...</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="group relative w-full bg-transparent">
|
||||
<CardHeader>
|
||||
@@ -55,7 +100,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
setSab(e as TabState);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
||||
<TabsList className="md:grid md:w-fit md:grid-cols-7 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
|
||||
<TabsTrigger
|
||||
value="github"
|
||||
@@ -78,6 +123,13 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
<BitbucketIcon className="size-4 text-current fill-current" />
|
||||
Bitbucket
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="gitea"
|
||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
>
|
||||
<GiteaIcon className="size-4 text-current fill-current" />
|
||||
Gitea
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="docker"
|
||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
@@ -106,7 +158,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
{githubProviders && githubProviders?.length > 0 ? (
|
||||
<SaveGithubProvider applicationId={applicationId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<GithubIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
To deploy using GitHub, you need to configure your account
|
||||
@@ -126,7 +178,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
{gitlabProviders && gitlabProviders?.length > 0 ? (
|
||||
<SaveGitlabProvider applicationId={applicationId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<GitlabIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
To deploy using GitLab, you need to configure your account
|
||||
@@ -146,7 +198,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
|
||||
<SaveBitbucketProvider applicationId={applicationId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<BitbucketIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
To deploy using Bitbucket, you need to configure your account
|
||||
@@ -162,6 +214,26 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="gitea" className="w-full p-2">
|
||||
{giteaProviders && giteaProviders?.length > 0 ? (
|
||||
<SaveGiteaProvider applicationId={applicationId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<GiteaIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
To deploy using Gitea, you need to configure your account
|
||||
first. Please, go to{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/git-providers"
|
||||
className="text-foreground"
|
||||
>
|
||||
Settings
|
||||
</Link>{" "}
|
||||
to do so.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="docker" className="w-full p-2">
|
||||
<SaveDockerProvider applicationId={applicationId} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import type { RouterOutputs } from "@/utils/api";
|
||||
import { useState } from "react";
|
||||
import { ShowDeployment } from "../deployments/show-deployment";
|
||||
|
||||
interface Props {
|
||||
deployments: RouterOutputs["deployment"]["all"];
|
||||
serverId?: string;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ShowPreviewBuilds = ({
|
||||
deployments,
|
||||
serverId,
|
||||
trigger,
|
||||
}: Props) => {
|
||||
const [activeLog, setActiveLog] = useState<
|
||||
RouterOutputs["deployment"]["all"][number] | null
|
||||
>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ? (
|
||||
trigger
|
||||
) : (
|
||||
<Button className="sm:w-auto w-full" size="sm" variant="outline">
|
||||
View Builds
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Preview Builds</DialogTitle>
|
||||
<DialogDescription>
|
||||
See all the preview builds for this application on this Pull Request
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
{deployments?.map((deployment) => (
|
||||
<div
|
||||
key={deployment.deploymentId}
|
||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||
{deployment.status}
|
||||
|
||||
<StatusTooltip
|
||||
status={deployment?.status}
|
||||
className="size-2.5"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{deployment.title}
|
||||
</span>
|
||||
{deployment.description && (
|
||||
<span className="break-all text-sm text-muted-foreground">
|
||||
{deployment.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="text-sm capitalize text-muted-foreground">
|
||||
<DateTooltip date={deployment.createdAt} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLog(deployment);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
<ShowDeployment
|
||||
serverId={serverId || ""}
|
||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
||||
onClose={() => setActiveLog(null)}
|
||||
logPath={activeLog?.logPath || ""}
|
||||
errorMessage={activeLog?.errorMessage || ""}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -17,15 +17,15 @@ import {
|
||||
ExternalLink,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
Layers,
|
||||
Loader2,
|
||||
PenSquare,
|
||||
RocketIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
||||
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
||||
import { AddPreviewDomain } from "./add-preview-domain";
|
||||
import { ShowPreviewBuilds } from "./show-preview-builds";
|
||||
import { ShowPreviewSettings } from "./show-preview-settings";
|
||||
|
||||
interface Props {
|
||||
@@ -38,13 +38,16 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
||||
api.previewDeployment.delete.useMutation();
|
||||
|
||||
const { data: previewDeployments, refetch: refetchPreviewDeployments } =
|
||||
api.previewDeployment.all.useQuery(
|
||||
{ applicationId },
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
},
|
||||
);
|
||||
const {
|
||||
data: previewDeployments,
|
||||
refetch: refetchPreviewDeployments,
|
||||
isLoading: isLoadingPreviewDeployments,
|
||||
} = api.previewDeployment.all.useQuery(
|
||||
{ applicationId },
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
},
|
||||
);
|
||||
|
||||
const handleDeletePreviewDeployment = async (previewDeploymentId: string) => {
|
||||
deletePreviewDeployment({
|
||||
@@ -80,8 +83,15 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
each pull request you create.
|
||||
</span>
|
||||
</div>
|
||||
{!previewDeployments?.length ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
{isLoadingPreviewDeployments ? (
|
||||
<div className="flex w-full flex-row items-center justify-center gap-3 min-h-[35vh]">
|
||||
<Loader2 className="size-5 text-muted-foreground animate-spin" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
Loading preview deployments...
|
||||
</span>
|
||||
</div>
|
||||
) : !previewDeployments?.length ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 min-h-[35vh]">
|
||||
<RocketIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No preview deployments found
|
||||
@@ -168,19 +178,10 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
</Button>
|
||||
</ShowModalLogs>
|
||||
|
||||
<ShowPreviewBuilds
|
||||
deployments={deployment.deployments || []}
|
||||
<ShowDeploymentsModal
|
||||
id={deployment.previewDeploymentId}
|
||||
type="previewDeployment"
|
||||
serverId={data?.serverId || ""}
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Layers className="size-4" />
|
||||
Builds
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<AddPreviewDomain
|
||||
|
||||
@@ -298,7 +298,11 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Preview deployments enabled");
|
||||
toast.success(
|
||||
checked
|
||||
? "Preview deployments enabled"
|
||||
: "Preview deployments disabled",
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
DatabaseZap,
|
||||
Info,
|
||||
PenBoxIcon,
|
||||
PlusCircle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import type { CacheType } from "../domains/handle-domain";
|
||||
|
||||
export const commonCronExpressions = [
|
||||
{ label: "Every minute", value: "* * * * *" },
|
||||
{ label: "Every hour", value: "0 * * * *" },
|
||||
{ label: "Every day at midnight", value: "0 0 * * *" },
|
||||
{ label: "Every Sunday at midnight", value: "0 0 * * 0" },
|
||||
{ label: "Every month on the 1st at midnight", value: "0 0 1 * *" },
|
||||
{ label: "Every 15 minutes", value: "*/15 * * * *" },
|
||||
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" },
|
||||
];
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
cronExpression: z.string().min(1, "Cron expression is required"),
|
||||
shellType: z.enum(["bash", "sh"]).default("bash"),
|
||||
command: z.string(),
|
||||
enabled: z.boolean().default(true),
|
||||
serviceName: z.string(),
|
||||
scheduleType: z.enum([
|
||||
"application",
|
||||
"compose",
|
||||
"server",
|
||||
"dokploy-server",
|
||||
]),
|
||||
script: z.string(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.scheduleType === "compose" && !data.serviceName) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Service name is required",
|
||||
path: ["serviceName"],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(data.scheduleType === "dokploy-server" ||
|
||||
data.scheduleType === "server") &&
|
||||
!data.script
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Script is required",
|
||||
path: ["script"],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(data.scheduleType === "application" ||
|
||||
data.scheduleType === "compose") &&
|
||||
!data.command
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Command is required",
|
||||
path: ["command"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
scheduleId?: string;
|
||||
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
|
||||
}
|
||||
|
||||
export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||
|
||||
const utils = api.useUtils();
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
cronExpression: "",
|
||||
shellType: "bash",
|
||||
command: "",
|
||||
enabled: true,
|
||||
serviceName: "",
|
||||
scheduleType: scheduleType || "application",
|
||||
script: "",
|
||||
},
|
||||
});
|
||||
|
||||
const scheduleTypeForm = form.watch("scheduleType");
|
||||
|
||||
const { data: schedule } = api.schedule.one.useQuery(
|
||||
{ scheduleId: scheduleId || "" },
|
||||
{ enabled: !!scheduleId },
|
||||
);
|
||||
|
||||
const {
|
||||
data: services,
|
||||
isFetching: isLoadingServices,
|
||||
error: errorServices,
|
||||
refetch: refetchServices,
|
||||
} = api.compose.loadServices.useQuery(
|
||||
{
|
||||
composeId: id || "",
|
||||
type: cacheType,
|
||||
},
|
||||
{
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!id && scheduleType === "compose",
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (scheduleId && schedule) {
|
||||
form.reset({
|
||||
name: schedule.name,
|
||||
cronExpression: schedule.cronExpression,
|
||||
shellType: schedule.shellType,
|
||||
command: schedule.command,
|
||||
enabled: schedule.enabled,
|
||||
serviceName: schedule.serviceName || "",
|
||||
scheduleType: schedule.scheduleType,
|
||||
script: schedule.script || "",
|
||||
});
|
||||
}
|
||||
}, [form, schedule, scheduleId]);
|
||||
|
||||
const { mutateAsync, isLoading } = scheduleId
|
||||
? api.schedule.update.useMutation()
|
||||
: api.schedule.create.useMutation();
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
if (!id && !scheduleId) return;
|
||||
|
||||
await mutateAsync({
|
||||
...values,
|
||||
scheduleId: scheduleId || "",
|
||||
...(scheduleType === "application" && {
|
||||
applicationId: id || "",
|
||||
}),
|
||||
...(scheduleType === "compose" && {
|
||||
composeId: id || "",
|
||||
}),
|
||||
...(scheduleType === "server" && {
|
||||
serverId: id || "",
|
||||
}),
|
||||
...(scheduleType === "dokploy-server" && {
|
||||
userId: id || "",
|
||||
}),
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
`Schedule ${scheduleId ? "updated" : "created"} successfully`,
|
||||
);
|
||||
utils.schedule.list.invalidate({
|
||||
id,
|
||||
scheduleType,
|
||||
});
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "An unknown error occurred",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{scheduleId ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button>
|
||||
<PlusCircle className="w-4 h-4 mr-2" />
|
||||
Add Schedule
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"max-h-screen overflow-y-auto",
|
||||
scheduleTypeForm === "dokploy-server" || scheduleTypeForm === "server"
|
||||
? "max-h-[95vh] sm:max-w-2xl"
|
||||
: " sm:max-w-lg",
|
||||
)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{scheduleId ? "Edit" : "Create"} Schedule</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{scheduleTypeForm === "compose" && (
|
||||
<div className="flex flex-col w-full gap-4">
|
||||
{errorServices && (
|
||||
<AlertBlock
|
||||
type="warning"
|
||||
className="[overflow-wrap:anywhere]"
|
||||
>
|
||||
{errorServices?.message}
|
||||
</AlertBlock>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serviceName"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Service Name</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a service name" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
||||
<SelectContent>
|
||||
{services?.map((service, index) => (
|
||||
<SelectItem
|
||||
value={service}
|
||||
key={`${service}-${index}`}
|
||||
>
|
||||
{service}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="none" disabled>
|
||||
Empty
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
isLoading={isLoadingServices}
|
||||
onClick={() => {
|
||||
if (cacheType === "fetch") {
|
||||
refetchServices();
|
||||
} else {
|
||||
setCacheType("fetch");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
className="max-w-[10rem]"
|
||||
>
|
||||
<p>
|
||||
Fetch: Will clone the repository and load the
|
||||
services
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
isLoading={isLoadingServices}
|
||||
onClick={() => {
|
||||
if (cacheType === "cache") {
|
||||
refetchServices();
|
||||
} else {
|
||||
setCacheType("cache");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
className="max-w-[10rem]"
|
||||
>
|
||||
<p>
|
||||
Cache: If you previously deployed this compose,
|
||||
it will read the services from the last
|
||||
deployment/fetch from the repository
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
Task Name
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Daily Database Backup" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A descriptive name for your scheduled task
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cronExpression"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
Schedule
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Cron expression format: minute hour day month
|
||||
weekday
|
||||
</p>
|
||||
<p>Example: 0 0 * * * (daily at midnight)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</FormLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a predefined schedule" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{commonCronExpressions.map((expr) => (
|
||||
<SelectItem key={expr.value} value={expr.value}>
|
||||
{expr.label} ({expr.value})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Choose a predefined schedule or enter a custom cron
|
||||
expression
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(scheduleTypeForm === "application" ||
|
||||
scheduleTypeForm === "compose") && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shellType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
Shell Type
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select shell type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="bash">Bash</SelectItem>
|
||||
<SelectItem value="sh">Sh</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose the shell to execute your command
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="command"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
Command
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="npm run backup" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The command to execute in your container
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(scheduleTypeForm === "dokploy-server" ||
|
||||
scheduleTypeForm === "server") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="script"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Script</FormLabel>
|
||||
<FormControl>
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
language="shell"
|
||||
placeholder={`# This is a comment
|
||||
echo "Hello, world!"
|
||||
`}
|
||||
className="h-96 font-mono"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
Enabled
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" isLoading={isLoading} className="w-full">
|
||||
{scheduleId ? "Update" : "Create"} Schedule
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
ClipboardList,
|
||||
Clock,
|
||||
Loader2,
|
||||
Play,
|
||||
Terminal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
||||
import { HandleSchedules } from "./handle-schedules";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
|
||||
}
|
||||
|
||||
export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
|
||||
const {
|
||||
data: schedules,
|
||||
isLoading: isLoadingSchedules,
|
||||
refetch: refetchSchedules,
|
||||
} = api.schedule.list.useQuery(
|
||||
{
|
||||
id: id || "",
|
||||
scheduleType,
|
||||
},
|
||||
{
|
||||
enabled: !!id,
|
||||
},
|
||||
);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
|
||||
api.schedule.delete.useMutation();
|
||||
|
||||
const { mutateAsync: runManually, isLoading } =
|
||||
api.schedule.runManually.useMutation();
|
||||
|
||||
return (
|
||||
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
|
||||
<CardHeader className="px-0">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||
Scheduled Tasks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Schedule tasks to run automatically at specified intervals.
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
{schedules && schedules.length > 0 && (
|
||||
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0">
|
||||
{isLoadingSchedules ? (
|
||||
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
|
||||
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
|
||||
<span className="text-sm text-muted-foreground/70">
|
||||
Loading scheduled tasks...
|
||||
</span>
|
||||
</div>
|
||||
) : schedules && schedules.length > 0 ? (
|
||||
<div className="grid xl:grid-cols-2 gap-4 grid-cols-1 h-full">
|
||||
{schedules.map((schedule) => {
|
||||
const serverId =
|
||||
schedule.serverId ||
|
||||
schedule.application?.serverId ||
|
||||
schedule.compose?.serverId;
|
||||
return (
|
||||
<div
|
||||
key={schedule.scheduleId}
|
||||
className=" flex items-center justify-between rounded-lg border p-3 transition-colors bg-muted/50"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5">
|
||||
<Clock className="size-4 text-primary/70" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium leading-none">
|
||||
{schedule.name}
|
||||
</h3>
|
||||
<Badge
|
||||
variant={schedule.enabled ? "default" : "secondary"}
|
||||
className="text-[10px] px-1 py-0"
|
||||
>
|
||||
{schedule.enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-mono text-[10px] bg-transparent"
|
||||
>
|
||||
Cron: {schedule.cronExpression}
|
||||
</Badge>
|
||||
{schedule.scheduleType !== "server" &&
|
||||
schedule.scheduleType !== "dokploy-server" && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground/50">
|
||||
•
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-mono text-[10px] bg-transparent"
|
||||
>
|
||||
{schedule.shellType}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{schedule.command && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-3.5 text-muted-foreground/70" />
|
||||
<code className="font-mono text-[10px] text-muted-foreground/70">
|
||||
{schedule.command}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShowDeploymentsModal
|
||||
id={schedule.scheduleId}
|
||||
type="schedule"
|
||||
serverId={serverId || undefined}
|
||||
>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ClipboardList className="size-4 transition-colors " />
|
||||
</Button>
|
||||
</ShowDeploymentsModal>
|
||||
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
isLoading={isLoading}
|
||||
onClick={async () => {
|
||||
toast.success("Schedule run successfully");
|
||||
|
||||
await runManually({
|
||||
scheduleId: schedule.scheduleId,
|
||||
}).then(async () => {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 1500),
|
||||
);
|
||||
refetchSchedules();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Play className="size-4 transition-colors" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Run Manual Schedule</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<HandleSchedules
|
||||
scheduleId={schedule.scheduleId}
|
||||
id={id}
|
||||
scheduleType={scheduleType}
|
||||
/>
|
||||
|
||||
<DialogAction
|
||||
title="Delete Schedule"
|
||||
description="Are you sure you want to delete this schedule?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteSchedule({
|
||||
scheduleId: schedule.scheduleId,
|
||||
})
|
||||
.then(() => {
|
||||
utils.schedule.list.invalidate({
|
||||
id,
|
||||
scheduleType,
|
||||
});
|
||||
toast.success("Schedule deleted successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting schedule");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
|
||||
<Clock className="size-8 mb-4 text-muted-foreground" />
|
||||
<p className="text-lg font-medium text-muted-foreground">
|
||||
No scheduled tasks
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Create your first scheduled task to automate your workflows
|
||||
</p>
|
||||
<HandleSchedules id={id} scheduleType={scheduleType} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { Paintbrush } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
export const CancelQueuesCompose = ({ composeId }: Props) => {
|
||||
const { mutateAsync, isLoading } = api.compose.cleanQueues.useMutation();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
|
||||
if (isCloud) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
|
||||
Cancel Queues
|
||||
<Paintbrush className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Are you sure to cancel the incoming deployments?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will cancel all the incoming deployments
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Queues are being cleaned");
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { api } from "@/utils/api";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
export const RefreshTokenCompose = ({ composeId }: Props) => {
|
||||
const { mutateAsync } = api.compose.refreshToken.useMutation();
|
||||
const utils = api.useUtils();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger>
|
||||
<RefreshCcw className="h-4 w-4 cursor-pointer text-muted-foreground" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently change the token
|
||||
and all the previous tokens will be invalidated
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
})
|
||||
.then(() => {
|
||||
utils.compose.one.invalidate({
|
||||
composeId,
|
||||
});
|
||||
toast.success("Refresh Token updated");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating the refresh token");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -1,184 +0,0 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { TerminalLine } from "../../docker/logs/terminal-line";
|
||||
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
||||
|
||||
interface Props {
|
||||
logPath: string | null;
|
||||
serverId?: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
errorMessage?: string;
|
||||
}
|
||||
export const ShowDeploymentCompose = ({
|
||||
logPath,
|
||||
open,
|
||||
onClose,
|
||||
serverId,
|
||||
errorMessage,
|
||||
}: Props) => {
|
||||
const [data, setData] = useState("");
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const [showExtraLogs, setShowExtraLogs] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (autoScroll && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
|
||||
setAutoScroll(isAtBottom);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !logPath) return;
|
||||
|
||||
setData("");
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
const wsUrl = `${protocol}//${window.location.host}/listen-deployment?logPath=${logPath}&serverId=${serverId}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
wsRef.current = ws; // Store WebSocket instance in ref
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
setData((currentData) => currentData + e.data);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error("WebSocket error: ", error);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null;
|
||||
};
|
||||
|
||||
return () => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [logPath, open]);
|
||||
|
||||
useEffect(() => {
|
||||
const logs = parseLogs(data);
|
||||
let filteredLogsResult = logs;
|
||||
if (serverId) {
|
||||
let hideSubsequentLogs = false;
|
||||
filteredLogsResult = logs.filter((log) => {
|
||||
if (
|
||||
log.message.includes(
|
||||
"===================================EXTRA LOGS============================================",
|
||||
)
|
||||
) {
|
||||
hideSubsequentLogs = true;
|
||||
return showExtraLogs;
|
||||
}
|
||||
return showExtraLogs ? true : !hideSubsequentLogs;
|
||||
});
|
||||
}
|
||||
|
||||
setFilteredLogs(filteredLogsResult);
|
||||
}, [data, showExtraLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
|
||||
if (autoScroll && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [filteredLogs, autoScroll]);
|
||||
|
||||
const optionalErrors = parseLogs(errorMessage || "");
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(e) => {
|
||||
onClose();
|
||||
if (!e) {
|
||||
setData("");
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
if (wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className={"sm:max-w-5xl max-h-screen"}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deployment</DialogTitle>
|
||||
<DialogDescription className="flex items-center gap-2">
|
||||
<span>
|
||||
See all the details of this deployment |{" "}
|
||||
<Badge variant="blank" className="text-xs">
|
||||
{filteredLogs.length} lines
|
||||
</Badge>
|
||||
</span>
|
||||
{serverId && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="show-extra-logs"
|
||||
checked={showExtraLogs}
|
||||
onCheckedChange={(checked) =>
|
||||
setShowExtraLogs(checked as boolean)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="show-extra-logs"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Show Extra Logs
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
|
||||
>
|
||||
{filteredLogs.length > 0 ? (
|
||||
filteredLogs.map((log: LogLine, index: number) => (
|
||||
<TerminalLine key={index} log={log} noTimestamp />
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
{optionalErrors.length > 0 ? (
|
||||
optionalErrors.map((log: LogLine, index: number) => (
|
||||
<TerminalLine key={`extra-${index}`} log={log} noTimestamp />
|
||||
))
|
||||
) : (
|
||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,125 +0,0 @@
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { type RouterOutputs, api } from "@/utils/api";
|
||||
import { RocketIcon } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { CancelQueuesCompose } from "./cancel-queues-compose";
|
||||
import { RefreshTokenCompose } from "./refresh-token-compose";
|
||||
import { ShowDeploymentCompose } from "./show-deployment-compose";
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
export const ShowDeploymentsCompose = ({ composeId }: Props) => {
|
||||
const [activeLog, setActiveLog] = useState<
|
||||
RouterOutputs["deployment"]["all"][number] | null
|
||||
>(null);
|
||||
const { data } = api.compose.one.useQuery({ composeId });
|
||||
const { data: deployments } = api.deployment.allByCompose.useQuery(
|
||||
{ composeId },
|
||||
{
|
||||
enabled: !!composeId,
|
||||
refetchInterval: 5000,
|
||||
},
|
||||
);
|
||||
const [url, setUrl] = React.useState("");
|
||||
useEffect(() => {
|
||||
setUrl(document.location.origin);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<CardTitle className="text-xl">Deployments</CardTitle>
|
||||
<CardDescription>
|
||||
See all the 10 last deployments for this compose
|
||||
</CardDescription>
|
||||
</div>
|
||||
<CancelQueuesCompose composeId={composeId} />
|
||||
{/* <CancelQueues applicationId={applicationId} /> */}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<span>
|
||||
If you want to re-deploy this application use this URL in the config
|
||||
of your git provider or docker
|
||||
</span>
|
||||
<div className="flex flex-row items-center gap-2 flex-wrap">
|
||||
<span>Webhook URL: </span>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span className="text-muted-foreground">
|
||||
{`${url}/api/deploy/compose/${data?.refreshToken}`}
|
||||
</span>
|
||||
<RefreshTokenCompose composeId={composeId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{data?.deployments?.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
<RocketIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No deployments found
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{deployments?.map((deployment) => (
|
||||
<div
|
||||
key={deployment.deploymentId}
|
||||
className="flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||
{deployment.status}
|
||||
|
||||
<StatusTooltip
|
||||
status={deployment?.status}
|
||||
className="size-2.5"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{deployment.title}
|
||||
</span>
|
||||
{deployment.description && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{deployment.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="text-sm capitalize text-muted-foreground">
|
||||
<DateTooltip date={deployment.createdAt} />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLog(deployment);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ShowDeploymentCompose
|
||||
serverId={data?.serverId || ""}
|
||||
open={Boolean(activeLog && activeLog.logPath !== null)}
|
||||
onClose={() => setActiveLog(null)}
|
||||
logPath={activeLog?.logPath || ""}
|
||||
errorMessage={activeLog?.errorMessage || ""}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -1,503 +0,0 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input, NumberInput } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { domainCompose } from "@/server/db/validations/domain";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
|
||||
import type z from "zod";
|
||||
import Link from "next/link";
|
||||
|
||||
type Domain = z.infer<typeof domainCompose>;
|
||||
|
||||
export type CacheType = "fetch" | "cache";
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
domainId?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AddDomainCompose = ({
|
||||
composeId,
|
||||
domainId = "",
|
||||
children,
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||
const utils = api.useUtils();
|
||||
const { data, refetch } = api.domain.one.useQuery(
|
||||
{
|
||||
domainId,
|
||||
},
|
||||
{
|
||||
enabled: !!domainId,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: compose } = api.compose.one.useQuery(
|
||||
{
|
||||
composeId,
|
||||
},
|
||||
{
|
||||
enabled: !!composeId,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: services,
|
||||
isFetching: isLoadingServices,
|
||||
error: errorServices,
|
||||
refetch: refetchServices,
|
||||
} = api.compose.loadServices.useQuery(
|
||||
{
|
||||
composeId,
|
||||
type: cacheType,
|
||||
},
|
||||
{
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
|
||||
api.domain.generateDomain.useMutation();
|
||||
|
||||
const { mutateAsync, isError, error, isLoading } = domainId
|
||||
? api.domain.update.useMutation()
|
||||
: api.domain.create.useMutation();
|
||||
|
||||
const { data: canGenerateTraefikMeDomains } =
|
||||
api.domain.canGenerateTraefikMeDomains.useQuery({
|
||||
serverId: compose?.serverId || "",
|
||||
});
|
||||
|
||||
const form = useForm<Domain>({
|
||||
resolver: zodResolver(domainCompose),
|
||||
defaultValues: {
|
||||
host: "",
|
||||
path: undefined,
|
||||
port: undefined,
|
||||
https: false,
|
||||
certificateType: undefined,
|
||||
customCertResolver: undefined,
|
||||
serviceName: "",
|
||||
},
|
||||
});
|
||||
|
||||
const https = form.watch("https");
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
...data,
|
||||
/* Convert null to undefined */
|
||||
path: data?.path || undefined,
|
||||
port: data?.port || undefined,
|
||||
serviceName: data?.serviceName || undefined,
|
||||
certificateType: data?.certificateType || undefined,
|
||||
customCertResolver: data?.customCertResolver || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (!domainId) {
|
||||
form.reset({
|
||||
host: "",
|
||||
path: undefined,
|
||||
port: undefined,
|
||||
https: false,
|
||||
certificateType: undefined,
|
||||
customCertResolver: undefined,
|
||||
serviceName: "",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data, isLoading]);
|
||||
|
||||
const dictionary = {
|
||||
success: domainId ? "Domain Updated" : "Domain Created",
|
||||
error: domainId ? "Error updating the domain" : "Error creating the domain",
|
||||
submit: domainId ? "Update" : "Create",
|
||||
dialogDescription: domainId
|
||||
? "In this section you can edit a domain"
|
||||
: "In this section you can add domains",
|
||||
};
|
||||
|
||||
const onSubmit = async (data: Domain) => {
|
||||
await mutateAsync({
|
||||
domainId,
|
||||
composeId,
|
||||
domainType: "compose",
|
||||
...data,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.domain.byComposeId.invalidate({
|
||||
composeId,
|
||||
});
|
||||
toast.success(dictionary.success);
|
||||
if (domainId) {
|
||||
refetch();
|
||||
}
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(dictionary.error);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger className="" asChild>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Domain</DialogTitle>
|
||||
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<AlertBlock type="info">
|
||||
Deploy is required to apply changes after creating or updating a
|
||||
domain.
|
||||
</AlertBlock>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-8 "
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
{errorServices && (
|
||||
<AlertBlock
|
||||
type="warning"
|
||||
className="[overflow-wrap:anywhere]"
|
||||
>
|
||||
{errorServices?.message}
|
||||
</AlertBlock>
|
||||
)}
|
||||
<div className="flex flex-row items-end w-full gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serviceName"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Service Name</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a service name" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
||||
<SelectContent>
|
||||
{services?.map((service, index) => (
|
||||
<SelectItem
|
||||
value={service}
|
||||
key={`${service}-${index}`}
|
||||
>
|
||||
{service}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="none" disabled>
|
||||
Empty
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
isLoading={isLoadingServices}
|
||||
onClick={() => {
|
||||
if (cacheType === "fetch") {
|
||||
refetchServices();
|
||||
} else {
|
||||
setCacheType("fetch");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
className="max-w-[10rem]"
|
||||
>
|
||||
<p>
|
||||
Fetch: Will clone the repository and load the
|
||||
services
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
isLoading={isLoadingServices}
|
||||
onClick={() => {
|
||||
if (cacheType === "cache") {
|
||||
refetchServices();
|
||||
} else {
|
||||
setCacheType("cache");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
className="max-w-[10rem]"
|
||||
>
|
||||
<p>
|
||||
Cache: If you previously deployed this
|
||||
compose, it will read the services from the
|
||||
last deployment/fetch from the repository
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="host"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
{!canGenerateTraefikMeDomains &&
|
||||
field.value.includes("traefik.me") && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{compose?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to make your traefik.me domain work.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<FormLabel>Host</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input placeholder="api.dokploy.com" {...field} />
|
||||
</FormControl>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
isLoading={isLoadingGenerate}
|
||||
onClick={() => {
|
||||
generateDomain({
|
||||
serverId: compose?.serverId || "",
|
||||
appName: compose?.appName || "",
|
||||
})
|
||||
.then((domain) => {
|
||||
field.onChange(domain);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Dices className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
className="max-w-[10rem]"
|
||||
>
|
||||
<p>Generate traefik.me domain</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="path"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"/"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Container Port</FormLabel>
|
||||
<FormControl>
|
||||
<NumberInput placeholder={"3000"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="https"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>HTTPS</FormLabel>
|
||||
<FormDescription>
|
||||
Automatically provision SSL Certificate.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{https && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="certificateType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Certificate Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value || ""}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a certificate provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
Let's Encrypt
|
||||
</SelectItem>
|
||||
<SelectItem value={"custom"}>Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.getValues().certificateType === "custom" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="customCertResolver"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Custom Certificate Resolver</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter your custom certificate resolver"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={form.formState.isSubmitting}
|
||||
form="hook-form"
|
||||
type="submit"
|
||||
>
|
||||
{dictionary.submit}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,150 +0,0 @@
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { AddDomainCompose } from "./add-domain";
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
export const ShowDomainsCompose = ({ composeId }: Props) => {
|
||||
const { data, refetch } = api.domain.byComposeId.useQuery(
|
||||
{
|
||||
composeId,
|
||||
},
|
||||
{
|
||||
enabled: !!composeId,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
||||
api.domain.delete.useMutation();
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Domains</CardTitle>
|
||||
<CardDescription>
|
||||
Domains are used to access to the application
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
{data && data?.length > 0 && (
|
||||
<AddDomainCompose composeId={composeId}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
</AddDomainCompose>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-row gap-4">
|
||||
{data?.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3">
|
||||
<GlobeIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
To access to the application it is required to set at least 1
|
||||
domain
|
||||
</span>
|
||||
<div className="flex flex-row gap-4 flex-wrap">
|
||||
<AddDomainCompose composeId={composeId}>
|
||||
<Button>
|
||||
<GlobeIcon className="size-4" /> Add Domain
|
||||
</Button>
|
||||
</AddDomainCompose>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{data?.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={item.domainId}
|
||||
className="flex w-full items-center justify-between gap-4 border p-4 md:px-6 rounded-lg flex-wrap"
|
||||
>
|
||||
<div className="md:basis-1/2 flex gap-6 w-full items-center">
|
||||
<span className="opacity-50 text-center font-medium text-sm whitespace-nowrap">
|
||||
{item.serviceName}
|
||||
</span>
|
||||
|
||||
<Link
|
||||
className="flex gap-2 items-center hover:underline transition-all w-full max-w-[calc(100%-4rem)]"
|
||||
target="_blank"
|
||||
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
||||
>
|
||||
<span className="truncate text-sm">{item.host}</span>
|
||||
<ExternalLink className="size-4 min-w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-8">
|
||||
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
|
||||
<span>{item.path}</span>
|
||||
<span>{item.port}</span>
|
||||
<span>{item.https ? "HTTPS" : "HTTP"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<AddDomainCompose
|
||||
composeId={composeId}
|
||||
domainId={item.domainId}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 "
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</AddDomainCompose>
|
||||
<DialogAction
|
||||
title="Delete Domain"
|
||||
description="Are you sure you want to delete this domain?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteDomain({
|
||||
domainId: item.domainId,
|
||||
})
|
||||
.then((_data) => {
|
||||
refetch();
|
||||
toast.success("Domain deleted successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting domain");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -79,6 +79,22 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
toast.error("Error updating the Compose config");
|
||||
});
|
||||
};
|
||||
|
||||
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
||||
e.preventDefault();
|
||||
form.handleSubmit(onSubmit)();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [form, onSubmit, isLoading]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex flex-col gap-4 ">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -39,13 +42,11 @@ import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BitbucketIcon } from "@/components/icons/data-tools-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
const BitbucketProviderSchema = z.object({
|
||||
composePath: z.string().min(1),
|
||||
@@ -58,6 +59,7 @@ const BitbucketProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
|
||||
@@ -84,6 +86,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
bitbucketId: "",
|
||||
branch: "",
|
||||
watchPaths: [],
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(BitbucketProviderSchema),
|
||||
});
|
||||
@@ -130,9 +133,10 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
composePath: data.composePath,
|
||||
bitbucketId: data.bitbucketId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
}, [form.reset, data?.composeId, form]);
|
||||
|
||||
const onSubmit = async (data: BitbucketProvider) => {
|
||||
await mutateAsync({
|
||||
@@ -145,6 +149,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
sourceType: "bitbucket",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -432,7 +437,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -451,7 +456,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
||||
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
@@ -469,6 +474,21 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GitIcon } from "@/components/icons/data-tools-icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -27,13 +29,12 @@ import {
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { GitIcon } from "@/components/icons/data-tools-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
const GitProviderSchema = z.object({
|
||||
composePath: z.string().min(1),
|
||||
@@ -43,6 +44,7 @@ const GitProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch required"),
|
||||
sshKey: z.string().optional(),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type GitProvider = z.infer<typeof GitProviderSchema>;
|
||||
@@ -65,6 +67,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
composePath: "./docker-compose.yml",
|
||||
sshKey: undefined,
|
||||
watchPaths: [],
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(GitProviderSchema),
|
||||
});
|
||||
@@ -77,6 +80,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
repositoryURL: data.customGitUrl || "",
|
||||
composePath: data.composePath,
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -91,6 +95,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
composePath: values.composePath,
|
||||
composeStatus: "idle",
|
||||
watchPaths: values.watchPaths || [],
|
||||
enableSubmodules: values.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Git Provider Saved");
|
||||
@@ -258,7 +263,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -277,7 +282,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
||||
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
@@ -295,6 +300,21 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end">
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
import { GiteaIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import type { Repository } from "@/utils/gitea-utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const GiteaProviderSchema = z.object({
|
||||
composePath: z.string().min(1),
|
||||
repository: z
|
||||
.object({
|
||||
repo: z.string().min(1, "Repo is required"),
|
||||
owner: z.string().min(1, "Owner is required"),
|
||||
})
|
||||
.required(),
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
|
||||
const { data, refetch } = api.compose.one.useQuery({ composeId });
|
||||
const { mutateAsync, isLoading: isSavingGiteaProvider } =
|
||||
api.compose.update.useMutation();
|
||||
|
||||
const form = useForm<GiteaProvider>({
|
||||
defaultValues: {
|
||||
composePath: "./docker-compose.yml",
|
||||
repository: {
|
||||
owner: "",
|
||||
repo: "",
|
||||
},
|
||||
giteaId: "",
|
||||
branch: "",
|
||||
watchPaths: [],
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(GiteaProviderSchema),
|
||||
});
|
||||
|
||||
const repository = form.watch("repository");
|
||||
const giteaId = form.watch("giteaId");
|
||||
|
||||
const { data: giteaUrl } = api.gitea.getGiteaUrl.useQuery(
|
||||
{ giteaId },
|
||||
{
|
||||
enabled: !!giteaId,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: repositories,
|
||||
isLoading: isLoadingRepositories,
|
||||
error,
|
||||
} = api.gitea.getGiteaRepositories.useQuery<Repository[]>(
|
||||
{
|
||||
giteaId,
|
||||
},
|
||||
{
|
||||
enabled: !!giteaId,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: branches,
|
||||
fetchStatus,
|
||||
status,
|
||||
} = api.gitea.getGiteaBranches.useQuery(
|
||||
{
|
||||
owner: repository?.owner,
|
||||
repositoryName: repository?.repo,
|
||||
giteaId: giteaId,
|
||||
},
|
||||
{
|
||||
enabled: !!repository?.owner && !!repository?.repo && !!giteaId,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
branch: data.giteaBranch || "",
|
||||
repository: {
|
||||
repo: data.giteaRepository || "",
|
||||
owner: data.giteaOwner || "",
|
||||
},
|
||||
composePath: data.composePath || "./docker-compose.yml",
|
||||
giteaId: data.giteaId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data?.composeId, form]);
|
||||
|
||||
const onSubmit = async (data: GiteaProvider) => {
|
||||
await mutateAsync({
|
||||
giteaBranch: data.branch,
|
||||
giteaRepository: data.repository.repo,
|
||||
giteaOwner: data.repository.owner,
|
||||
composePath: data.composePath,
|
||||
giteaId: data.giteaId,
|
||||
composeId,
|
||||
sourceType: "gitea",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
} as any)
|
||||
.then(async () => {
|
||||
toast.success("Service Provider Saved");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the Gitea provider");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4 py-3"
|
||||
>
|
||||
{error && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="giteaId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2 flex flex-col">
|
||||
<FormLabel>Gitea Account</FormLabel>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
form.setValue("repository", {
|
||||
owner: "",
|
||||
repo: "",
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a Gitea Account" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{giteaProviders?.map((giteaProvider) => (
|
||||
<SelectItem
|
||||
key={giteaProvider.giteaId}
|
||||
value={giteaProvider.giteaId}
|
||||
>
|
||||
{giteaProvider.gitProvider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="repository"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
{field.value.owner && field.value.repo && (
|
||||
<Link
|
||||
href={`${giteaUrl}/${field.value.owner}/${field.value.repo}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<GiteaIcon className="h-4 w-4" />
|
||||
<span>View Repository</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoadingRepositories
|
||||
? "Loading...."
|
||||
: field.value.owner
|
||||
? repositories?.find(
|
||||
(repo) => repo.name === field.value.repo,
|
||||
)?.name
|
||||
: "Select repository"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search repository..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoadingRepositories && (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Repositories....
|
||||
</span>
|
||||
)}
|
||||
<CommandEmpty>No repositories found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
{repositories?.map((repo) => (
|
||||
<CommandItem
|
||||
key={repo.url}
|
||||
value={repo.name}
|
||||
onSelect={() => {
|
||||
form.setValue("repository", {
|
||||
owner: repo.owner.username,
|
||||
repo: repo.name,
|
||||
});
|
||||
form.setValue("branch", "");
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{repo.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{repo.owner.username}
|
||||
</span>
|
||||
</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
repo.name === field.value.repo
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{form.formState.errors.repository && (
|
||||
<p className={cn("text-sm font-medium text-destructive")}>
|
||||
Repository is required
|
||||
</p>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="branch"
|
||||
render={({ field }) => (
|
||||
<FormItem className="block w-full">
|
||||
<FormLabel>Branch</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status === "loading" && fetchStatus === "fetching"
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? branches?.find(
|
||||
(branch) => branch.name === field.value,
|
||||
)?.name
|
||||
: "Select branch"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search branches..."
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandEmpty>No branches found.</CommandEmpty>
|
||||
<ScrollArea className="h-96">
|
||||
<CommandGroup>
|
||||
{branches?.map((branch) => (
|
||||
<CommandItem
|
||||
key={branch.name}
|
||||
value={branch.name}
|
||||
onSelect={() =>
|
||||
form.setValue("branch", branch.name)
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{branch.name}
|
||||
</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
branch.name === field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{form.formState.errors.branch && (
|
||||
<p className={cn("text-sm font-medium text-destructive")}>
|
||||
Branch is required
|
||||
</p>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="composePath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Compose Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="docker-compose.yml" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Add paths to watch for changes. When files in these
|
||||
paths change, a new deployment will be triggered.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder*="Enter a path"]',
|
||||
) as HTMLInputElement;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...(field.value || []), path]);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isSavingGiteaProvider}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -38,13 +40,12 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
const GithubProviderSchema = z.object({
|
||||
composePath: z.string().min(1),
|
||||
@@ -57,6 +58,8 @@ const GithubProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
githubId: z.string().min(1, "Github Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type GithubProvider = z.infer<typeof GithubProviderSchema>;
|
||||
@@ -82,13 +85,15 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
githubId: "",
|
||||
branch: "",
|
||||
watchPaths: [],
|
||||
triggerType: "push",
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(GithubProviderSchema),
|
||||
});
|
||||
|
||||
const repository = form.watch("repository");
|
||||
const githubId = form.watch("githubId");
|
||||
|
||||
const triggerType = form.watch("triggerType");
|
||||
const { data: repositories, isLoading: isLoadingRepositories } =
|
||||
api.github.getGithubRepositories.useQuery(
|
||||
{
|
||||
@@ -125,9 +130,11 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
composePath: data.composePath,
|
||||
githubId: data.githubId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
triggerType: data.triggerType || "push",
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
}, [form.reset, data?.composeId, form]);
|
||||
|
||||
const onSubmit = async (data: GithubProvider) => {
|
||||
await mutateAsync({
|
||||
@@ -140,6 +147,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
sourceType: "github",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
triggerType: data.triggerType,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -384,79 +393,140 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
name="triggerType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<FormLabel>Trigger Type</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>
|
||||
Add paths to watch for changes. When files in these
|
||||
paths change, a new deployment will be triggered.
|
||||
Choose when to trigger deployments: on push to the
|
||||
selected branch or when a new tag is created.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a trigger type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="push">On Push</SelectItem>
|
||||
<SelectItem value="tag">On Tag</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{triggerType === "push" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Add paths to watch for changes. When files in
|
||||
these paths change, a new deployment will be
|
||||
triggered.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{field.value?.map((path, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{path}
|
||||
<X
|
||||
className="ml-1 size-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
const newPaths = [...(field.value || [])];
|
||||
newPaths.splice(index, 1);
|
||||
form.setValue("watchPaths", newPaths);
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [
|
||||
...(field.value || []),
|
||||
value,
|
||||
];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -39,13 +42,11 @@ import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { GitlabIcon } from "@/components/icons/data-tools-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
const GitlabProviderSchema = z.object({
|
||||
composePath: z.string().min(1),
|
||||
@@ -60,6 +61,7 @@ const GitlabProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
|
||||
@@ -87,6 +89,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
gitlabId: "",
|
||||
branch: "",
|
||||
watchPaths: [],
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(GitlabProviderSchema),
|
||||
});
|
||||
@@ -136,9 +139,10 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
composePath: data.composePath,
|
||||
gitlabId: data.gitlabId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
}, [form.reset, data?.composeId, form]);
|
||||
|
||||
const onSubmit = async (data: GitlabProvider) => {
|
||||
await mutateAsync({
|
||||
@@ -153,6 +157,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
sourceType: "gitlab",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -448,7 +453,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<FormControl>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -467,7 +472,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
||||
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
@@ -485,6 +490,21 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableSubmodules"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
|
||||
@@ -1,35 +1,73 @@
|
||||
import {
|
||||
BitbucketIcon,
|
||||
GitIcon,
|
||||
GiteaIcon,
|
||||
GithubIcon,
|
||||
GitlabIcon,
|
||||
} from "@/components/icons/data-tools-icons";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/utils/api";
|
||||
import { CodeIcon, GitBranch } from "lucide-react";
|
||||
import { CodeIcon, GitBranch, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { ComposeFileEditor } from "../compose-file-editor";
|
||||
import { ShowConvertedCompose } from "../show-converted-compose";
|
||||
import { SaveBitbucketProviderCompose } from "./save-bitbucket-provider-compose";
|
||||
import { SaveGitProviderCompose } from "./save-git-provider-compose";
|
||||
import { SaveGiteaProviderCompose } from "./save-gitea-provider-compose";
|
||||
import { SaveGithubProviderCompose } from "./save-github-provider-compose";
|
||||
import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose";
|
||||
|
||||
type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket";
|
||||
type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket" | "gitea";
|
||||
interface Props {
|
||||
composeId: string;
|
||||
}
|
||||
|
||||
export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
||||
const { data: githubProviders } = api.github.githubProviders.useQuery();
|
||||
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
|
||||
const { data: bitbucketProviders } =
|
||||
const { data: githubProviders, isLoading: isLoadingGithub } =
|
||||
api.github.githubProviders.useQuery();
|
||||
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
|
||||
api.gitlab.gitlabProviders.useQuery();
|
||||
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
|
||||
api.bitbucket.bitbucketProviders.useQuery();
|
||||
const { data: giteaProviders, isLoading: isLoadingGitea } =
|
||||
api.gitea.giteaProviders.useQuery();
|
||||
|
||||
const { data: compose } = api.compose.one.useQuery({ composeId });
|
||||
const [tab, setSab] = useState<TabState>(compose?.sourceType || "github");
|
||||
|
||||
const isLoading =
|
||||
isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="group relative w-full bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-start justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="flex flex-col space-y-0.5">Provider</span>
|
||||
<p className="flex items-center text-sm font-normal text-muted-foreground">
|
||||
Select the source of your code
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden space-y-1 text-sm font-normal md:block">
|
||||
<GitBranch className="size-6 text-muted-foreground" />
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex min-h-[25vh] items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>Loading providers...</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="group relative w-full bg-transparent">
|
||||
<CardHeader>
|
||||
@@ -54,21 +92,21 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
||||
setSab(e as TabState);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
||||
<TabsList className="md:grid md:w-fit md:grid-cols-5 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
||||
<TabsList className="md:grid md:w-fit md:grid-cols-6 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
|
||||
<TabsTrigger
|
||||
value="github"
|
||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
>
|
||||
<GithubIcon className="size-4 text-current fill-current" />
|
||||
Github
|
||||
GitHub
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="gitlab"
|
||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
>
|
||||
<GitlabIcon className="size-4 text-current fill-current" />
|
||||
Gitlab
|
||||
GitLab
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="bitbucket"
|
||||
@@ -77,7 +115,12 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
||||
<BitbucketIcon className="size-4 text-current fill-current" />
|
||||
Bitbucket
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
value="gitea"
|
||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
>
|
||||
<GiteaIcon className="size-4 text-current fill-current" /> Gitea
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="git"
|
||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
@@ -89,16 +132,17 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
||||
value="raw"
|
||||
className="rounded-none border-b-2 gap-2 border-b-transparent data-[state=active]:border-b-2 data-[state=active]:border-b-border"
|
||||
>
|
||||
<CodeIcon className="size-4 " />
|
||||
<CodeIcon className="size-4" />
|
||||
Raw
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="github" className="w-full p-2">
|
||||
{githubProviders && githubProviders?.length > 0 ? (
|
||||
<SaveGithubProviderCompose composeId={composeId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<GithubIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
To deploy using GitHub, you need to configure your account
|
||||
@@ -118,7 +162,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
||||
{gitlabProviders && gitlabProviders?.length > 0 ? (
|
||||
<SaveGitlabProviderCompose composeId={composeId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<GitlabIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
To deploy using GitLab, you need to configure your account
|
||||
@@ -138,7 +182,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
||||
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
|
||||
<SaveBitbucketProviderCompose composeId={composeId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<BitbucketIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
To deploy using Bitbucket, you need to configure your account
|
||||
@@ -154,6 +198,26 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="gitea" className="w-full p-2">
|
||||
{giteaProviders && giteaProviders?.length > 0 ? (
|
||||
<SaveGiteaProviderCompose composeId={composeId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<GiteaIcon className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
To deploy using Gitea, you need to configure your account
|
||||
first. Please, go to{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/git-providers"
|
||||
className="text-foreground"
|
||||
>
|
||||
Settings
|
||||
</Link>{" "}
|
||||
to do so.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="git" className="w-full p-2">
|
||||
<SaveGitProviderCompose composeId={composeId} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -71,8 +71,8 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
|
||||
isolatedDeployment: formData?.isolatedDeployment || false,
|
||||
})
|
||||
.then(async (_data) => {
|
||||
randomizeCompose();
|
||||
refetch();
|
||||
await randomizeCompose();
|
||||
await refetch();
|
||||
toast.success("Compose updated");
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -84,15 +84,10 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
suffix: data?.appName || "",
|
||||
})
|
||||
.then(async (data) => {
|
||||
await utils.project.all.invalidate();
|
||||
setCompose(data);
|
||||
toast.success("Compose Isolated");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error isolating the compose");
|
||||
});
|
||||
}).then(async (data) => {
|
||||
await utils.project.all.invalidate();
|
||||
setCompose(data);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -147,7 +142,9 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4 flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Enable Isolated Deployment ({data?.appName})</FormLabel>
|
||||
<FormLabel>
|
||||
Enable Isolated Deployment ({data?.appName})
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Enable isolated deployment to the compose file.
|
||||
</FormDescription>
|
||||
|
||||
@@ -77,8 +77,8 @@ export const RandomizeCompose = ({ composeId }: Props) => {
|
||||
randomize: formData?.randomize || false,
|
||||
})
|
||||
.then(async (_data) => {
|
||||
randomizeCompose();
|
||||
refetch();
|
||||
await randomizeCompose();
|
||||
await refetch();
|
||||
toast.success("Compose updated");
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -90,15 +90,10 @@ export const RandomizeCompose = ({ composeId }: Props) => {
|
||||
await mutateAsync({
|
||||
composeId,
|
||||
suffix,
|
||||
})
|
||||
.then(async (data) => {
|
||||
await utils.project.all.invalidate();
|
||||
setCompose(data);
|
||||
toast.success("Compose randomized");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error randomizing the compose");
|
||||
});
|
||||
}).then(async (data) => {
|
||||
await utils.project.all.invalidate();
|
||||
setCompose(data);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
import { Puzzle, RefreshCw } from "lucide-react";
|
||||
import { Loader2, Puzzle, RefreshCw } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -62,35 +62,54 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<div className="flex flex-row gap-2 justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isLoading}
|
||||
onClick={() => {
|
||||
mutateAsync({ composeId })
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Fetched source type");
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error("Error fetching source type", {
|
||||
description: err.message,
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
Refresh <RefreshCw className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<AlertBlock type="info">
|
||||
Preview your docker-compose file with added domains. Note: At least
|
||||
one domain must be specified for this conversion to take effect.
|
||||
</AlertBlock>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row items-center justify-center min-h-[25rem] border p-4 rounded-md">
|
||||
<Loader2 className="h-8 w-8 text-muted-foreground mb-2 animate-spin" />
|
||||
</div>
|
||||
) : compose?.length === 5 ? (
|
||||
<div className="border p-4 rounded-md flex flex-col items-center justify-center min-h-[25rem]">
|
||||
<Puzzle className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<span className="text-muted-foreground">
|
||||
No converted compose data available.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-row gap-2 justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
isLoading={isLoading}
|
||||
onClick={() => {
|
||||
mutateAsync({ composeId })
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Fetched source type");
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error("Error fetching source type", {
|
||||
description: err.message,
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
Refresh <RefreshCw className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<pre>
|
||||
<CodeEditor
|
||||
value={compose || ""}
|
||||
language="yaml"
|
||||
readOnly
|
||||
height="50rem"
|
||||
/>
|
||||
</pre>
|
||||
<pre>
|
||||
<CodeEditor
|
||||
value={compose || ""}
|
||||
language="yaml"
|
||||
readOnly
|
||||
height="50rem"
|
||||
/>
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,334 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const AddPostgresBackup1Schema = z.object({
|
||||
destinationId: z.string().min(1, "Destination required"),
|
||||
schedule: z.string().min(1, "Schedule (Cron) required"),
|
||||
// .regex(
|
||||
// new RegExp(
|
||||
// /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/,
|
||||
// ),
|
||||
// "Invalid Cron",
|
||||
// ),
|
||||
prefix: z.string().min(1, "Prefix required"),
|
||||
enabled: z.boolean(),
|
||||
database: z.string().min(1, "Database required"),
|
||||
keepLatestCount: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>;
|
||||
|
||||
interface Props {
|
||||
databaseId: string;
|
||||
databaseType: "postgres" | "mariadb" | "mysql" | "mongo";
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
|
||||
const { data, isLoading } = api.destination.all.useQuery();
|
||||
|
||||
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
|
||||
api.backup.create.useMutation();
|
||||
|
||||
const form = useForm<AddPostgresBackup>({
|
||||
defaultValues: {
|
||||
database: "",
|
||||
destinationId: "",
|
||||
enabled: true,
|
||||
prefix: "/",
|
||||
schedule: "",
|
||||
keepLatestCount: undefined,
|
||||
},
|
||||
resolver: zodResolver(AddPostgresBackup1Schema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
database: "",
|
||||
destinationId: "",
|
||||
enabled: true,
|
||||
prefix: "/",
|
||||
schedule: "",
|
||||
keepLatestCount: undefined,
|
||||
});
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
||||
|
||||
const onSubmit = async (data: AddPostgresBackup) => {
|
||||
const getDatabaseId =
|
||||
databaseType === "postgres"
|
||||
? {
|
||||
postgresId: databaseId,
|
||||
}
|
||||
: databaseType === "mariadb"
|
||||
? {
|
||||
mariadbId: databaseId,
|
||||
}
|
||||
: databaseType === "mysql"
|
||||
? {
|
||||
mysqlId: databaseId,
|
||||
}
|
||||
: databaseType === "mongo"
|
||||
? {
|
||||
mongoId: databaseId,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await createBackup({
|
||||
destinationId: data.destinationId,
|
||||
prefix: data.prefix,
|
||||
schedule: data.schedule,
|
||||
enabled: data.enabled,
|
||||
database: data.database,
|
||||
keepLatestCount: data.keepLatestCount,
|
||||
databaseType,
|
||||
...getDatabaseId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Backup Created");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error creating a backup");
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Create Backup
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg max-h-screen overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a backup</DialogTitle>
|
||||
<DialogDescription>Add a new backup</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-add-backup"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destinationId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="">
|
||||
<FormLabel>Destination</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoading
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? data?.find(
|
||||
(destination) =>
|
||||
destination.destinationId === field.value,
|
||||
)?.name
|
||||
: "Select Destination"}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search Destination..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoading && (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Destinations....
|
||||
</span>
|
||||
)}
|
||||
<CommandEmpty>No destinations found.</CommandEmpty>
|
||||
<ScrollArea className="h-64">
|
||||
<CommandGroup>
|
||||
{data?.map((destination) => (
|
||||
<CommandItem
|
||||
value={destination.destinationId}
|
||||
key={destination.destinationId}
|
||||
onSelect={() => {
|
||||
form.setValue(
|
||||
"destinationId",
|
||||
destination.destinationId,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{destination.name}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
destination.destinationId === field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="database"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Database</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"dokploy"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="schedule"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Schedule (Cron)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"0 0 * * *"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="prefix"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Prefix Destination</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"dokploy/"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use if you want to back up in a specific path of your
|
||||
destination/bucket
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepLatestCount"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Keep the latest</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder={"keeps all the backups if left empty"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional. If provided, only keeps the latest N backups in the cloud.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Enabled</FormLabel>
|
||||
<FormDescription>
|
||||
Enable or disable the backup
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isCreatingPostgresBackup}
|
||||
form="hook-form-add-backup"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,828 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
DatabaseZap,
|
||||
Info,
|
||||
PenBoxIcon,
|
||||
PlusIcon,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { commonCronExpressions } from "../../application/schedules/handle-schedules";
|
||||
|
||||
type CacheType = "cache" | "fetch";
|
||||
|
||||
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
|
||||
|
||||
const Schema = z
|
||||
.object({
|
||||
destinationId: z.string().min(1, "Destination required"),
|
||||
schedule: z.string().min(1, "Schedule (Cron) required"),
|
||||
prefix: z.string().min(1, "Prefix required"),
|
||||
enabled: z.boolean(),
|
||||
database: z.string().min(1, "Database required"),
|
||||
keepLatestCount: z.coerce.number().optional(),
|
||||
serviceName: z.string().nullable(),
|
||||
databaseType: z
|
||||
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
|
||||
.optional(),
|
||||
backupType: z.enum(["database", "compose"]),
|
||||
metadata: z
|
||||
.object({
|
||||
postgres: z
|
||||
.object({
|
||||
databaseUser: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
mariadb: z
|
||||
.object({
|
||||
databaseUser: z.string(),
|
||||
databasePassword: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
mongo: z
|
||||
.object({
|
||||
databaseUser: z.string(),
|
||||
databasePassword: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
mysql: z
|
||||
.object({
|
||||
databaseRootPassword: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.backupType === "compose" && !data.databaseType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Database type is required for compose backups",
|
||||
path: ["databaseType"],
|
||||
});
|
||||
}
|
||||
|
||||
if (data.backupType === "compose" && !data.serviceName) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Service name is required for compose backups",
|
||||
path: ["serviceName"],
|
||||
});
|
||||
}
|
||||
|
||||
if (data.backupType === "compose" && data.databaseType) {
|
||||
if (data.databaseType === "postgres") {
|
||||
if (!data.metadata?.postgres?.databaseUser) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Database user is required for PostgreSQL",
|
||||
path: ["metadata", "postgres", "databaseUser"],
|
||||
});
|
||||
}
|
||||
} else if (data.databaseType === "mariadb") {
|
||||
if (!data.metadata?.mariadb?.databaseUser) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Database user is required for MariaDB",
|
||||
path: ["metadata", "mariadb", "databaseUser"],
|
||||
});
|
||||
}
|
||||
if (!data.metadata?.mariadb?.databasePassword) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Database password is required for MariaDB",
|
||||
path: ["metadata", "mariadb", "databasePassword"],
|
||||
});
|
||||
}
|
||||
} else if (data.databaseType === "mongo") {
|
||||
if (!data.metadata?.mongo?.databaseUser) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Database user is required for MongoDB",
|
||||
path: ["metadata", "mongo", "databaseUser"],
|
||||
});
|
||||
}
|
||||
if (!data.metadata?.mongo?.databasePassword) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Database password is required for MongoDB",
|
||||
path: ["metadata", "mongo", "databasePassword"],
|
||||
});
|
||||
}
|
||||
} else if (data.databaseType === "mysql") {
|
||||
if (!data.metadata?.mysql?.databaseRootPassword) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Root password is required for MySQL",
|
||||
path: ["metadata", "mysql", "databaseRootPassword"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
backupId?: string;
|
||||
databaseType?: DatabaseType;
|
||||
refetch: () => void;
|
||||
backupType: "database" | "compose";
|
||||
}
|
||||
|
||||
export const HandleBackup = ({
|
||||
id,
|
||||
backupId,
|
||||
databaseType = "postgres",
|
||||
refetch,
|
||||
backupType = "database",
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data, isLoading } = api.destination.all.useQuery();
|
||||
const { data: backup } = api.backup.one.useQuery(
|
||||
{
|
||||
backupId: backupId ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!backupId,
|
||||
},
|
||||
);
|
||||
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
|
||||
backupId
|
||||
? api.backup.update.useMutation()
|
||||
: api.backup.create.useMutation();
|
||||
|
||||
const form = useForm<z.infer<typeof Schema>>({
|
||||
defaultValues: {
|
||||
database: databaseType === "web-server" ? "dokploy" : "",
|
||||
destinationId: "",
|
||||
enabled: true,
|
||||
prefix: "/",
|
||||
schedule: "",
|
||||
keepLatestCount: undefined,
|
||||
serviceName: null,
|
||||
databaseType: backupType === "compose" ? undefined : databaseType,
|
||||
backupType: backupType,
|
||||
metadata: {},
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
|
||||
const {
|
||||
data: services,
|
||||
isFetching: isLoadingServices,
|
||||
error: errorServices,
|
||||
refetch: refetchServices,
|
||||
} = api.compose.loadServices.useQuery(
|
||||
{
|
||||
composeId: backup?.composeId ?? id ?? "",
|
||||
type: cacheType,
|
||||
},
|
||||
{
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: backupType === "compose" && !!backup?.composeId && !!id,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
database: backup?.database
|
||||
? backup?.database
|
||||
: databaseType === "web-server"
|
||||
? "dokploy"
|
||||
: "",
|
||||
destinationId: backup?.destinationId ?? "",
|
||||
enabled: backup?.enabled ?? true,
|
||||
prefix: backup?.prefix ?? "/",
|
||||
schedule: backup?.schedule ?? "",
|
||||
keepLatestCount: backup?.keepLatestCount ?? undefined,
|
||||
serviceName: backup?.serviceName ?? null,
|
||||
databaseType: backup?.databaseType ?? databaseType,
|
||||
backupType: backup?.backupType ?? backupType,
|
||||
metadata: backup?.metadata ?? {},
|
||||
});
|
||||
}, [form, form.reset, backupId, backup]);
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof Schema>) => {
|
||||
const getDatabaseId =
|
||||
backupType === "compose"
|
||||
? {
|
||||
composeId: id,
|
||||
}
|
||||
: databaseType === "postgres"
|
||||
? {
|
||||
postgresId: id,
|
||||
}
|
||||
: databaseType === "mariadb"
|
||||
? {
|
||||
mariadbId: id,
|
||||
}
|
||||
: databaseType === "mysql"
|
||||
? {
|
||||
mysqlId: id,
|
||||
}
|
||||
: databaseType === "mongo"
|
||||
? {
|
||||
mongoId: id,
|
||||
}
|
||||
: databaseType === "web-server"
|
||||
? {
|
||||
userId: id,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await createBackup({
|
||||
destinationId: data.destinationId,
|
||||
prefix: data.prefix,
|
||||
schedule: data.schedule,
|
||||
enabled: data.enabled,
|
||||
database: data.database,
|
||||
keepLatestCount: data.keepLatestCount ?? null,
|
||||
databaseType: data.databaseType || databaseType,
|
||||
serviceName: data.serviceName,
|
||||
...getDatabaseId,
|
||||
backupId: backupId ?? "",
|
||||
backupType,
|
||||
metadata: data.metadata,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(`Backup ${backupId ? "Updated" : "Created"}`);
|
||||
refetch();
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(`Error ${backupId ? "updating" : "creating"} a backup`);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{backupId ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 size-8"
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{backupId ? "Update Backup" : "Create Backup"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl max-h-screen overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{backupId ? "Update Backup" : "Create Backup"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{backupId ? "Update a backup" : "Add a new backup"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-add-backup"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{errorServices && (
|
||||
<AlertBlock type="warning" className="[overflow-wrap:anywhere]">
|
||||
{errorServices?.message}
|
||||
</AlertBlock>
|
||||
)}
|
||||
{backupType === "compose" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="databaseType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database Type</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value as DatabaseType);
|
||||
form.setValue("metadata", {});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a database type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||||
<SelectItem value="mariadb">MariaDB</SelectItem>
|
||||
<SelectItem value="mysql">MySQL</SelectItem>
|
||||
<SelectItem value="mongo">MongoDB</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destinationId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="">
|
||||
<FormLabel>Destination</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoading
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? data?.find(
|
||||
(destination) =>
|
||||
destination.destinationId === field.value,
|
||||
)?.name
|
||||
: "Select Destination"}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search Destination..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoading && (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Destinations....
|
||||
</span>
|
||||
)}
|
||||
<CommandEmpty>No destinations found.</CommandEmpty>
|
||||
<ScrollArea className="h-64">
|
||||
<CommandGroup>
|
||||
{data?.map((destination) => (
|
||||
<CommandItem
|
||||
value={destination.destinationId}
|
||||
key={destination.destinationId}
|
||||
onSelect={() => {
|
||||
form.setValue(
|
||||
"destinationId",
|
||||
destination.destinationId,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{destination.name}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
destination.destinationId === field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{backupType === "compose" && (
|
||||
<div className="flex flex-row items-end w-full gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serviceName"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Service Name</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || undefined}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a service name" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
||||
<SelectContent>
|
||||
{services?.map((service, index) => (
|
||||
<SelectItem
|
||||
value={service}
|
||||
key={`${service}-${index}`}
|
||||
>
|
||||
{service}
|
||||
</SelectItem>
|
||||
))}
|
||||
{(!services || services.length === 0) && (
|
||||
<SelectItem value="none" disabled>
|
||||
Empty
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
isLoading={isLoadingServices}
|
||||
onClick={() => {
|
||||
if (cacheType === "fetch") {
|
||||
refetchServices();
|
||||
} else {
|
||||
setCacheType("fetch");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
className="max-w-[10rem]"
|
||||
>
|
||||
<p>
|
||||
Fetch: Will clone the repository and load the
|
||||
services
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
isLoading={isLoadingServices}
|
||||
onClick={() => {
|
||||
if (cacheType === "cache") {
|
||||
refetchServices();
|
||||
} else {
|
||||
setCacheType("cache");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
className="max-w-[10rem]"
|
||||
>
|
||||
<p>
|
||||
Cache: If you previously deployed this
|
||||
compose, it will read the services from the
|
||||
last deployment/fetch from the repository
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="database"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Database</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled={databaseType === "web-server"}
|
||||
placeholder={"dokploy"}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="schedule"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2">
|
||||
Schedule
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Cron expression format: minute hour day month
|
||||
weekday
|
||||
</p>
|
||||
<p>Example: 0 0 * * * (daily at midnight)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</FormLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a predefined schedule" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{commonCronExpressions.map((expr) => (
|
||||
<SelectItem key={expr.value} value={expr.value}>
|
||||
{expr.label} ({expr.value})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Custom cron expression (e.g., 0 0 * * *)"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</div>
|
||||
<FormDescription>
|
||||
Choose a predefined schedule or enter a custom cron
|
||||
expression
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="prefix"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Prefix Destination</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"dokploy/"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use if you want to back up in a specific path of your
|
||||
destination/bucket
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepLatestCount"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Keep the latest</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={"keeps all the backups if left empty"}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional. If provided, only keeps the latest N backups
|
||||
in the cloud.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Enabled</FormLabel>
|
||||
<FormDescription>
|
||||
Enable or disable the backup
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{backupType === "compose" && (
|
||||
<>
|
||||
{form.watch("databaseType") === "postgres" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metadata.postgres.databaseUser"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database User</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="postgres" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.watch("databaseType") === "mariadb" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metadata.mariadb.databaseUser"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database User</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="mariadb" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metadata.mariadb.databasePassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form.watch("databaseType") === "mongo" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metadata.mongo.databaseUser"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database User</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="mongo" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metadata.mongo.databasePassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form.watch("databaseType") === "mysql" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metadata.mysql.databaseRootPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Root Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isCreatingPostgresBackup}
|
||||
form="hook-form-add-backup"
|
||||
type="submit"
|
||||
>
|
||||
{backupId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -23,89 +25,230 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { debounce } from "lodash";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
Copy,
|
||||
DatabaseZap,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import type { ServiceType } from "../../application/advanced/show-resources";
|
||||
import { debounce } from "lodash";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
||||
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type DatabaseType =
|
||||
| Exclude<ServiceType, "application" | "redis">
|
||||
| "web-server";
|
||||
|
||||
interface Props {
|
||||
databaseId: string;
|
||||
databaseType: Exclude<ServiceType, "application" | "redis">;
|
||||
serverId: string | null;
|
||||
id: string;
|
||||
databaseType?: DatabaseType;
|
||||
serverId?: string | null;
|
||||
backupType?: "database" | "compose";
|
||||
}
|
||||
|
||||
const RestoreBackupSchema = z.object({
|
||||
destinationId: z
|
||||
.string({
|
||||
required_error: "Please select a destination",
|
||||
})
|
||||
.min(1, {
|
||||
message: "Destination is required",
|
||||
}),
|
||||
backupFile: z
|
||||
.string({
|
||||
required_error: "Please select a backup file",
|
||||
})
|
||||
.min(1, {
|
||||
message: "Backup file is required",
|
||||
}),
|
||||
databaseName: z
|
||||
.string({
|
||||
required_error: "Please enter a database name",
|
||||
})
|
||||
.min(1, {
|
||||
message: "Database name is required",
|
||||
}),
|
||||
});
|
||||
const RestoreBackupSchema = z
|
||||
.object({
|
||||
destinationId: z
|
||||
.string({
|
||||
required_error: "Please select a destination",
|
||||
})
|
||||
.min(1, {
|
||||
message: "Destination is required",
|
||||
}),
|
||||
backupFile: z
|
||||
.string({
|
||||
required_error: "Please select a backup file",
|
||||
})
|
||||
.min(1, {
|
||||
message: "Backup file is required",
|
||||
}),
|
||||
databaseName: z
|
||||
.string({
|
||||
required_error: "Please enter a database name",
|
||||
})
|
||||
.min(1, {
|
||||
message: "Database name is required",
|
||||
}),
|
||||
databaseType: z
|
||||
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
|
||||
.optional(),
|
||||
backupType: z.enum(["database", "compose"]).default("database"),
|
||||
metadata: z
|
||||
.object({
|
||||
postgres: z
|
||||
.object({
|
||||
databaseUser: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
mariadb: z
|
||||
.object({
|
||||
databaseUser: z.string(),
|
||||
databasePassword: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
mongo: z
|
||||
.object({
|
||||
databaseUser: z.string(),
|
||||
databasePassword: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
mysql: z
|
||||
.object({
|
||||
databaseRootPassword: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
serviceName: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.backupType === "compose" && !data.databaseType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Database type is required for compose backups",
|
||||
path: ["databaseType"],
|
||||
});
|
||||
}
|
||||
|
||||
type RestoreBackup = z.infer<typeof RestoreBackupSchema>;
|
||||
if (data.backupType === "compose" && !data.metadata?.serviceName) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Service name is required for compose backups",
|
||||
path: ["metadata", "serviceName"],
|
||||
});
|
||||
}
|
||||
|
||||
if (data.backupType === "compose" && data.databaseType) {
|
||||
if (data.databaseType === "postgres") {
|
||||
if (!data.metadata?.postgres?.databaseUser) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Database user is required for PostgreSQL",
|
||||
path: ["metadata", "postgres", "databaseUser"],
|
||||
});
|
||||
}
|
||||
} else if (data.databaseType === "mariadb") {
|
||||
if (!data.metadata?.mariadb?.databaseUser) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Database user is required for MariaDB",
|
||||
path: ["metadata", "mariadb", "databaseUser"],
|
||||
});
|
||||
}
|
||||
if (!data.metadata?.mariadb?.databasePassword) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Database password is required for MariaDB",
|
||||
path: ["metadata", "mariadb", "databasePassword"],
|
||||
});
|
||||
}
|
||||
} else if (data.databaseType === "mongo") {
|
||||
if (!data.metadata?.mongo?.databaseUser) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Database user is required for MongoDB",
|
||||
path: ["metadata", "mongo", "databaseUser"],
|
||||
});
|
||||
}
|
||||
if (!data.metadata?.mongo?.databasePassword) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Database password is required for MongoDB",
|
||||
path: ["metadata", "mongo", "databasePassword"],
|
||||
});
|
||||
}
|
||||
} else if (data.databaseType === "mysql") {
|
||||
if (!data.metadata?.mysql?.databaseRootPassword) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Root password is required for MySQL",
|
||||
path: ["metadata", "mysql", "databaseRootPassword"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
export const RestoreBackup = ({
|
||||
databaseId,
|
||||
id,
|
||||
databaseType,
|
||||
serverId,
|
||||
backupType = "database",
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||
|
||||
const { data: destinations = [] } = api.destination.all.useQuery();
|
||||
|
||||
const form = useForm<RestoreBackup>({
|
||||
const form = useForm<z.infer<typeof RestoreBackupSchema>>({
|
||||
defaultValues: {
|
||||
destinationId: "",
|
||||
backupFile: "",
|
||||
databaseName: "",
|
||||
databaseName: databaseType === "web-server" ? "dokploy" : "",
|
||||
databaseType:
|
||||
backupType === "compose" ? ("postgres" as DatabaseType) : databaseType,
|
||||
backupType: backupType,
|
||||
metadata: {},
|
||||
},
|
||||
resolver: zodResolver(RestoreBackupSchema),
|
||||
});
|
||||
|
||||
const destionationId = form.watch("destinationId");
|
||||
const currentDatabaseType = form.watch("databaseType");
|
||||
const metadata = form.watch("metadata");
|
||||
|
||||
const debouncedSetSearch = debounce((value: string) => {
|
||||
setDebouncedSearchTerm(value);
|
||||
}, 350);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
}, 300);
|
||||
debouncedSetSearch(value);
|
||||
};
|
||||
|
||||
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
|
||||
{
|
||||
destinationId: destionationId,
|
||||
search,
|
||||
search: debouncedSearchTerm,
|
||||
serverId: serverId ?? "",
|
||||
},
|
||||
{
|
||||
@@ -117,16 +260,15 @@ export const RestoreBackup = ({
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
|
||||
// const { mutateAsync: restore, isLoading: isRestoring } =
|
||||
// api.backup.restoreBackup.useMutation();
|
||||
|
||||
api.backup.restoreBackupWithLogs.useSubscription(
|
||||
{
|
||||
databaseId,
|
||||
databaseType,
|
||||
databaseId: id,
|
||||
databaseType: currentDatabaseType as DatabaseType,
|
||||
databaseName: form.watch("databaseName"),
|
||||
backupFile: form.watch("backupFile"),
|
||||
destinationId: form.watch("destinationId"),
|
||||
backupType: backupType,
|
||||
metadata: metadata,
|
||||
},
|
||||
{
|
||||
enabled: isDeploying,
|
||||
@@ -148,10 +290,32 @@ export const RestoreBackup = ({
|
||||
},
|
||||
);
|
||||
|
||||
const onSubmit = async (_data: RestoreBackup) => {
|
||||
const onSubmit = async (data: z.infer<typeof RestoreBackupSchema>) => {
|
||||
if (backupType === "compose" && !data.databaseType) {
|
||||
toast.error("Please select a database type");
|
||||
return;
|
||||
}
|
||||
console.log({ data });
|
||||
setIsDeploying(true);
|
||||
};
|
||||
|
||||
const [cacheType, setCacheType] = useState<"fetch" | "cache">("cache");
|
||||
const {
|
||||
data: services = [],
|
||||
isLoading: isLoadingServices,
|
||||
refetch: refetchServices,
|
||||
} = api.compose.loadServices.useQuery(
|
||||
{
|
||||
composeId: id,
|
||||
type: cacheType,
|
||||
},
|
||||
{
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: backupType === "compose",
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -160,7 +324,7 @@ export const RestoreBackup = ({
|
||||
Restore Backup
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center">
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
@@ -265,7 +429,7 @@ export const RestoreBackup = ({
|
||||
</Badge>
|
||||
)}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<Popover modal>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
@@ -284,7 +448,8 @@ export const RestoreBackup = ({
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search backup files..."
|
||||
onValueChange={debouncedSetSearch}
|
||||
value={search}
|
||||
onValueChange={handleSearchChange}
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoading ? (
|
||||
@@ -301,26 +466,51 @@ export const RestoreBackup = ({
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<CommandGroup>
|
||||
{files.map((file) => (
|
||||
<CommandGroup className="w-96">
|
||||
{files?.map((file) => (
|
||||
<CommandItem
|
||||
value={file}
|
||||
key={file}
|
||||
value={file.Path}
|
||||
key={file.Path}
|
||||
onSelect={() => {
|
||||
form.setValue("backupFile", file);
|
||||
form.setValue("backupFile", file.Path);
|
||||
if (file.IsDir) {
|
||||
setSearch(`${file.Path}/`);
|
||||
setDebouncedSearchTerm(`${file.Path}/`);
|
||||
} else {
|
||||
setSearch(file.Path);
|
||||
setDebouncedSearchTerm(file.Path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full justify-between">
|
||||
<span>{file}</span>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div className="flex w-full justify-between">
|
||||
<span className="font-medium">
|
||||
{file.Path}
|
||||
</span>
|
||||
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
file.Path === field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Size: {formatBytes(file.Size)}
|
||||
</span>
|
||||
{file.IsDir && (
|
||||
<span className="text-blue-500">
|
||||
Directory
|
||||
</span>
|
||||
)}
|
||||
{file.Hashes?.MD5 && (
|
||||
<span>MD5: {file.Hashes.MD5}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
file === field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
@@ -337,21 +527,274 @@ export const RestoreBackup = ({
|
||||
control={form.control}
|
||||
name="databaseName"
|
||||
render={({ field }) => (
|
||||
<FormItem className="">
|
||||
<FormItem>
|
||||
<FormLabel>Database Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Enter database name" />
|
||||
<Input
|
||||
placeholder="Enter database name"
|
||||
{...field}
|
||||
disabled={databaseType === "web-server"}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{backupType === "compose" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="databaseType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database Type</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value: DatabaseType) => {
|
||||
field.onChange(value);
|
||||
form.setValue("metadata", {});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select database type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||||
<SelectItem value="mariadb">MariaDB</SelectItem>
|
||||
<SelectItem value="mongo">MongoDB</SelectItem>
|
||||
<SelectItem value="mysql">MySQL</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metadata.serviceName"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormLabel>Service Name</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || undefined}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a service name" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
|
||||
<SelectContent>
|
||||
{services?.map((service, index) => (
|
||||
<SelectItem
|
||||
value={service}
|
||||
key={`${service}-${index}`}
|
||||
>
|
||||
{service}
|
||||
</SelectItem>
|
||||
))}
|
||||
{(!services || services.length === 0) && (
|
||||
<SelectItem value="none" disabled>
|
||||
Empty
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
isLoading={isLoadingServices}
|
||||
onClick={() => {
|
||||
if (cacheType === "fetch") {
|
||||
refetchServices();
|
||||
} else {
|
||||
setCacheType("fetch");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
className="max-w-[10rem]"
|
||||
>
|
||||
<p>
|
||||
Fetch: Will clone the repository and load the
|
||||
services
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
isLoading={isLoadingServices}
|
||||
onClick={() => {
|
||||
if (cacheType === "cache") {
|
||||
refetchServices();
|
||||
} else {
|
||||
setCacheType("cache");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="left"
|
||||
sideOffset={5}
|
||||
className="max-w-[10rem]"
|
||||
>
|
||||
<p>
|
||||
Cache: If you previously deployed this compose,
|
||||
it will read the services from the last
|
||||
deployment/fetch from the repository
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{currentDatabaseType === "postgres" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metadata.postgres.databaseUser"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database User</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter database user" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentDatabaseType === "mariadb" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metadata.mariadb.databaseUser"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database User</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter database user"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metadata.mariadb.databasePassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter database password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentDatabaseType === "mongo" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metadata.mongo.databaseUser"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database User</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter database user"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metadata.mongo.databasePassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Database Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter database password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentDatabaseType === "mysql" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="metadata.mysql.databaseRootPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Root Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter root password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isDeploying}
|
||||
form="hook-form-restore-backup"
|
||||
type="submit"
|
||||
disabled={!form.watch("backupFile")}
|
||||
// disabled={
|
||||
// !form.watch("backupFile") ||
|
||||
// (backupType === "compose" && !form.watch("databaseType"))
|
||||
// }
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
MariadbIcon,
|
||||
MongodbIcon,
|
||||
MysqlIcon,
|
||||
PostgresqlIcon,
|
||||
} from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -13,48 +20,77 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { DatabaseBackup, Play, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ClipboardList,
|
||||
Database,
|
||||
DatabaseBackup,
|
||||
Play,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { ServiceType } from "../../application/advanced/show-resources";
|
||||
import { AddBackup } from "./add-backup";
|
||||
import { UpdateBackup } from "./update-backup";
|
||||
import { ShowDeploymentsModal } from "../../application/deployments/show-deployments-modal";
|
||||
import { HandleBackup } from "./handle-backup";
|
||||
import { RestoreBackup } from "./restore-backup";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: Exclude<ServiceType, "application" | "redis">;
|
||||
databaseType?: Exclude<ServiceType, "application" | "redis"> | "web-server";
|
||||
backupType?: "database" | "compose";
|
||||
}
|
||||
export const ShowBackups = ({ id, type }: Props) => {
|
||||
export const ShowBackups = ({
|
||||
id,
|
||||
databaseType,
|
||||
backupType = "database",
|
||||
}: Props) => {
|
||||
const [activeManualBackup, setActiveManualBackup] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
};
|
||||
const queryMap =
|
||||
backupType === "database"
|
||||
? {
|
||||
postgres: () =>
|
||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||
mysql: () =>
|
||||
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||
mariadb: () =>
|
||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||
mongo: () =>
|
||||
api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||
"web-server": () => api.user.getBackups.useQuery(),
|
||||
}
|
||||
: {
|
||||
compose: () =>
|
||||
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||
};
|
||||
const { data } = api.destination.all.useQuery();
|
||||
const { data: postgres, refetch } = queryMap[type]
|
||||
? queryMap[type]()
|
||||
const key = backupType === "database" ? databaseType : "compose";
|
||||
const query = queryMap[key as keyof typeof queryMap];
|
||||
const { data: postgres, refetch } = query
|
||||
? query()
|
||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||
|
||||
const mutationMap = {
|
||||
postgres: () => api.backup.manualBackupPostgres.useMutation(),
|
||||
mysql: () => api.backup.manualBackupMySql.useMutation(),
|
||||
mariadb: () => api.backup.manualBackupMariadb.useMutation(),
|
||||
mongo: () => api.backup.manualBackupMongo.useMutation(),
|
||||
};
|
||||
const mutationMap =
|
||||
backupType === "database"
|
||||
? {
|
||||
postgres: api.backup.manualBackupPostgres.useMutation(),
|
||||
mysql: api.backup.manualBackupMySql.useMutation(),
|
||||
mariadb: api.backup.manualBackupMariadb.useMutation(),
|
||||
mongo: api.backup.manualBackupMongo.useMutation(),
|
||||
"web-server": api.backup.manualBackupWebServer.useMutation(),
|
||||
}
|
||||
: {
|
||||
compose: api.backup.manualBackupCompose.useMutation(),
|
||||
};
|
||||
|
||||
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutationMap[
|
||||
type
|
||||
]
|
||||
? mutationMap[type]()
|
||||
const mutation = mutationMap[key as keyof typeof mutationMap];
|
||||
|
||||
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutation
|
||||
? mutation
|
||||
: api.backup.manualBackupMongo.useMutation();
|
||||
|
||||
const { mutateAsync: deleteBackup, isLoading: isRemoving } =
|
||||
@@ -64,7 +100,10 @@ export const ShowBackups = ({ id, type }: Props) => {
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row justify-between gap-4 flex-wrap">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<CardTitle className="text-xl">Backups</CardTitle>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Database className="size-6 text-muted-foreground" />
|
||||
Backups
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add backups to your database to save the data to a different
|
||||
provider.
|
||||
@@ -73,18 +112,26 @@ export const ShowBackups = ({ id, type }: Props) => {
|
||||
|
||||
{postgres && postgres?.backups?.length > 0 && (
|
||||
<div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto">
|
||||
<AddBackup databaseId={id} databaseType={type} refetch={refetch} />
|
||||
{databaseType !== "web-server" && (
|
||||
<HandleBackup
|
||||
id={id}
|
||||
databaseType={databaseType}
|
||||
backupType={backupType}
|
||||
refetch={refetch}
|
||||
/>
|
||||
)}
|
||||
<RestoreBackup
|
||||
databaseId={id}
|
||||
databaseType={type}
|
||||
serverId={postgres.serverId}
|
||||
id={id}
|
||||
databaseType={databaseType}
|
||||
backupType={backupType}
|
||||
serverId={"serverId" in postgres ? postgres.serverId : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex flex-col items-center gap-3 min-h-[35vh] justify-center">
|
||||
<DatabaseBackup className="size-8 text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
To create a backup it is required to set at least 1 provider.
|
||||
@@ -99,7 +146,7 @@ export const ShowBackups = ({ id, type }: Props) => {
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{postgres?.backups.length === 0 ? (
|
||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||
<DatabaseBackup className="size-8 text-muted-foreground" />
|
||||
@@ -107,132 +154,222 @@ export const ShowBackups = ({ id, type }: Props) => {
|
||||
No backups configured
|
||||
</span>
|
||||
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||||
<AddBackup
|
||||
databaseId={id}
|
||||
databaseType={type}
|
||||
<HandleBackup
|
||||
id={id}
|
||||
databaseType={databaseType}
|
||||
backupType={backupType}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<RestoreBackup
|
||||
databaseId={id}
|
||||
databaseType={type}
|
||||
serverId={postgres.serverId}
|
||||
id={id}
|
||||
databaseType={databaseType}
|
||||
backupType={backupType}
|
||||
serverId={
|
||||
"serverId" in postgres ? postgres.serverId : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col pt-2">
|
||||
<div className="flex flex-col pt-2 gap-4">
|
||||
{backupType === "compose" && (
|
||||
<AlertBlock title="Compose Backups">
|
||||
Make sure the compose is running before creating a backup.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<div className="flex flex-col gap-6">
|
||||
{postgres?.backups.map((backup) => (
|
||||
<div key={backup.backupId}>
|
||||
<div className="flex w-full flex-col md:flex-row md:items-center justify-between gap-4 md:gap-10 border rounded-lg p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-6 flex-col gap-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Destination</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{backup.destination.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Database</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{backup.database}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Scheduled</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{backup.schedule}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Prefix Storage</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{backup.prefix}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Enabled</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{backup.enabled ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium">Keep Latest</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{backup.keepLatestCount || "All"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-4">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
isLoading={
|
||||
isManualBackup &&
|
||||
activeManualBackup === backup.backupId
|
||||
}
|
||||
onClick={async () => {
|
||||
setActiveManualBackup(backup.backupId);
|
||||
await manualBackup({
|
||||
backupId: backup.backupId as string,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(
|
||||
"Manual Backup Successful",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error creating the manual backup",
|
||||
);
|
||||
});
|
||||
setActiveManualBackup(undefined);
|
||||
}}
|
||||
>
|
||||
<Play className="size-5 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Run Manual Backup</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{postgres?.backups.map((backup) => {
|
||||
const serverId =
|
||||
"serverId" in postgres ? postgres.serverId : undefined;
|
||||
|
||||
<UpdateBackup
|
||||
backupId={backup.backupId}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<DialogAction
|
||||
title="Delete Backup"
|
||||
description="Are you sure you want to delete this backup?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteBackup({
|
||||
backupId: backup.backupId,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Backup deleted successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting backup");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
return (
|
||||
<div key={backup.backupId}>
|
||||
<div className="flex w-full flex-col md:flex-row md:items-start justify-between gap-4 border rounded-lg p-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex flex-col w-full gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{backup.backupType === "compose" && (
|
||||
<div className="flex items-center justify-center size-10 rounded-lg">
|
||||
{backup.databaseType === "postgres" && (
|
||||
<PostgresqlIcon className="size-7" />
|
||||
)}
|
||||
{backup.databaseType === "mysql" && (
|
||||
<MysqlIcon className="size-7" />
|
||||
)}
|
||||
{backup.databaseType === "mariadb" && (
|
||||
<MariadbIcon className="size-7" />
|
||||
)}
|
||||
{backup.databaseType === "mongo" && (
|
||||
<MongodbIcon className="size-7" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{backup.backupType === "compose" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">
|
||||
{backup.serviceName}
|
||||
</h3>
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-muted text-xs font-medium capitalize">
|
||||
{backup.databaseType}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
backup.enabled
|
||||
? "bg-green-500"
|
||||
: "bg-red-500",
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{backup.enabled ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-2">
|
||||
<div className="min-w-[200px]">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Destination
|
||||
</span>
|
||||
<p className="font-medium text-sm mt-0.5">
|
||||
{backup.destination.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[150px]">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Database
|
||||
</span>
|
||||
<p className="font-medium text-sm mt-0.5">
|
||||
{backup.database}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[120px]">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Schedule
|
||||
</span>
|
||||
<p className="font-medium text-sm mt-0.5">
|
||||
{backup.schedule}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[150px]">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Prefix Storage
|
||||
</span>
|
||||
<p className="font-medium text-sm mt-0.5">
|
||||
{backup.prefix}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[100px]">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Keep Latest
|
||||
</span>
|
||||
<p className="font-medium text-sm mt-0.5">
|
||||
{backup.keepLatestCount || "All"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row md:flex-col gap-1.5">
|
||||
<ShowDeploymentsModal
|
||||
id={backup.backupId}
|
||||
type="backup"
|
||||
serverId={serverId || undefined}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
>
|
||||
<ClipboardList className="size-4 transition-colors " />
|
||||
</Button>
|
||||
</ShowDeploymentsModal>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
isLoading={
|
||||
isManualBackup &&
|
||||
activeManualBackup === backup.backupId
|
||||
}
|
||||
onClick={async () => {
|
||||
setActiveManualBackup(backup.backupId);
|
||||
await manualBackup({
|
||||
backupId: backup.backupId as string,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(
|
||||
"Manual Backup Successful",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error creating the manual backup",
|
||||
);
|
||||
});
|
||||
setActiveManualBackup(undefined);
|
||||
}}
|
||||
>
|
||||
<Play className="size-4 " />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Run Manual Backup
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<HandleBackup
|
||||
backupType={backup.backupType}
|
||||
backupId={backup.backupId}
|
||||
databaseType={backup.databaseType}
|
||||
refetch={refetch}
|
||||
/>
|
||||
<DialogAction
|
||||
title="Delete Backup"
|
||||
description="Are you sure you want to delete this backup?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteBackup({
|
||||
backupId: backup.backupId,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Backup deleted successfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting backup");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 size-8"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, PenBoxIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const UpdateBackupSchema = z.object({
|
||||
destinationId: z.string().min(1, "Destination required"),
|
||||
schedule: z.string().min(1, "Schedule (Cron) required"),
|
||||
prefix: z.string().min(1, "Prefix required"),
|
||||
enabled: z.boolean(),
|
||||
database: z.string().min(1, "Database required"),
|
||||
keepLatestCount: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
type UpdateBackup = z.infer<typeof UpdateBackupSchema>;
|
||||
|
||||
interface Props {
|
||||
backupId: string;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export const UpdateBackup = ({ backupId, refetch }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { data, isLoading } = api.destination.all.useQuery();
|
||||
const { data: backup } = api.backup.one.useQuery(
|
||||
{
|
||||
backupId,
|
||||
},
|
||||
{
|
||||
enabled: !!backupId,
|
||||
},
|
||||
);
|
||||
|
||||
const { mutateAsync, isLoading: isLoadingUpdate } =
|
||||
api.backup.update.useMutation();
|
||||
|
||||
const form = useForm<UpdateBackup>({
|
||||
defaultValues: {
|
||||
database: "",
|
||||
destinationId: "",
|
||||
enabled: true,
|
||||
prefix: "/",
|
||||
schedule: "",
|
||||
keepLatestCount: undefined,
|
||||
},
|
||||
resolver: zodResolver(UpdateBackupSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (backup) {
|
||||
form.reset({
|
||||
database: backup.database,
|
||||
destinationId: backup.destinationId,
|
||||
enabled: backup.enabled || false,
|
||||
prefix: backup.prefix,
|
||||
schedule: backup.schedule,
|
||||
keepLatestCount: backup.keepLatestCount ? Number(backup.keepLatestCount) : undefined,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, backup]);
|
||||
|
||||
const onSubmit = async (data: UpdateBackup) => {
|
||||
await mutateAsync({
|
||||
backupId,
|
||||
destinationId: data.destinationId,
|
||||
prefix: data.prefix,
|
||||
schedule: data.schedule,
|
||||
enabled: data.enabled,
|
||||
database: data.database,
|
||||
keepLatestCount: data.keepLatestCount as number | null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Backup Updated");
|
||||
refetch();
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating the Backup");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 "
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update Backup</DialogTitle>
|
||||
<DialogDescription>Update the backup</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-update-backup"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="destinationId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="">
|
||||
<FormLabel>Destination</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-between !bg-input",
|
||||
!field.value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isLoading
|
||||
? "Loading...."
|
||||
: field.value
|
||||
? data?.find(
|
||||
(destination) =>
|
||||
destination.destinationId === field.value,
|
||||
)?.name
|
||||
: "Select Destination"}
|
||||
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search Destination..."
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoading && (
|
||||
<span className="py-6 text-center text-sm">
|
||||
Loading Destinations....
|
||||
</span>
|
||||
)}
|
||||
<CommandEmpty>No destinations found.</CommandEmpty>
|
||||
<ScrollArea className="h-64">
|
||||
<CommandGroup>
|
||||
{data?.map((destination) => (
|
||||
<CommandItem
|
||||
value={destination.destinationId}
|
||||
key={destination.destinationId}
|
||||
onSelect={() => {
|
||||
form.setValue(
|
||||
"destinationId",
|
||||
destination.destinationId,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{destination.name}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
destination.destinationId === field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="database"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Database</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"dokploy"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="schedule"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Schedule (Cron)</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"0 0 * * *"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="prefix"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Prefix Destination</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={"dokploy/"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use if you want to back up in a specific path of your
|
||||
destination/bucket
|
||||
</FormDescription>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keepLatestCount"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Keep the latest</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder={"keeps all the backups if left empty"} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Optional. If provided, only keeps the latest N backups in the cloud.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Enabled</FormLabel>
|
||||
<FormDescription>
|
||||
Enable or disable the backup
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoadingUpdate}
|
||||
form="hook-form-update-backup"
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -140,7 +140,14 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
if (!isCurrentConnection) return;
|
||||
setRawLogs((prev) => prev + e.data);
|
||||
setRawLogs((prev) => {
|
||||
const updated = prev + e.data;
|
||||
const splitLines = updated.split("\n");
|
||||
if (splitLines.length > lines) {
|
||||
return splitLines.slice(-lines).join("\n");
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
setIsLoading(false);
|
||||
if (noDataTimeout) clearTimeout(noDataTimeout);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
"use client";
|
||||
|
||||
import { Logo } from "@/components/shared/logo";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Building2,
|
||||
Calendar,
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
Copy,
|
||||
CreditCard,
|
||||
Fingerprint,
|
||||
Key,
|
||||
Server,
|
||||
Settings2,
|
||||
Shield,
|
||||
UserIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type User = typeof authClient.$Infer.Session.user;
|
||||
|
||||
export const ImpersonationBar = () => {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [isImpersonating, setIsImpersonating] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showBar, setShowBar] = useState(false);
|
||||
const { data } = api.user.get.useQuery();
|
||||
|
||||
const fetchUsers = async (search?: string) => {
|
||||
try {
|
||||
const session = await authClient.getSession();
|
||||
if (session?.data?.session?.impersonatedBy) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
const response = await authClient.admin.listUsers({
|
||||
query: {
|
||||
limit: 30,
|
||||
...(search && {
|
||||
searchField: "email",
|
||||
searchOperator: "contains",
|
||||
searchValue: search,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const filteredUsers = response.data?.users.filter(
|
||||
// @ts-ignore
|
||||
(user) => user.allowImpersonation && data?.user?.email !== user.email,
|
||||
);
|
||||
|
||||
if (!response.error) {
|
||||
// @ts-ignore
|
||||
setUsers(filteredUsers || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error);
|
||||
toast.error("Error loading users");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImpersonate = async () => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
try {
|
||||
await authClient.admin.impersonateUser({
|
||||
userId: selectedUser.id,
|
||||
});
|
||||
setIsImpersonating(true);
|
||||
setOpen(false);
|
||||
|
||||
toast.success("Successfully impersonating user", {
|
||||
description: `You are now viewing as ${selectedUser.name || selectedUser.email}`,
|
||||
});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Error impersonating user:", error);
|
||||
toast.error("Error impersonating user");
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopImpersonating = async () => {
|
||||
try {
|
||||
await authClient.admin.stopImpersonating();
|
||||
setIsImpersonating(false);
|
||||
setSelectedUser(null);
|
||||
setShowBar(false);
|
||||
toast.success("Stopped impersonating user");
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error("Error stopping impersonation:", error);
|
||||
toast.error("Error stopping impersonation");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const checkImpersonation = async () => {
|
||||
try {
|
||||
const session = await authClient.getSession();
|
||||
if (session?.data?.session?.impersonatedBy) {
|
||||
setIsImpersonating(true);
|
||||
setShowBar(true);
|
||||
// setSelectedUser(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking impersonation status:", error);
|
||||
}
|
||||
};
|
||||
|
||||
checkImpersonation();
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"fixed bottom-4 right-4 z-50 rounded-full shadow-lg",
|
||||
isImpersonating &&
|
||||
!showBar &&
|
||||
"bg-red-100 hover:bg-red-200 border-red-200",
|
||||
)}
|
||||
onClick={() => setShowBar(!showBar)}
|
||||
>
|
||||
<Settings2
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
isImpersonating && !showBar && "text-red-500",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isImpersonating ? "Impersonation Controls" : "User Impersonation"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"fixed bottom-0 left-0 right-0 bg-background border-t border-border p-4 flex items-center justify-center gap-4 z-40 transition-all duration-200 ease-in-out",
|
||||
showBar ? "translate-y-0" : "translate-y-full",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4 px-4 md:px-20 w-full">
|
||||
<Logo className="w-10 h-10" />
|
||||
{!isImpersonating ? (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
aria-expanded={open}
|
||||
className="w-[300px] justify-between"
|
||||
>
|
||||
{selectedUser ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate flex flex-col items-start">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedUser.name || ""}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedUser.email}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<UserIcon className="mr-2 h-4 w-4" />
|
||||
<span>Select user to impersonate</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search users by email or name..."
|
||||
onValueChange={(search) => {
|
||||
fetchUsers(search);
|
||||
}}
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoading ? (
|
||||
<div className="py-6 text-center text-sm">
|
||||
Loading users...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty>No users found.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup heading="All Users">
|
||||
{users.map((user) => (
|
||||
<CommandItem
|
||||
key={user.id}
|
||||
value={user.email}
|
||||
onSelect={() => {
|
||||
setSelectedUser(user);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2 flex-1">
|
||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="flex flex-col items-start">
|
||||
<span className="text-sm font-medium">
|
||||
{user.name || ""}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.email} • {user.role}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
selectedUser?.id === user.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</>
|
||||
)}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
onClick={handleImpersonate}
|
||||
disabled={!selectedUser}
|
||||
variant="default"
|
||||
className="gap-2"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
Impersonate
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4 w-full flex-wrap">
|
||||
<div className="flex items-center gap-4 flex-1 flex-wrap">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage
|
||||
src={data?.user?.image || ""}
|
||||
alt={data?.user?.name || ""}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
{data?.user?.name?.slice(0, 2).toUpperCase() || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 py-1 text-yellow-500 bg-yellow-50/20"
|
||||
>
|
||||
<Shield className="h-3 w-3" />
|
||||
Impersonating
|
||||
</Badge>
|
||||
<span className="font-medium">
|
||||
{data?.user?.name || ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<UserIcon className="h-3 w-3" />
|
||||
{data?.user?.email} • {data?.role}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Key className="h-3 w-3" />
|
||||
<span className="flex items-center gap-1">
|
||||
ID: {data?.user?.id?.slice(0, 8)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 hover:bg-muted/50"
|
||||
onClick={() => {
|
||||
if (data?.id) {
|
||||
copy(data.id);
|
||||
toast.success("ID copied to clipboard");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Building2 className="h-3 w-3" />
|
||||
<span className="flex items-center gap-1">
|
||||
Org: {data?.organizationId?.slice(0, 8)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 hover:bg-muted/50"
|
||||
onClick={() => {
|
||||
if (data?.organizationId) {
|
||||
copy(data.organizationId);
|
||||
toast.success(
|
||||
"Organization ID copied to clipboard",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</span>
|
||||
</span>
|
||||
{data?.user?.stripeCustomerId && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CreditCard className="h-3 w-3" />
|
||||
<span className="flex items-center gap-1">
|
||||
Customer:
|
||||
{data?.user?.stripeCustomerId?.slice(0, 8)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 hover:bg-muted/50"
|
||||
onClick={() => {
|
||||
copy(data?.user?.stripeCustomerId || "");
|
||||
toast.success(
|
||||
"Stripe Customer ID copied to clipboard",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{data?.user?.stripeSubscriptionId && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CreditCard className="h-3 w-3" />
|
||||
<span className="flex items-center gap-1">
|
||||
Sub: {data?.user?.stripeSubscriptionId?.slice(0, 8)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 hover:bg-muted/50"
|
||||
onClick={() => {
|
||||
copy(data.user.stripeSubscriptionId || "");
|
||||
toast.success(
|
||||
"Stripe Subscription ID copied to clipboard",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{data?.user?.serversQuantity !== undefined && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Server className="h-3 w-3" />
|
||||
<span>Servers: {data.user.serversQuantity}</span>
|
||||
</span>
|
||||
)}
|
||||
{data?.createdAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Created:{" "}
|
||||
{format(new Date(data.createdAt), "MMM d, yyyy")}
|
||||
</span>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-1 cursor-default">
|
||||
<Fingerprint
|
||||
className={cn(
|
||||
"h-3 w-3",
|
||||
data?.user?.twoFactorEnabled
|
||||
? "text-green-500"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<Badge
|
||||
variant={
|
||||
data?.user?.twoFactorEnabled
|
||||
? "green"
|
||||
: "secondary"
|
||||
}
|
||||
className="text-[10px] px-1 py-0"
|
||||
>
|
||||
2FA{" "}
|
||||
{data?.user?.twoFactorEnabled
|
||||
? "Enabled"
|
||||
: "Disabled"}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Two-Factor Authentication Status
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleStopImpersonating}
|
||||
variant="secondary"
|
||||
className="gap-2"
|
||||
size="sm"
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
Stop Impersonating
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -27,145 +27,149 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()),
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z
|
||||
.number()
|
||||
.gte(0, "Range must be 0 - 65535")
|
||||
.lte(65535, "Range must be 0 - 65535")
|
||||
.nullable()),
|
||||
});
|
||||
|
||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
mariadbId: string;
|
||||
mariadbId: string;
|
||||
}
|
||||
export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.mariadb.one.useQuery({ mariadbId });
|
||||
const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.mariadb.one.useQuery({ mariadbId });
|
||||
const { mutateAsync, isLoading } = api.mariadb.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
mariadbId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
mariadbId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
|
||||
return `mariadb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
|
||||
};
|
||||
return `mariadb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
|
||||
};
|
||||
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [
|
||||
data?.appName,
|
||||
data?.externalPort,
|
||||
data?.databasePassword,
|
||||
form,
|
||||
data?.databaseName,
|
||||
data?.databaseUser,
|
||||
getIp,
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-4 ">
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="3306"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* jdbc:mariadb://5.161.59.207:3306/pixel-calculate?user=mariadb&password=HdVXfq6hM7W7F1 */}
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [
|
||||
data?.appName,
|
||||
data?.externalPort,
|
||||
data?.databasePassword,
|
||||
form,
|
||||
data?.databaseName,
|
||||
data?.databaseUser,
|
||||
getIp,
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-4 ">
|
||||
<div className="md:col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="3306"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* jdbc:mariadb://5.161.59.207:3306/pixel-calculate?user=mariadb&password=HdVXfq6hM7W7F1 */}
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,144 +27,148 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()),
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z
|
||||
.number()
|
||||
.gte(0, "Range must be 0 - 65535")
|
||||
.lte(65535, "Range must be 0 - 65535")
|
||||
.nullable()),
|
||||
});
|
||||
|
||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
mongoId: string;
|
||||
mongoId: string;
|
||||
}
|
||||
export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.mongo.one.useQuery({ mongoId });
|
||||
const { mutateAsync, isLoading } = api.mongo.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.mongo.one.useQuery({ mongoId });
|
||||
const { mutateAsync, isLoading } = api.mongo.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
mongoId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
mongoId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
|
||||
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
|
||||
};
|
||||
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
|
||||
};
|
||||
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [
|
||||
data?.appName,
|
||||
data?.externalPort,
|
||||
data?.databasePassword,
|
||||
form,
|
||||
data?.databaseUser,
|
||||
getIp,
|
||||
]);
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [
|
||||
data?.appName,
|
||||
data?.externalPort,
|
||||
data?.databasePassword,
|
||||
form,
|
||||
data?.databaseUser,
|
||||
getIp,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 ">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="27017"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 ">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="27017"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,144 +27,148 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()),
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z
|
||||
.number()
|
||||
.gte(0, "Range must be 0 - 65535")
|
||||
.lte(65535, "Range must be 0 - 65535")
|
||||
.nullable()),
|
||||
});
|
||||
|
||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
mysqlId: string;
|
||||
mysqlId: string;
|
||||
}
|
||||
export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.mysql.one.useQuery({ mysqlId });
|
||||
const { mutateAsync, isLoading } = api.mysql.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.mysql.one.useQuery({ mysqlId });
|
||||
const { mutateAsync, isLoading } = api.mysql.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
mysqlId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
mysqlId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
|
||||
return `mysql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
|
||||
};
|
||||
return `mysql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
|
||||
};
|
||||
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [
|
||||
data?.appName,
|
||||
data?.externalPort,
|
||||
data?.databasePassword,
|
||||
data?.databaseName,
|
||||
data?.databaseUser,
|
||||
form,
|
||||
getIp,
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 ">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="3306"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput disabled value={connectionUrl} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [
|
||||
data?.appName,
|
||||
data?.externalPort,
|
||||
data?.databasePassword,
|
||||
data?.databaseName,
|
||||
data?.databaseUser,
|
||||
form,
|
||||
getIp,
|
||||
]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 ">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="3306"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput disabled value={connectionUrl} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,146 +27,150 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()),
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z
|
||||
.number()
|
||||
.gte(0, "Range must be 0 - 65535")
|
||||
.lte(65535, "Range must be 0 - 65535")
|
||||
.nullable()),
|
||||
});
|
||||
|
||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
postgresId: string;
|
||||
postgresId: string;
|
||||
}
|
||||
export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.postgres.one.useQuery({ postgresId });
|
||||
const { mutateAsync, isLoading } =
|
||||
api.postgres.saveExternalPort.useMutation();
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.postgres.one.useQuery({ postgresId });
|
||||
const { mutateAsync, isLoading } =
|
||||
api.postgres.saveExternalPort.useMutation();
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
postgresId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
postgresId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
|
||||
return `postgresql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
|
||||
};
|
||||
return `postgresql://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/${data?.databaseName}`;
|
||||
};
|
||||
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [
|
||||
data?.appName,
|
||||
data?.externalPort,
|
||||
data?.databasePassword,
|
||||
form,
|
||||
data?.databaseName,
|
||||
getIp,
|
||||
]);
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [
|
||||
data?.appName,
|
||||
data?.externalPort,
|
||||
data?.databasePassword,
|
||||
form,
|
||||
data?.databaseName,
|
||||
getIp,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 ">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="5432"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 ">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="5432"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,58 +5,58 @@ import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
postgresId: string;
|
||||
postgresId: string;
|
||||
}
|
||||
export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
||||
const { data } = api.postgres.one.useQuery({ postgresId });
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Internal Credentials</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-row gap-4">
|
||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value={data?.databaseUser} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Database Name</Label>
|
||||
<Input disabled value={data?.databaseName} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
<div className="flex flex-row gap-4">
|
||||
<ToggleVisibilityInput
|
||||
value={data?.databasePassword}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Port (Container)</Label>
|
||||
<Input disabled value="5432" />
|
||||
</div>
|
||||
const { data } = api.postgres.one.useQuery({ postgresId });
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Internal Credentials</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-row gap-4">
|
||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>User</Label>
|
||||
<Input disabled value={data?.databaseUser} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Database Name</Label>
|
||||
<Input disabled value={data?.databaseName} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Password</Label>
|
||||
<div className="flex flex-row gap-4">
|
||||
<ToggleVisibilityInput
|
||||
value={data?.databasePassword}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Port (Container)</Label>
|
||||
<Input disabled value="5432" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Host</Label>
|
||||
<Input disabled value={data?.appName} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Connection URL </Label>
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={`postgresql://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5432/${data?.databaseName}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Internal Connection URL </Label>
|
||||
<ToggleVisibilityInput
|
||||
disabled
|
||||
value={`postgresql://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:5432/${data?.databaseName}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
// ReplyError: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk. Commands that may modify the data set are disabled, because this instance is configured to report errors during writes if RDB snapshotting fails (stop-w
|
||||
|
||||
@@ -28,139 +28,139 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const updatePostgresSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
type UpdatePostgres = z.infer<typeof updatePostgresSchema>;
|
||||
|
||||
interface Props {
|
||||
postgresId: string;
|
||||
postgresId: string;
|
||||
}
|
||||
|
||||
export const UpdatePostgres = ({ postgresId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, error, isError, isLoading } =
|
||||
api.postgres.update.useMutation();
|
||||
const { data } = api.postgres.one.useQuery(
|
||||
{
|
||||
postgresId,
|
||||
},
|
||||
{
|
||||
enabled: !!postgresId,
|
||||
}
|
||||
);
|
||||
const form = useForm<UpdatePostgres>({
|
||||
defaultValues: {
|
||||
description: data?.description ?? "",
|
||||
name: data?.name ?? "",
|
||||
},
|
||||
resolver: zodResolver(updatePostgresSchema),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
description: data.description ?? "",
|
||||
name: data.name,
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync, error, isError, isLoading } =
|
||||
api.postgres.update.useMutation();
|
||||
const { data } = api.postgres.one.useQuery(
|
||||
{
|
||||
postgresId,
|
||||
},
|
||||
{
|
||||
enabled: !!postgresId,
|
||||
},
|
||||
);
|
||||
const form = useForm<UpdatePostgres>({
|
||||
defaultValues: {
|
||||
description: data?.description ?? "",
|
||||
name: data?.name ?? "",
|
||||
},
|
||||
resolver: zodResolver(updatePostgresSchema),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
description: data.description ?? "",
|
||||
name: data.name,
|
||||
});
|
||||
}
|
||||
}, [data, form, form.reset]);
|
||||
|
||||
const onSubmit = async (formData: UpdatePostgres) => {
|
||||
await mutateAsync({
|
||||
name: formData.name,
|
||||
postgresId: postgresId,
|
||||
description: formData.description || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Postgres updated successfully");
|
||||
utils.postgres.one.invalidate({
|
||||
postgresId: postgresId,
|
||||
});
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Postgres");
|
||||
})
|
||||
.finally(() => {});
|
||||
};
|
||||
const onSubmit = async (formData: UpdatePostgres) => {
|
||||
await mutateAsync({
|
||||
name: formData.name,
|
||||
postgresId: postgresId,
|
||||
description: formData.description || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Postgres updated successfully");
|
||||
utils.postgres.one.invalidate({
|
||||
postgresId: postgresId,
|
||||
});
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Postgres");
|
||||
})
|
||||
.finally(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<PenBox className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modify Postgres</DialogTitle>
|
||||
<DialogDescription>Update the Postgres data</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<PenBox className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modify Postgres</DialogTitle>
|
||||
<DialogDescription>Update the Postgres data</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid items-center gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-update-postgres"
|
||||
className="grid w-full gap-4 "
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Vandelay Industries" {...field} />
|
||||
</FormControl>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid items-center gap-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="hook-form-update-postgres"
|
||||
className="grid w-full gap-4 "
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Vandelay Industries" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description about your project..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Description about your project..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-update-postgres"
|
||||
type="submit"
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
form="hook-form-update-postgres"
|
||||
type="submit"
|
||||
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -145,10 +145,8 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value?.trim() || "";
|
||||
form.setValue(
|
||||
"appName",
|
||||
`${slug}-${val.toLowerCase().replaceAll(" ", "-")}`,
|
||||
);
|
||||
const serviceName = slugify(val);
|
||||
form.setValue("appName", `${slug}-${serviceName}`);
|
||||
field.onChange(val);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -152,10 +152,8 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value?.trim() || "";
|
||||
form.setValue(
|
||||
"appName",
|
||||
`${slug}-${val.toLowerCase()}`,
|
||||
);
|
||||
const serviceName = slugify(val);
|
||||
form.setValue("appName", `${slug}-${serviceName}`);
|
||||
field.onChange(val);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -363,10 +363,8 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value?.trim() || "";
|
||||
form.setValue(
|
||||
"appName",
|
||||
`${slug}-${val.toLowerCase()}`,
|
||||
);
|
||||
const serviceName = slugify(val);
|
||||
form.setValue("appName", `${slug}-${serviceName}`);
|
||||
field.onChange(val);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -67,7 +67,7 @@ import {
|
||||
SearchIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url";
|
||||
@@ -307,7 +307,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
||||
>
|
||||
{templates?.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
key={template?.id}
|
||||
className={cn(
|
||||
"flex flex-col border rounded-lg overflow-hidden relative",
|
||||
viewMode === "icon" && "h-[200px]",
|
||||
@@ -315,7 +315,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
||||
)}
|
||||
>
|
||||
<Badge className="absolute top-2 right-2" variant="blue">
|
||||
{template.version}
|
||||
{template?.version}
|
||||
</Badge>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -324,21 +324,21 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template.id}/${template.logo}`}
|
||||
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template?.id}/${template?.logo}`}
|
||||
className={cn(
|
||||
"object-contain",
|
||||
viewMode === "detailed" ? "size-24" : "size-16",
|
||||
)}
|
||||
alt={template.name}
|
||||
alt={template?.name}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-sm font-medium line-clamp-1">
|
||||
{template.name}
|
||||
{template?.name}
|
||||
</span>
|
||||
{viewMode === "detailed" &&
|
||||
template.tags.length > 0 && (
|
||||
template?.tags?.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{template.tags.map((tag) => (
|
||||
{template?.tags?.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="green"
|
||||
@@ -356,7 +356,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
||||
{viewMode === "detailed" && (
|
||||
<ScrollArea className="flex-1 p-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{template.description}
|
||||
{template?.description}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
@@ -372,25 +372,27 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
||||
>
|
||||
{viewMode === "detailed" && (
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href={template.links.github}
|
||||
target="_blank"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<GithubIcon className="size-5" />
|
||||
</Link>
|
||||
{template.links.website && (
|
||||
{template?.links?.github && (
|
||||
<Link
|
||||
href={template.links.website}
|
||||
href={template?.links?.github}
|
||||
target="_blank"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<GithubIcon className="size-5" />
|
||||
</Link>
|
||||
)}
|
||||
{template?.links?.website && (
|
||||
<Link
|
||||
href={template?.links?.website}
|
||||
target="_blank"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Globe className="size-5" />
|
||||
</Link>
|
||||
)}
|
||||
{template.links.docs && (
|
||||
{template?.links?.docs && (
|
||||
<Link
|
||||
href={template.links.docs}
|
||||
href={template?.links?.docs}
|
||||
target="_blank"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
@@ -419,7 +421,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will create an application from the{" "}
|
||||
{template.name} template and add it to your
|
||||
{template?.name} template and add it to your
|
||||
project.
|
||||
</AlertDialogDescription>
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
import { useState } from "react";
|
||||
|
||||
const examples = [
|
||||
"Make a personal blog",
|
||||
@@ -23,7 +22,7 @@ const examples = [
|
||||
"Sendgrid service opensource analogue",
|
||||
];
|
||||
|
||||
export const StepOne = ({ nextStep, setTemplateInfo, templateInfo }: any) => {
|
||||
export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
|
||||
// Get servers from the API
|
||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||
|
||||
|
||||
208
apps/dokploy/components/dashboard/project/duplicate-project.tsx
Normal file
208
apps/dokploy/components/dashboard/project/duplicate-project.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { api } from "@/utils/api";
|
||||
import { Copy, Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export type Services = {
|
||||
appName: string;
|
||||
serverId?: string | null;
|
||||
name: string;
|
||||
type:
|
||||
| "mariadb"
|
||||
| "application"
|
||||
| "postgres"
|
||||
| "mysql"
|
||||
| "mongo"
|
||||
| "redis"
|
||||
| "compose";
|
||||
description?: string | null;
|
||||
id: string;
|
||||
createdAt: string;
|
||||
status?: "idle" | "running" | "done" | "error";
|
||||
};
|
||||
|
||||
interface DuplicateProjectProps {
|
||||
projectId: string;
|
||||
services: Services[];
|
||||
selectedServiceIds: string[];
|
||||
}
|
||||
|
||||
export const DuplicateProject = ({
|
||||
projectId,
|
||||
services,
|
||||
selectedServiceIds,
|
||||
}: DuplicateProjectProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "same-project"
|
||||
const utils = api.useUtils();
|
||||
const router = useRouter();
|
||||
|
||||
const selectedServices = services.filter((service) =>
|
||||
selectedServiceIds.includes(service.id),
|
||||
);
|
||||
|
||||
const { mutateAsync: duplicateProject, isLoading } =
|
||||
api.project.duplicate.useMutation({
|
||||
onSuccess: async (newProject) => {
|
||||
await utils.project.all.invalidate();
|
||||
toast.success(
|
||||
duplicateType === "new-project"
|
||||
? "Project duplicated successfully"
|
||||
: "Services duplicated successfully",
|
||||
);
|
||||
setOpen(false);
|
||||
if (duplicateType === "new-project") {
|
||||
router.push(`/dashboard/project/${newProject.projectId}`);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
if (duplicateType === "new-project" && !name) {
|
||||
toast.error("Project name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
await duplicateProject({
|
||||
sourceProjectId: projectId,
|
||||
name,
|
||||
description,
|
||||
includeServices: true,
|
||||
selectedServices: selectedServices.map((service) => ({
|
||||
id: service.id,
|
||||
type: service.type,
|
||||
})),
|
||||
duplicateInSameProject: duplicateType === "same-project",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
// Reset form when closing
|
||||
setName("");
|
||||
setDescription("");
|
||||
setDuplicateType("new-project");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-start">
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Duplicate Services</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose where to duplicate the selected services
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Duplicate to</Label>
|
||||
<RadioGroup
|
||||
value={duplicateType}
|
||||
onValueChange={setDuplicateType}
|
||||
className="grid gap-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="new-project" id="new-project" />
|
||||
<Label htmlFor="new-project">New project</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="same-project" id="same-project" />
|
||||
<Label htmlFor="same-project">Same project</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{duplicateType === "new-project" && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="New project name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Project description (optional)"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Selected services to duplicate</Label>
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto border rounded-md p-4">
|
||||
{selectedServices.map((service) => (
|
||||
<div key={service.id} className="flex items-center space-x-2">
|
||||
<span className="text-sm">
|
||||
{service.name} ({service.type})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDuplicate} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{duplicateType === "new-project"
|
||||
? "Duplicating project..."
|
||||
: "Duplicating services..."}
|
||||
</>
|
||||
) : duplicateType === "new-project" ? (
|
||||
"Duplicate project"
|
||||
) : (
|
||||
"Duplicate services"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -31,9 +31,25 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const AddProjectSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, "Project name is required")
|
||||
.refine(
|
||||
(name) => {
|
||||
const trimmedName = name.trim();
|
||||
const validNameRegex =
|
||||
/^[\p{L}\p{N}_-][\p{L}\p{N}\s_-]*[\p{L}\p{N}_-]$/u;
|
||||
return validNameRegex.test(trimmedName);
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Project name must start and end with a letter, number, hyphen or underscore. Spaces are allowed in between.",
|
||||
},
|
||||
)
|
||||
.refine((name) => !/^\d/.test(name.trim()), {
|
||||
message: "Project name cannot start with a number",
|
||||
})
|
||||
.transform((name) => name.trim()),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -97,18 +113,6 @@ export const HandleProject = ({ projectId }: Props) => {
|
||||
);
|
||||
});
|
||||
};
|
||||
// useEffect(() => {
|
||||
// const getUsers = async () => {
|
||||
// const users = await authClient.admin.listUsers({
|
||||
// query: {
|
||||
// limit: 100,
|
||||
// },
|
||||
// });
|
||||
// console.log(users);
|
||||
// };
|
||||
|
||||
// getUsers();
|
||||
// });
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
|
||||
@@ -115,7 +115,7 @@ export const ShowProjects = () => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
|
||||
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
|
||||
{filteredProjects?.map((project) => {
|
||||
const emptyServices =
|
||||
project?.mariadb.length === 0 &&
|
||||
@@ -186,7 +186,9 @@ export const ShowProjects = () => {
|
||||
target="_blank"
|
||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||
>
|
||||
<span className="truncate">{domain.host}</span>
|
||||
<span className="truncate">
|
||||
{domain.host}
|
||||
</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
@@ -222,7 +224,9 @@ export const ShowProjects = () => {
|
||||
target="_blank"
|
||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||
>
|
||||
<span className="truncate">{domain.host}</span>
|
||||
<span className="truncate">
|
||||
{domain.host}
|
||||
</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -27,138 +27,142 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const DockerProviderSchema = z.object({
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z.number().gte(0, "Range must be 0 - 65535").lte(65535, "Range must be 0 - 65535").nullable()),
|
||||
externalPort: z.preprocess((a) => {
|
||||
if (a !== null) {
|
||||
const parsed = Number.parseInt(z.string().parse(a), 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
}, z
|
||||
.number()
|
||||
.gte(0, "Range must be 0 - 65535")
|
||||
.lte(65535, "Range must be 0 - 65535")
|
||||
.nullable()),
|
||||
});
|
||||
|
||||
type DockerProvider = z.infer<typeof DockerProviderSchema>;
|
||||
|
||||
interface Props {
|
||||
redisId: string;
|
||||
redisId: string;
|
||||
}
|
||||
export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.redis.one.useQuery({ redisId });
|
||||
const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
const { data: ip } = api.settings.getIp.useQuery();
|
||||
const { data, refetch } = api.redis.one.useQuery({ redisId });
|
||||
const { mutateAsync, isLoading } = api.redis.saveExternalPort.useMutation();
|
||||
const [connectionUrl, setConnectionUrl] = useState("");
|
||||
const getIp = data?.server?.ipAddress || ip;
|
||||
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
const form = useForm<DockerProvider>({
|
||||
defaultValues: {},
|
||||
resolver: zodResolver(DockerProviderSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
useEffect(() => {
|
||||
if (data?.externalPort) {
|
||||
form.reset({
|
||||
externalPort: data.externalPort,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
redisId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
const onSubmit = async (values: DockerProvider) => {
|
||||
await mutateAsync({
|
||||
externalPort: values.externalPort,
|
||||
redisId,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("External Port updated");
|
||||
await refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error saving the external port");
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const _hostname = window.location.hostname;
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
useEffect(() => {
|
||||
const buildConnectionUrl = () => {
|
||||
const _hostname = window.location.hostname;
|
||||
const port = form.watch("externalPort") || data?.externalPort;
|
||||
|
||||
return `redis://default:${data?.databasePassword}@${getIp}:${port}`;
|
||||
};
|
||||
return `redis://default:${data?.databasePassword}@${getIp}:${port}`;
|
||||
};
|
||||
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [data?.appName, data?.externalPort, data?.databasePassword, form, getIp]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 ">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="6379"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
setConnectionUrl(buildConnectionUrl());
|
||||
}, [data?.appName, data?.externalPort, data?.databasePassword, form, getIp]);
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-5 ">
|
||||
<Card className="bg-background">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">External Credentials</CardTitle>
|
||||
<CardDescription>
|
||||
In order to make the database reachable trought internet is
|
||||
required to set a port, make sure the port is not used by another
|
||||
application or database
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
{!getIp && (
|
||||
<AlertBlock type="warning">
|
||||
You need to set an IP address in your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/server"
|
||||
className="text-primary"
|
||||
>
|
||||
{data?.serverId
|
||||
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
|
||||
: "Web Server -> Server -> Update Server IP"}
|
||||
</Link>{" "}
|
||||
to fix the database url connection.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 ">
|
||||
<div className="col-span-2 space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="externalPort"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>External Port (Internet)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="6379"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!data?.externalPort && (
|
||||
<div className="grid w-full gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>External Host</Label>
|
||||
<ToggleVisibilityInput value={connectionUrl} disabled />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
|
||||
@@ -25,13 +25,13 @@ import {
|
||||
import { type RouterOutputs, api } from "@/utils/api";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
ArrowDownUp,
|
||||
AlertCircle,
|
||||
InfoIcon,
|
||||
ArrowDownUp,
|
||||
Calendar as CalendarIcon,
|
||||
InfoIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { RequestDistributionChart } from "./request-distribution-chart";
|
||||
import { RequestsTable } from "./requests-table";
|
||||
|
||||
@@ -55,7 +55,7 @@ export const AiForm = () => {
|
||||
key={config.aiId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div>
|
||||
<span className="text-sm font-medium">
|
||||
{config.name}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -17,22 +25,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "@/components/ui/form";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -7,13 +9,11 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { ExternalLinkIcon, KeyIcon, Trash2, Clock, Tag } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Clock, ExternalLinkIcon, KeyIcon, Tag, Trash2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { AddApiKey } from "./add-api-key";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export const ShowApiKeys = () => {
|
||||
const { data, refetch } = api.user.get.useQuery();
|
||||
|
||||
@@ -70,7 +70,7 @@ export const ShowCertificates = () => {
|
||||
key={certificate.certificateId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -13,53 +13,65 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
||||
bytes[i] = binaryStr.charCodeAt(i);
|
||||
}
|
||||
|
||||
let dateFound = 0;
|
||||
// ASN.1 tag for UTCTime is 0x17, GeneralizedTime is 0x18
|
||||
// We need to find the second occurrence of either tag as it's the "not after" (expiration) date
|
||||
let dateFound = false;
|
||||
for (let i = 0; i < bytes.length - 2; i++) {
|
||||
if (bytes[i] === 0x17 || bytes[i] === 0x18) {
|
||||
const dateType = bytes[i];
|
||||
const dateLength = bytes[i + 1];
|
||||
if (typeof dateLength === "undefined") continue;
|
||||
// Look for sequence containing validity period (0x30)
|
||||
if (bytes[i] === 0x30) {
|
||||
// Check next bytes for UTCTime or GeneralizedTime
|
||||
let j = i + 1;
|
||||
while (j < bytes.length - 2) {
|
||||
if (bytes[j] === 0x17 || bytes[j] === 0x18) {
|
||||
const dateType = bytes[j];
|
||||
const dateLength = bytes[j + 1];
|
||||
if (typeof dateLength === "undefined") break;
|
||||
|
||||
if (dateFound === 0) {
|
||||
dateFound++;
|
||||
i += dateLength + 1;
|
||||
continue;
|
||||
if (!dateFound) {
|
||||
// Skip "not before" date
|
||||
dateFound = true;
|
||||
j += dateLength + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found "not after" date
|
||||
let dateStr = "";
|
||||
for (let k = 0; k < dateLength; k++) {
|
||||
const charCode = bytes[j + 2 + k];
|
||||
if (typeof charCode === "undefined") continue;
|
||||
dateStr += String.fromCharCode(charCode);
|
||||
}
|
||||
|
||||
if (dateType === 0x17) {
|
||||
// UTCTime (YYMMDDhhmmssZ)
|
||||
const year = Number.parseInt(dateStr.slice(0, 2));
|
||||
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
fullYear,
|
||||
Number.parseInt(dateStr.slice(2, 4)) - 1,
|
||||
Number.parseInt(dateStr.slice(4, 6)),
|
||||
Number.parseInt(dateStr.slice(6, 8)),
|
||||
Number.parseInt(dateStr.slice(8, 10)),
|
||||
Number.parseInt(dateStr.slice(10, 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// GeneralizedTime (YYYYMMDDhhmmssZ)
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
Number.parseInt(dateStr.slice(0, 4)),
|
||||
Number.parseInt(dateStr.slice(4, 6)) - 1,
|
||||
Number.parseInt(dateStr.slice(6, 8)),
|
||||
Number.parseInt(dateStr.slice(8, 10)),
|
||||
Number.parseInt(dateStr.slice(10, 12)),
|
||||
Number.parseInt(dateStr.slice(12, 14)),
|
||||
),
|
||||
);
|
||||
}
|
||||
j++;
|
||||
}
|
||||
|
||||
let dateStr = "";
|
||||
for (let j = 0; j < dateLength; j++) {
|
||||
const charCode = bytes[i + 2 + j];
|
||||
if (typeof charCode === "undefined") continue;
|
||||
dateStr += String.fromCharCode(charCode);
|
||||
}
|
||||
|
||||
if (dateType === 0x17) {
|
||||
// UTCTime (YYMMDDhhmmssZ)
|
||||
const year = Number.parseInt(dateStr.slice(0, 2));
|
||||
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
fullYear,
|
||||
Number.parseInt(dateStr.slice(2, 4)) - 1,
|
||||
Number.parseInt(dateStr.slice(4, 6)),
|
||||
Number.parseInt(dateStr.slice(6, 8)),
|
||||
Number.parseInt(dateStr.slice(8, 10)),
|
||||
Number.parseInt(dateStr.slice(10, 12)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// GeneralizedTime (YYYYMMDDhhmmssZ)
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
Number.parseInt(dateStr.slice(0, 4)),
|
||||
Number.parseInt(dateStr.slice(4, 6)) - 1,
|
||||
Number.parseInt(dateStr.slice(6, 8)),
|
||||
Number.parseInt(dateStr.slice(8, 10)),
|
||||
Number.parseInt(dateStr.slice(10, 12)),
|
||||
Number.parseInt(dateStr.slice(12, 14)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -48,6 +49,10 @@ export const AddNode = ({ serverId }: Props) => {
|
||||
Architecture
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
<AlertBlock type="warning">
|
||||
Make sure you use the same architecture as the node you are
|
||||
adding.
|
||||
</AlertBlock>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -56,10 +61,10 @@ export const AddNode = ({ serverId }: Props) => {
|
||||
<TabsTrigger value="worker">Worker</TabsTrigger>
|
||||
<TabsTrigger value="manager">Manager</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="worker" className="pt-4">
|
||||
<TabsContent value="worker" className="pt-4 overflow-hidden">
|
||||
<AddWorker serverId={serverId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="manager" className="pt-4">
|
||||
<TabsContent value="manager" className="pt-4 overflow-hidden">
|
||||
<AddManager serverId={serverId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
DialogDescription,
|
||||
@@ -6,7 +7,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon } from "lucide-react";
|
||||
import { CopyIcon, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
@@ -14,56 +15,66 @@ interface Props {
|
||||
}
|
||||
|
||||
export const AddManager = ({ serverId }: Props) => {
|
||||
const { data } = api.cluster.addManager.useQuery({ serverId });
|
||||
const { data, isLoading, error, isError } = api.cluster.addManager.useQuery({
|
||||
serverId,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<CardContent className="sm:max-w-4xl max-h-screen overflow-y-auto flex flex-col gap-4 px-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a new manager</DialogTitle>
|
||||
<DialogDescription>Add a new manager</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2.5 text-sm">
|
||||
<span>1. Go to your new server and run the following command</span>
|
||||
<span className="bg-muted rounded-lg p-2 flex justify-between">
|
||||
curl https://get.docker.com | sh -s -- --version {data?.version}
|
||||
<button
|
||||
type="button"
|
||||
className="self-center"
|
||||
onClick={() => {
|
||||
copy(
|
||||
`curl https://get.docker.com | sh -s -- --version ${data?.version}`,
|
||||
);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<CardContent className="sm:max-w-4xl flex flex-col gap-4 px-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a new manager</DialogTitle>
|
||||
<DialogDescription>Add a new manager</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-full animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2.5 text-sm">
|
||||
<span>
|
||||
1. Go to your new server and run the following command
|
||||
</span>
|
||||
<span className="bg-muted rounded-lg p-2 flex justify-between">
|
||||
curl https://get.docker.com | sh -s -- --version {data?.version}
|
||||
<button
|
||||
type="button"
|
||||
className="self-center"
|
||||
onClick={() => {
|
||||
copy(
|
||||
`curl https://get.docker.com | sh -s -- --version ${data?.version}`,
|
||||
);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5 text-sm">
|
||||
<span>
|
||||
2. Run the following command to add the node(manager) to your
|
||||
cluster
|
||||
</span>
|
||||
<span className="bg-muted rounded-lg p-2 flex">
|
||||
{data?.command}
|
||||
<button
|
||||
type="button"
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
copy(data?.command || "");
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5 text-sm">
|
||||
<span>
|
||||
2. Run the following command to add the node(manager) to your
|
||||
cluster
|
||||
</span>
|
||||
|
||||
<span className="bg-muted rounded-lg p-2 flex">
|
||||
{data?.command}
|
||||
<button
|
||||
type="button"
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
copy(data?.command || "");
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export const ShowNodesModal = ({ serverId }: Props) => {
|
||||
className="w-full cursor-pointer "
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Show Nodes
|
||||
Show Swarm Nodes
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen ">
|
||||
|
||||
@@ -35,9 +35,9 @@ import { api } from "@/utils/api";
|
||||
import {
|
||||
Boxes,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
LockIcon,
|
||||
MoreHorizontal,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AddNode } from "./add-node";
|
||||
@@ -144,7 +144,7 @@ export const ShowNodes = ({ serverId }: Props) => {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<ShowNodeData data={node} />
|
||||
{node?.ManagerStatus?.Leader && (
|
||||
{!node?.ManagerStatus?.Leader && (
|
||||
<DialogAction
|
||||
title="Delete Node"
|
||||
description="Are you sure you want to delete this node from the cluster?"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
DialogDescription,
|
||||
@@ -6,7 +7,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon } from "lucide-react";
|
||||
import { CopyIcon, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
@@ -14,54 +15,62 @@ interface Props {
|
||||
}
|
||||
|
||||
export const AddWorker = ({ serverId }: Props) => {
|
||||
const { data } = api.cluster.addWorker.useQuery({ serverId });
|
||||
const { data, isLoading, error, isError } = api.cluster.addWorker.useQuery({
|
||||
serverId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardContent className="sm:max-w-4xl max-h-screen overflow-y-auto flex flex-col gap-4 px-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a new worker</DialogTitle>
|
||||
<DialogDescription>Add a new worker</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2.5 text-sm">
|
||||
<span>1. Go to your new server and run the following command</span>
|
||||
<span className="bg-muted rounded-lg p-2 flex justify-between">
|
||||
curl https://get.docker.com | sh -s -- --version {data?.version}
|
||||
<button
|
||||
type="button"
|
||||
className="self-center"
|
||||
onClick={() => {
|
||||
copy(
|
||||
`curl https://get.docker.com | sh -s -- --version ${data?.version}`,
|
||||
);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<CardContent className="sm:max-w-4xl flex flex-col gap-4 px-0">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add a new worker</DialogTitle>
|
||||
<DialogDescription>Add a new worker</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-full animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2.5 text-sm">
|
||||
<span>1. Go to your new server and run the following command</span>
|
||||
<span className="bg-muted rounded-lg p-2 flex justify-between">
|
||||
curl https://get.docker.com | sh -s -- --version {data?.version}
|
||||
<button
|
||||
type="button"
|
||||
className="self-center"
|
||||
onClick={() => {
|
||||
copy(
|
||||
`curl https://get.docker.com | sh -s -- --version ${data?.version}`,
|
||||
);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2.5 text-sm">
|
||||
<span>
|
||||
2. Run the following command to add the node(worker) to your cluster
|
||||
</span>
|
||||
<div className="flex flex-col gap-2.5 text-sm">
|
||||
<span>
|
||||
2. Run the following command to add the node(worker) to your
|
||||
cluster
|
||||
</span>
|
||||
|
||||
<span className="bg-muted rounded-lg p-2 flex">
|
||||
{data?.command}
|
||||
<button
|
||||
type="button"
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
copy(data?.command || "");
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
<span className="bg-muted rounded-lg p-2 flex">
|
||||
{data?.command}
|
||||
<button
|
||||
type="button"
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
copy(data?.command || "");
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4 cursor-pointer" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ export const ShowRegistry = () => {
|
||||
key={registry.registryId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -55,7 +55,7 @@ export const ShowDestinations = () => {
|
||||
key={destination.destinationId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm">
|
||||
{index + 1}. {destination.name}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { GiteaIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
type GiteaProviderResponse,
|
||||
getGiteaOAuthUrl,
|
||||
} from "@/utils/gitea-utils";
|
||||
import { useUrl } from "@/utils/hooks/use-url";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const Schema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
giteaUrl: z.string().min(1, {
|
||||
message: "Gitea URL is required",
|
||||
}),
|
||||
clientId: z.string().min(1, {
|
||||
message: "Client ID is required",
|
||||
}),
|
||||
clientSecret: z.string().min(1, {
|
||||
message: "Client Secret is required",
|
||||
}),
|
||||
redirectUri: z.string().min(1, {
|
||||
message: "Redirect URI is required",
|
||||
}),
|
||||
organizationName: z.string().optional(),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
|
||||
export const AddGiteaProvider = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const urlObj = useUrl();
|
||||
const baseUrl =
|
||||
typeof urlObj === "string" ? urlObj : (urlObj as any)?.url || "";
|
||||
|
||||
const { mutateAsync, error, isError } = api.gitea.create.useMutation();
|
||||
const webhookUrl = `${baseUrl}/api/providers/gitea/callback`;
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
giteaUrl: "https://gitea.com",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
|
||||
const giteaUrl = form.watch("giteaUrl");
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
redirectUri: webhookUrl,
|
||||
name: "",
|
||||
giteaUrl: "https://gitea.com",
|
||||
});
|
||||
}, [form, webhookUrl, isOpen]);
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
try {
|
||||
// Send the form data to create the Gitea provider
|
||||
const result = (await mutateAsync({
|
||||
clientId: data.clientId,
|
||||
clientSecret: data.clientSecret,
|
||||
name: data.name,
|
||||
redirectUri: data.redirectUri,
|
||||
giteaUrl: data.giteaUrl,
|
||||
organizationName: data.organizationName,
|
||||
})) as unknown as GiteaProviderResponse;
|
||||
|
||||
// Check if we have a giteaId from the response
|
||||
if (!result || !result.giteaId) {
|
||||
toast.error("Failed to get Gitea ID from response");
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate OAuth URL using the shared utility
|
||||
const authUrl = getGiteaOAuthUrl(
|
||||
result.giteaId,
|
||||
data.clientId,
|
||||
data.giteaUrl,
|
||||
baseUrl,
|
||||
);
|
||||
|
||||
// Open the Gitea OAuth URL
|
||||
if (authUrl !== "#") {
|
||||
window.open(authUrl, "_blank");
|
||||
} else {
|
||||
toast.error("Configuration Incomplete", {
|
||||
description: "Please fill in Client ID and Gitea URL first.",
|
||||
});
|
||||
}
|
||||
|
||||
toast.success("Gitea provider created successfully");
|
||||
setIsOpen(false);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
toast.error(`Error configuring Gitea: ${error.message}`);
|
||||
} else {
|
||||
toast.error("An unknown error occurred.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="flex items-center space-x-1 bg-green-700 text-white hover:bg-green-500"
|
||||
>
|
||||
<GiteaIcon />
|
||||
<span>Gitea</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl overflow-y-auto max-h-screen">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
Gitea Provider <GiteaIcon className="size-5" />
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-add-gitea"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-1"
|
||||
>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
To integrate your Gitea account, you need to create a new
|
||||
application in your Gitea settings. Follow these steps:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside text-sm text-muted-foreground">
|
||||
<li className="flex flex-row gap-2 items-center">
|
||||
Go to your Gitea settings{" "}
|
||||
<Link
|
||||
href={`${giteaUrl}/user/settings/applications`}
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="w-fit text-primary size-4" />
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
Navigate to Applications {"->"} Create new OAuth2
|
||||
Application
|
||||
</li>
|
||||
<li>
|
||||
Create a new application with the following details:
|
||||
<ul className="list-disc list-inside ml-4">
|
||||
<li>Name: Dokploy</li>
|
||||
<li>
|
||||
Redirect URI:{" "}
|
||||
<span className="text-primary">{webhookUrl}</span>{" "}
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
After creating, you'll receive an ID and Secret, copy them
|
||||
and paste them below.
|
||||
</li>
|
||||
</ol>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Random Name eg(my-personal-account)"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="giteaUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gitea URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://gitea.com/" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="redirectUri"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Redirect URI</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
disabled
|
||||
placeholder="Random Name eg(my-personal-account)"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Client ID" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client Secret</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Client Secret"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button isLoading={form.formState.isSubmitting}>
|
||||
Configure Gitea App
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,296 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { getGiteaOAuthUrl } from "@/utils/gitea-utils";
|
||||
import { useUrl } from "@/utils/hooks/use-url";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PenBoxIcon } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
giteaUrl: z.string().min(1, "Gitea URL is required"),
|
||||
clientId: z.string().min(1, "Client ID is required"),
|
||||
clientSecret: z.string().min(1, "Client Secret is required"),
|
||||
});
|
||||
|
||||
interface Props {
|
||||
giteaId: string;
|
||||
}
|
||||
|
||||
export const EditGiteaProvider = ({ giteaId }: Props) => {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const {
|
||||
data: gitea,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = api.gitea.one.useQuery({ giteaId });
|
||||
const { mutateAsync, isLoading: isUpdating } = api.gitea.update.useMutation();
|
||||
const { mutateAsync: testConnection, isLoading: isTesting } =
|
||||
api.gitea.testConnection.useMutation();
|
||||
const url = useUrl();
|
||||
const utils = api.useUtils();
|
||||
|
||||
useEffect(() => {
|
||||
const { connected, error } = router.query;
|
||||
|
||||
if (!router.isReady) return;
|
||||
|
||||
if (connected) {
|
||||
toast.success("Successfully connected to Gitea", {
|
||||
description: "Your Gitea provider has been authorized.",
|
||||
id: "gitea-connection-success",
|
||||
});
|
||||
refetch();
|
||||
router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: {},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
toast.error("Gitea Connection Failed", {
|
||||
description: decodeURIComponent(error as string),
|
||||
id: "gitea-connection-error",
|
||||
});
|
||||
router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: {},
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
}
|
||||
}, [router.query, router.isReady, refetch]);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
giteaUrl: "https://gitea.com",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (gitea) {
|
||||
form.reset({
|
||||
name: gitea.gitProvider?.name || "",
|
||||
giteaUrl: gitea.giteaUrl || "https://gitea.com",
|
||||
clientId: gitea.clientId || "",
|
||||
clientSecret: gitea.clientSecret || "",
|
||||
});
|
||||
}
|
||||
}, [gitea, form]);
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
await mutateAsync({
|
||||
giteaId: giteaId,
|
||||
gitProviderId: gitea?.gitProvider?.gitProviderId || "",
|
||||
name: values.name,
|
||||
giteaUrl: values.giteaUrl,
|
||||
clientId: values.clientId,
|
||||
clientSecret: values.clientSecret,
|
||||
})
|
||||
.then(async () => {
|
||||
await utils.gitProvider.getAll.invalidate();
|
||||
toast.success("Gitea provider updated successfully");
|
||||
await refetch();
|
||||
setOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating Gitea provider");
|
||||
});
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
try {
|
||||
const result = await testConnection({ giteaId });
|
||||
toast.success("Gitea Connection Verified", {
|
||||
description: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
const formValues = form.getValues();
|
||||
const authUrl =
|
||||
error.authorizationUrl ||
|
||||
getGiteaOAuthUrl(
|
||||
giteaId,
|
||||
formValues.clientId,
|
||||
formValues.giteaUrl,
|
||||
typeof url === "string" ? url : (url as any).url || "",
|
||||
);
|
||||
|
||||
toast.error("Gitea Not Connected", {
|
||||
description:
|
||||
error.message || "Please complete the OAuth authorization process.",
|
||||
action:
|
||||
authUrl && authUrl !== "#"
|
||||
? {
|
||||
label: "Authorize Now",
|
||||
onClick: () => window.open(authUrl, "_blank"),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Button variant="ghost" size="icon" disabled>
|
||||
<PenBoxIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Function to handle dialog open state
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Gitea Provider</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update your Gitea provider details.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Gitea"
|
||||
{...field}
|
||||
autoFocus={false}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="giteaUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Gitea URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="https://gitea.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Client ID" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="clientSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Client Secret</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Client Secret"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
isLoading={isTesting}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const formValues = form.getValues();
|
||||
const authUrl = getGiteaOAuthUrl(
|
||||
giteaId,
|
||||
formValues.clientId,
|
||||
formValues.giteaUrl,
|
||||
typeof url === "string" ? url : (url as any).url || "",
|
||||
);
|
||||
if (authUrl !== "#") {
|
||||
window.open(authUrl, "_blank");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Connect to Gitea
|
||||
</Button>
|
||||
|
||||
<Button type="submit" isLoading={isUpdating}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -248,7 +248,9 @@ export const AddGitlabProvider = () => {
|
||||
name="groupName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Group Name (Optional)</FormLabel>
|
||||
<FormLabel>
|
||||
Group Name (Optional, Comma-Separated List)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="For organization/group access use the slugish name of the group eg: my-org"
|
||||
|
||||
@@ -156,7 +156,9 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
name="groupName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Group Name (Optional)</FormLabel>
|
||||
<FormLabel>
|
||||
Group Name (Optional, Comma-Separated List)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="For organization/group access use the slugish name of the group eg: my-org"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BitbucketIcon,
|
||||
GiteaIcon,
|
||||
GithubIcon,
|
||||
GitlabIcon,
|
||||
} from "@/components/icons/data-tools-icons";
|
||||
@@ -26,6 +27,8 @@ import Link from "next/link";
|
||||
import { toast } from "sonner";
|
||||
import { AddBitbucketProvider } from "./bitbucket/add-bitbucket-provider";
|
||||
import { EditBitbucketProvider } from "./bitbucket/edit-bitbucket-provider";
|
||||
import { AddGiteaProvider } from "./gitea/add-gitea-provider";
|
||||
import { EditGiteaProvider } from "./gitea/edit-gitea-provider";
|
||||
import { AddGithubProvider } from "./github/add-github-provider";
|
||||
import { EditGithubProvider } from "./github/edit-github-provider";
|
||||
import { AddGitlabProvider } from "./gitlab/add-gitlab-provider";
|
||||
@@ -36,19 +39,18 @@ export const ShowGitProviders = () => {
|
||||
const { mutateAsync, isLoading: isRemoving } =
|
||||
api.gitProvider.remove.useMutation();
|
||||
const url = useUrl();
|
||||
|
||||
const getGitlabUrl = (
|
||||
clientId: string,
|
||||
gitlabId: string,
|
||||
gitlabUrl: string,
|
||||
) => {
|
||||
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
|
||||
|
||||
const scope = "api read_user read_repository";
|
||||
|
||||
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
|
||||
|
||||
return authUrl;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
@@ -82,6 +84,7 @@ export const ShowGitProviders = () => {
|
||||
<AddGithubProvider />
|
||||
<AddGitlabProvider />
|
||||
<AddBitbucketProvider />
|
||||
<AddGiteaProvider />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,6 +100,7 @@ export const ShowGitProviders = () => {
|
||||
<AddGithubProvider />
|
||||
<AddGitlabProvider />
|
||||
<AddBitbucketProvider />
|
||||
<AddGiteaProvider />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,13 +111,16 @@ export const ShowGitProviders = () => {
|
||||
const isGitlab = gitProvider.providerType === "gitlab";
|
||||
const isBitbucket =
|
||||
gitProvider.providerType === "bitbucket";
|
||||
const isGitea = gitProvider.providerType === "gitea";
|
||||
|
||||
const haveGithubRequirements =
|
||||
gitProvider.providerType === "github" &&
|
||||
isGithub &&
|
||||
gitProvider.github?.githubPrivateKey &&
|
||||
gitProvider.github?.githubAppId &&
|
||||
gitProvider.github?.githubInstallationId;
|
||||
|
||||
const haveGitlabRequirements =
|
||||
isGitlab &&
|
||||
gitProvider.gitlab?.accessToken &&
|
||||
gitProvider.gitlab?.refreshToken;
|
||||
|
||||
@@ -122,18 +129,19 @@ export const ShowGitProviders = () => {
|
||||
key={gitProvider.gitProviderId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex flex-col items-center justify-between">
|
||||
<div className="flex gap-2 flex-row items-center">
|
||||
{gitProvider.providerType === "github" && (
|
||||
{isGithub && (
|
||||
<GithubIcon className="size-5" />
|
||||
)}
|
||||
{gitProvider.providerType === "gitlab" && (
|
||||
{isGitlab && (
|
||||
<GitlabIcon className="size-5" />
|
||||
)}
|
||||
{gitProvider.providerType === "bitbucket" && (
|
||||
{isBitbucket && (
|
||||
<BitbucketIcon className="size-5" />
|
||||
)}
|
||||
{isGitea && <GiteaIcon className="size-5" />}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
{gitProvider.name}
|
||||
@@ -194,26 +202,33 @@ export const ShowGitProviders = () => {
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGithub && haveGithubRequirements && (
|
||||
<EditGithubProvider
|
||||
githubId={gitProvider.github.githubId}
|
||||
githubId={gitProvider.github?.githubId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitlab && (
|
||||
<EditGitlabProvider
|
||||
gitlabId={gitProvider.gitlab.gitlabId}
|
||||
gitlabId={gitProvider.gitlab?.gitlabId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBitbucket && (
|
||||
<EditBitbucketProvider
|
||||
bitbucketId={
|
||||
gitProvider.bitbucket.bitbucketId
|
||||
gitProvider.bitbucket?.bitbucketId
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isGitea && (
|
||||
<EditGiteaProvider
|
||||
giteaId={gitProvider.gitea?.giteaId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogAction
|
||||
title="Delete Git Provider"
|
||||
description="Are you sure you want to delete this Git Provider?"
|
||||
@@ -238,7 +253,7 @@ export const ShowGitProviders = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 "
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user