mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-01 20:15:29 +02:00
Compare commits
245 Commits
v0.26.0
...
fix/preven
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5967f48c6b | ||
|
|
24c1c2a377 | ||
|
|
15e90e9ca9 | ||
|
|
d1553e1bda | ||
|
|
880a377e54 | ||
|
|
74e0bd5fe3 | ||
|
|
7362cc49d2 | ||
|
|
84fa805acc | ||
|
|
bc6647071f | ||
|
|
dd10d0b1a4 | ||
|
|
9714695d5a | ||
|
|
37e817ff26 | ||
|
|
733f4c4a23 | ||
|
|
86548a1f24 | ||
|
|
dbd354d928 | ||
|
|
9a9e3dc295 | ||
|
|
cbd70fe5d0 | ||
|
|
c8ec86c639 | ||
|
|
b902c160a2 | ||
|
|
8f2a0f8029 | ||
|
|
f334e89108 | ||
|
|
a8fc2adab6 | ||
|
|
b8d8d9e5b2 | ||
|
|
6c2457907f | ||
|
|
36f082f12a | ||
|
|
f3f52c21ab | ||
|
|
9c565656b1 | ||
|
|
983c8d5e9e | ||
|
|
9a7b7c0c23 | ||
|
|
a76147d820 | ||
|
|
7e48b2cf29 | ||
|
|
a0d8eb9380 | ||
|
|
e5fcc10db2 | ||
|
|
a33c6bcce4 | ||
|
|
5aa5b5538c | ||
|
|
49e52ac674 | ||
|
|
2a8387bcc2 | ||
|
|
138b193577 | ||
|
|
f0400495b0 | ||
|
|
240e5cb12f | ||
|
|
2760c16ade | ||
|
|
79655b5673 | ||
|
|
384fdd01d6 | ||
|
|
c93ec1f06c | ||
|
|
7b3f0273cb | ||
|
|
66ed6e07c0 | ||
|
|
c1d452bcf7 | ||
|
|
f39b511316 | ||
|
|
a2df52ea7c | ||
|
|
3e5a189177 | ||
|
|
2b9231dcd1 | ||
|
|
5d26df9d9f | ||
|
|
b965dedd7d | ||
|
|
2b779f9fc6 | ||
|
|
15b0ca7ab2 | ||
|
|
fd6f61fd2a | ||
|
|
8f95546535 | ||
|
|
8b370d4f7b | ||
|
|
1ed941b17c | ||
|
|
18d980c3ff | ||
|
|
5ddcdd843c | ||
|
|
fdf88b1ff3 | ||
|
|
13b64e45ec | ||
|
|
4383e46686 | ||
|
|
60d69d2915 | ||
|
|
a2b16d4be8 | ||
|
|
831a1815cf | ||
|
|
6b9bcbc539 | ||
|
|
6ca6ff3530 | ||
|
|
7583d5f860 | ||
|
|
7921f754fd | ||
|
|
0c0944d221 | ||
|
|
d490111a58 | ||
|
|
167daccee0 | ||
|
|
11af6a5eb9 | ||
|
|
85424badcf | ||
|
|
ccfd7f5189 | ||
|
|
6d94da1dee | ||
|
|
10c0de9d5f | ||
|
|
2b0ae65f71 | ||
|
|
2acaaede37 | ||
|
|
f303962319 | ||
|
|
edc8efe816 | ||
|
|
4e0cb2a9c7 | ||
|
|
4001f1d067 | ||
|
|
d894b2a3bf | ||
|
|
14d359dd14 | ||
|
|
1e11f603de | ||
|
|
d12f029e2b | ||
|
|
0c62bc0f29 | ||
|
|
b19d3e94eb | ||
|
|
5005f9198b | ||
|
|
fe5efd7651 | ||
|
|
8db7a421dc | ||
|
|
068deecb61 | ||
|
|
9aa03efd13 | ||
|
|
016aa0248a | ||
|
|
eb9d140c5d | ||
|
|
9e8c3f1525 | ||
|
|
611b0b3113 | ||
|
|
2eb73b988b | ||
|
|
d2ce587494 | ||
|
|
13ad8cb846 | ||
|
|
0897417d7c | ||
|
|
eb14a68bdd | ||
|
|
01c0b461b5 | ||
|
|
9498fbeff3 | ||
|
|
d2aa60ddf7 | ||
|
|
58b75205af | ||
|
|
9e03625586 | ||
|
|
260efdc2bb | ||
|
|
1b5bfe051d | ||
|
|
e4384075f2 | ||
|
|
b355d44605 | ||
|
|
f39aa23803 | ||
|
|
3abc4cdc3b | ||
|
|
ec56062f17 | ||
|
|
10c4f882a5 | ||
|
|
f1dfa9c6a2 | ||
|
|
6010643d9e | ||
|
|
1ccb205495 | ||
|
|
b2be5bc09f | ||
|
|
babd30a110 | ||
|
|
e77f276785 | ||
|
|
78c9a047b0 | ||
|
|
84e0f5856b | ||
|
|
2bfa4643fc | ||
|
|
8c7bc82712 | ||
|
|
44645a6fbe | ||
|
|
771d0dd8ab | ||
|
|
67725759e6 | ||
|
|
2065372d4f | ||
|
|
67d5e1a350 | ||
|
|
93fa19213e | ||
|
|
1988a14b24 | ||
|
|
3bdf029155 | ||
|
|
e1896c2498 | ||
|
|
a8064afd60 | ||
|
|
3849a206e8 | ||
|
|
69d5c6f0cb | ||
|
|
bb0a53d976 | ||
|
|
0a8753d0a9 | ||
|
|
23b14cf0cf | ||
|
|
53f67c6eb2 | ||
|
|
7c53a3ef75 | ||
|
|
c065c85ee6 | ||
|
|
db97de2a39 | ||
|
|
dc7af1b840 | ||
|
|
97362da2ae | ||
|
|
b476e50ff1 | ||
|
|
1b22384315 | ||
|
|
6685bd618e | ||
|
|
f5d334244a | ||
|
|
fd084c6d37 | ||
|
|
e607220bfa | ||
|
|
d8514b067b | ||
|
|
0590e78854 | ||
|
|
27fa0e881a | ||
|
|
72f2cc6268 | ||
|
|
854bd88e0a | ||
|
|
acf385a1f3 | ||
|
|
d1bc109697 | ||
|
|
38c7e1e996 | ||
|
|
54d5266573 | ||
|
|
3a5ac9d31f | ||
|
|
0ddf6b851f | ||
|
|
ed701df6ac | ||
|
|
dfc15cd621 | ||
|
|
1ac3d1c1b0 | ||
|
|
f6b756e711 | ||
|
|
9f84dd4e0d | ||
|
|
2e32b0a4af | ||
|
|
0f69bbbd20 | ||
|
|
9e79314ef4 | ||
|
|
540b4039ac | ||
|
|
9e89edf167 | ||
|
|
e31d5a723b | ||
|
|
eb4fbff1b2 | ||
|
|
3aeb52810c | ||
|
|
8eaf2ab5c7 | ||
|
|
5ebcbf86ea | ||
|
|
67f4ca2cd9 | ||
|
|
6bb5404f87 | ||
|
|
3e356e6890 | ||
|
|
b65f53d141 | ||
|
|
2b1a3db7b8 | ||
|
|
b66156956a | ||
|
|
669de0f95f | ||
|
|
371cf83e52 | ||
|
|
51abf49458 | ||
|
|
72cc7a2d2c | ||
|
|
ba5283039c | ||
|
|
19a7a80d43 | ||
|
|
5d42737943 | ||
|
|
4c10056394 | ||
|
|
d875e08d48 | ||
|
|
0b45b795e8 | ||
|
|
d187b52e09 | ||
|
|
5f13679a97 | ||
|
|
415327c246 | ||
|
|
12b8f8a4fd | ||
|
|
fea3ec9a6f | ||
|
|
2976bb5cf7 | ||
|
|
092afbe1fa | ||
|
|
a32e7e0041 | ||
|
|
c045c5328f | ||
|
|
ee9edd7ff4 | ||
|
|
3799aeab74 | ||
|
|
4f6eb51c06 | ||
|
|
7cf898dcf6 | ||
|
|
1c83919408 | ||
|
|
b230687c8a | ||
|
|
b499cefebc | ||
|
|
a04a4c05ea | ||
|
|
8c889fc71e | ||
|
|
e7dc05d031 | ||
|
|
9544b2ace3 | ||
|
|
85632fd0c2 | ||
|
|
31cdae1b72 | ||
|
|
702af64444 | ||
|
|
eef27b67c2 | ||
|
|
70f50dd8bc | ||
|
|
3e25b97b99 | ||
|
|
22927c2716 | ||
|
|
8ab4ee8e0e | ||
|
|
99aa34f27e | ||
|
|
48be8544cf | ||
|
|
ee411ac74f | ||
|
|
c233ddb520 | ||
|
|
0cfe87cb72 | ||
|
|
7998b296a2 | ||
|
|
9e20f66bf5 | ||
|
|
1dc943ef5b | ||
|
|
0f63fdac4e | ||
|
|
ec8c516aa3 | ||
|
|
58be8f91c0 | ||
|
|
2036ac3dc8 | ||
|
|
17f83f746a | ||
|
|
bcd1cbe920 | ||
|
|
3993263615 | ||
|
|
97bd4de4f1 | ||
|
|
2fc29ff7c8 | ||
|
|
4a74016b52 | ||
|
|
d465fb4da1 | ||
|
|
698104e7b7 |
BIN
.github/sponsors/awesome.png
vendored
Normal file
BIN
.github/sponsors/awesome.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
6
.github/workflows/pull-request.yml
vendored
6
.github/workflows/pull-request.yml
vendored
@@ -24,14 +24,14 @@ jobs:
|
|||||||
- name: Install Nixpacks
|
- name: Install Nixpacks
|
||||||
if: matrix.job == 'test'
|
if: matrix.job == 'test'
|
||||||
run: |
|
run: |
|
||||||
export NIXPACKS_VERSION=1.39.0
|
export NIXPACKS_VERSION=1.41.0
|
||||||
curl -sSL https://nixpacks.com/install.sh | bash
|
curl -sSL https://nixpacks.com/install.sh | bash
|
||||||
echo "Nixpacks installed $NIXPACKS_VERSION"
|
echo "Nixpacks installed $NIXPACKS_VERSION"
|
||||||
|
|
||||||
- name: Install Railpack
|
- name: Install Railpack
|
||||||
if: matrix.job == 'test'
|
if: matrix.job == 'test'
|
||||||
run: |
|
run: |
|
||||||
export RAILPACK_VERSION=0.15.0
|
export RAILPACK_VERSION=0.15.4
|
||||||
curl -sSL https://railpack.com/install.sh | bash
|
curl -sSL https://railpack.com/install.sh | bash
|
||||||
echo "Railpack installed $RAILPACK_VERSION"
|
echo "Railpack installed $RAILPACK_VERSION"
|
||||||
|
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -43,4 +43,7 @@ yarn-error.log*
|
|||||||
*.pem
|
*.pem
|
||||||
|
|
||||||
|
|
||||||
.db
|
.db
|
||||||
|
|
||||||
|
# Development environment
|
||||||
|
.devcontainer
|
||||||
@@ -148,7 +148,7 @@ curl -sSL https://railpack.com/install.sh | sh
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install Buildpacks
|
# Install Buildpacks
|
||||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.39.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pull Request
|
## Pull Request
|
||||||
|
|||||||
@@ -51,18 +51,18 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --ver
|
|||||||
# Install Nixpacks and tsx
|
# Install Nixpacks and tsx
|
||||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||||
|
|
||||||
ARG NIXPACKS_VERSION=1.39.0
|
ARG NIXPACKS_VERSION=1.41.0
|
||||||
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
||||||
&& chmod +x install.sh \
|
&& chmod +x install.sh \
|
||||||
&& ./install.sh \
|
&& ./install.sh \
|
||||||
&& pnpm install -g tsx
|
&& pnpm install -g tsx
|
||||||
|
|
||||||
# Install Railpack
|
# Install Railpack
|
||||||
ARG RAILPACK_VERSION=0.2.2
|
ARG RAILPACK_VERSION=0.15.4
|
||||||
RUN curl -sSL https://railpack.com/install.sh | bash
|
RUN curl -sSL https://railpack.com/install.sh | bash
|
||||||
|
|
||||||
# Install buildpacks
|
# Install buildpacks
|
||||||
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
|
COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD [ "pnpm", "start" ]
|
CMD [ "pnpm", "start" ]
|
||||||
|
|||||||
@@ -60,4 +60,4 @@ RUN curl https://rclone.org/install.sh | bash
|
|||||||
RUN pnpm install -g tsx
|
RUN pnpm install -g tsx
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD [ "pnpm", "start" ]
|
CMD [ "pnpm", "start" ]
|
||||||
|
|||||||
@@ -35,4 +35,4 @@ COPY --from=build /prod/schedules/dist ./dist
|
|||||||
COPY --from=build /prod/schedules/package.json ./package.json
|
COPY --from=build /prod/schedules/package.json ./package.json
|
||||||
COPY --from=build /prod/schedules/node_modules ./node_modules
|
COPY --from=build /prod/schedules/node_modules ./node_modules
|
||||||
|
|
||||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
CMD HOSTNAME=0.0.0.0 && pnpm start
|
||||||
|
|||||||
@@ -35,4 +35,4 @@ COPY --from=build /prod/api/dist ./dist
|
|||||||
COPY --from=build /prod/api/package.json ./package.json
|
COPY --from=build /prod/api/package.json ./package.json
|
||||||
COPY --from=build /prod/api/node_modules ./node_modules
|
COPY --from=build /prod/api/node_modules ./node_modules
|
||||||
|
|
||||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
CMD HOSTNAME=0.0.0.0 && pnpm start
|
||||||
|
|||||||
19
LICENSE.MD
19
LICENSE.MD
@@ -1,8 +1,13 @@
|
|||||||
# License
|
Copyright 2026-present Dokploy Technology, Inc.
|
||||||
|
|
||||||
## Core License (Apache License 2.0)
|
Portions of this software are licensed as follows:
|
||||||
|
|
||||||
Copyright 2025 Mauricio Siu.
|
* All content that resides under a "/proprietary" directory of this repository, if that directory exists, is licensed under the license defined in "LICENSE_PROPRIETARY".
|
||||||
|
* Content outside of the above mentioned directories or restrictions above is available under the "Apache License 2.0" license as defined below.
|
||||||
|
|
||||||
|
## Apache License 2.0
|
||||||
|
|
||||||
|
Copyright 2026-present Dokploy Technology, Inc.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
@@ -15,12 +20,4 @@ distributed under the License is distributed on an "AS IS" BASIS,
|
|||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
See the License for the specific language governing permissions and limitations under the License.
|
See the License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
## Additional Terms for Specific Features
|
|
||||||
|
|
||||||
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
|
||||||
|
|
||||||
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
|
|
||||||
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
|
||||||
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
|
||||||
|
|
||||||
For further inquiries or permissions, please contact us directly.
|
|
||||||
|
|||||||
11
LICENSE_PROPRIETARY.md
Normal file
11
LICENSE_PROPRIETARY.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
The Dokploy Source Available license (DSAL) version 1.0
|
||||||
|
|
||||||
|
Copyright (c) 2026-present Dokploy Technology, Inc.
|
||||||
|
|
||||||
|
With regard to the Dokploy Software:This software and associated documentation files (the "Software") may only beused in production, if you (and any entity that you represent) have agreed to, and are in compliance with, a valid commercial agreement from Dokploy.Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software. You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid Dokploy Source Available License. Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes, without requiring a subscription. You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications. You are not granted any other rights beyond what is expressly stated herein. Subject to theforegoing, it is forbidden to copy, merge, publish, distribute, sublicense,and/or sell the Software.
|
||||||
|
|
||||||
|
This Dokploy Source Available license applies only to the part of this Software that is in a /proprietary folder. The full text of this License shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.
|
||||||
|
|
||||||
|
For all third party components incorporated into the Dokploy Software, thosecomponents are licensed under the original license provided by the owner of the applicable component.
|
||||||
58
README.md
58
README.md
@@ -68,51 +68,21 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
|
|||||||
|
|
||||||
[Github Sponsors](https://github.com/sponsors/Siumauricio)
|
[Github Sponsors](https://github.com/sponsors/Siumauricio)
|
||||||
|
|
||||||
<!-- Hero Sponsors 🎖 -->
|
## Sponsors
|
||||||
|
|
||||||
<!-- Add Hero Sponsors here -->
|
| Sponsor | Logo | Supporter Level |
|
||||||
|
|---------|:----:|----------------|
|
||||||
### Hero Sponsors 🎖
|
| [Hostinger](https://www.hostinger.com/vps-hosting?ref=dokploy) | <img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="200"/> | 🎖 Hero Sponsor |
|
||||||
|
| [LX Aer](https://www.lxaer.com/?ref=dokploy) | <img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/> | 🎖 Hero Sponsor |
|
||||||
<div>
|
| [LinkDR](https://linkdr.com/?ref=dokploy) | <img src="https://dokploy.com/linkdr-logo.svg" alt="LinkDR" width="100"/> | 🎖 Hero Sponsor |
|
||||||
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
|
| [LambdaTest](https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor) | <img src="https://www.lambdatest.com/blue-logo.png" alt="LambdaTest" width="200"/> | 🎖 Hero Sponsor |
|
||||||
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
|
| [Awesome Tools](https://awesome.tools/) | <img src=".github/sponsors/awesome.png" alt="Awesome Tools" width="100"/> | 🎖 Hero Sponsor |
|
||||||
<a href="https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor" target="_blank">
|
| [Supafort](https://supafort.com/?ref=dokploy) | <img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="200"/> | 🥇 Premium Supporter |
|
||||||
<img src="https://www.lambdatest.com/blue-logo.png" width="450" height="100" />
|
| [Agentdock](https://agentdock.ai/?ref=dokploy) | <img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/> | 🥇 Premium Supporter |
|
||||||
</a>
|
| [AmericanCloud](https://americancloud.com/?ref=dokploy) | <img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="200"/> | 🥈 Elite Contributor |
|
||||||
|
| [Tolgee](https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy) | <img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/> | 🥈 Elite Contributor |
|
||||||
</div>
|
| [Cloudblast](https://cloudblast.io/?ref=dokploy) | <img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" alt="Cloudblast.io" width="150"/> | 🥉 Supporting Member |
|
||||||
|
| [Synexa](https://synexa.ai/?ref=dokploy) | <img src=".github/sponsors/synexa.png" alt="Synexa" width="100"/> | 🥉 Supporting Member |
|
||||||
<!-- Premium Supporters 🥇 -->
|
|
||||||
|
|
||||||
<!-- Add Premium Supporters here -->
|
|
||||||
|
|
||||||
### Premium Supporters 🥇
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<a href="https://supafort.com/?ref=dokploy"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="300"/></a>
|
|
||||||
<a href="https://agentdock.ai/?ref=dokploy"><img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Elite Contributors 🥈 -->
|
|
||||||
|
|
||||||
<!-- Add Elite Contributors here -->
|
|
||||||
|
|
||||||
### Elite Contributors 🥈
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<a href="https://americancloud.com/?ref=dokploy"><img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="300"/></a>
|
|
||||||
<a href="https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy"><img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### Supporting Members 🥉
|
|
||||||
|
|
||||||
<div>
|
|
||||||
|
|
||||||
<a href="https://cloudblast.io/?ref=dokploy"><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
|
|
||||||
|
|
||||||
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### Community Backers 🤝
|
### Community Backers 🤝
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
"@dokploy/server": "workspace:*",
|
"@dokploy/server": "workspace:*",
|
||||||
"@hono/node-server": "^1.14.3",
|
"@hono/node-server": "^1.14.3",
|
||||||
"@hono/zod-validator": "0.3.0",
|
"@hono/zod-validator": "0.3.0",
|
||||||
"@nerimity/mimiqueue": "1.2.3",
|
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"hono": "^4.7.10",
|
"hono": "^4.7.10",
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
|||||||
titleLog: z.string().optional(),
|
titleLog: z.string().optional(),
|
||||||
descriptionLog: z.string().optional(),
|
descriptionLog: z.string().optional(),
|
||||||
server: z.boolean().optional(),
|
server: z.boolean().optional(),
|
||||||
type: z.enum(["deploy"]),
|
type: z.enum(["deploy", "redeploy"]),
|
||||||
applicationType: z.literal("application-preview"),
|
applicationType: z.literal("application-preview"),
|
||||||
serverId: z.string().min(1),
|
serverId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
deployPreviewApplication,
|
deployPreviewApplication,
|
||||||
rebuildApplication,
|
rebuildApplication,
|
||||||
rebuildCompose,
|
rebuildCompose,
|
||||||
|
rebuildPreviewApplication,
|
||||||
updateApplicationStatus,
|
updateApplicationStatus,
|
||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
@@ -54,7 +55,14 @@ export const deploy = async (job: DeployJob) => {
|
|||||||
previewStatus: "running",
|
previewStatus: "running",
|
||||||
});
|
});
|
||||||
if (job.server) {
|
if (job.server) {
|
||||||
if (job.type === "deploy") {
|
if (job.type === "redeploy") {
|
||||||
|
await rebuildPreviewApplication({
|
||||||
|
applicationId: job.applicationId,
|
||||||
|
titleLog: job.titleLog || "Rebuild Preview Deployment",
|
||||||
|
descriptionLog: job.descriptionLog || "",
|
||||||
|
previewDeploymentId: job.previewDeploymentId,
|
||||||
|
});
|
||||||
|
} else if (job.type === "deploy") {
|
||||||
await deployPreviewApplication({
|
await deployPreviewApplication({
|
||||||
applicationId: job.applicationId,
|
applicationId: job.applicationId,
|
||||||
titleLog: job.titleLog || "Preview Deployment",
|
titleLog: job.titleLog || "Preview Deployment",
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy"
|
|
||||||
PORT=3000
|
PORT=3000
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
243
apps/dokploy/__test__/cluster/upload.test.ts
Normal file
243
apps/dokploy/__test__/cluster/upload.test.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import type { Registry } from "@dokploy/server";
|
||||||
|
import { getRegistryTag } from "@dokploy/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("getRegistryTag", () => {
|
||||||
|
// Helper to create a mock registry
|
||||||
|
const createMockRegistry = (overrides: Partial<Registry> = {}): Registry => {
|
||||||
|
return {
|
||||||
|
registryId: "test-registry-id",
|
||||||
|
registryName: "Test Registry",
|
||||||
|
username: "myuser",
|
||||||
|
password: "test-password",
|
||||||
|
registryUrl: "docker.io",
|
||||||
|
registryType: "cloud",
|
||||||
|
imagePrefix: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
organizationId: "test-org-id",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("with username (no imagePrefix)", () => {
|
||||||
|
it("should handle simple image name without tag", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("docker.io/myuser/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with tag", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "nginx:latest");
|
||||||
|
expect(result).toBe("docker.io/myuser/nginx:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with username already present (no duplication)", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe("docker.io/myuser/myprivaterepo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with username and tag already present", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo:latest");
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe("docker.io/myuser/myprivaterepo:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle complex image name with username", () => {
|
||||||
|
const registry = createMockRegistry({ username: "siumauricio" });
|
||||||
|
const result = getRegistryTag(
|
||||||
|
registry,
|
||||||
|
"siumauricio/app-parse-multi-byte-port-e32uh7",
|
||||||
|
);
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe(
|
||||||
|
"docker.io/siumauricio/app-parse-multi-byte-port-e32uh7",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with different username (should not duplicate)", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "otheruser/myprivaterepo");
|
||||||
|
expect(result).toBe("docker.io/myuser/myprivaterepo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with full registry URL (no username)", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "docker.io/nginx");
|
||||||
|
// Should add username since imageName doesn't have one
|
||||||
|
expect(result).toBe("docker.io/myuser/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with custom registry URL and username", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "ghcr.io/myuser/repo");
|
||||||
|
// Should not duplicate username even if registry URL is different
|
||||||
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with custom registry URL (different username)", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "ghcr.io/otheruser/repo");
|
||||||
|
// Should use registry username, not the one in imageName
|
||||||
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("with imagePrefix", () => {
|
||||||
|
it("should use imagePrefix instead of username", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("docker.io/myorg/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use imagePrefix with image tag", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx:latest");
|
||||||
|
expect(result).toBe("docker.io/myorg/nginx:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle imagePrefix with username already in image name", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||||
|
expect(result).toBe("docker.io/myorg/myprivaterepo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle imagePrefix matching image name prefix", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myorg/myprivaterepo");
|
||||||
|
// Should not duplicate prefix
|
||||||
|
expect(result).toBe("docker.io/myorg/myprivaterepo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("without registryUrl", () => {
|
||||||
|
it("should work without registryUrl", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
registryUrl: "",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("myuser/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should work without registryUrl with imagePrefix", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
registryUrl: "",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("myorg/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle username already present without registryUrl", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
registryUrl: "",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe("myuser/myprivaterepo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("with custom registryUrl", () => {
|
||||||
|
it("should handle custom registry URL", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
registryUrl: "ghcr.io",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("ghcr.io/myuser/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle custom registry URL with imagePrefix", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
registryUrl: "ghcr.io",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("ghcr.io/myorg/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle custom registry URL with username already present", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
registryUrl: "ghcr.io",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe("ghcr.io/myuser/myprivaterepo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle empty image name", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "");
|
||||||
|
expect(result).toBe("docker.io/myuser/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with multiple slashes", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "org/suborg/repo");
|
||||||
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with username at different position", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "org/myuser/repo");
|
||||||
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("special characters in username", () => {
|
||||||
|
it("should handle Harbor robot account username with $ (e.g. robot$library+dokploy)", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "robot$library+dokploy",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("docker.io/robot$library+dokploy/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle username with $ and other special characters", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "robot$test+app",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myapp:latest");
|
||||||
|
expect(result).toBe("docker.io/robot$test+app/myapp:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle username with multiple $ symbols", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "user$name$test",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "app");
|
||||||
|
expect(result).toBe("docker.io/user$name$test/app");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle username with + and - symbols", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "robot+test-user",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx:latest");
|
||||||
|
expect(result).toBe("docker.io/robot+test-user/nginx:latest");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Domain } from "@dokploy/server";
|
import type { Domain } from "@dokploy/server";
|
||||||
import { createDomainLabels } from "@dokploy/server";
|
import { createDomainLabels } from "@dokploy/server";
|
||||||
import { parse, stringify } from "yaml";
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parse, stringify } from "yaml";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regression tests for Traefik Host rule label format.
|
* Regression tests for Traefik Host rule label format.
|
||||||
|
|||||||
@@ -25,9 +25,10 @@ if (typeof window === "undefined") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseApp: ApplicationNested = {
|
const baseApp: ApplicationNested = {
|
||||||
railpackVersion: "0.2.2",
|
railpackVersion: "0.15.4",
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
|
createEnvFile: true,
|
||||||
herokuVersion: "",
|
herokuVersion: "",
|
||||||
giteaBranch: "",
|
giteaBranch: "",
|
||||||
buildServerId: "",
|
buildServerId: "",
|
||||||
@@ -67,6 +68,7 @@ const baseApp: ApplicationNested = {
|
|||||||
previewWildcard: "",
|
previewWildcard: "",
|
||||||
environment: {
|
environment: {
|
||||||
env: "",
|
env: "",
|
||||||
|
isDefault: false,
|
||||||
environmentId: "",
|
environmentId: "",
|
||||||
name: "",
|
name: "",
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
|
|||||||
294
apps/dokploy/__test__/env/environment-access-fallback.test.ts
vendored
Normal file
294
apps/dokploy/__test__/env/environment-access-fallback.test.ts
vendored
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// Type definitions matching the project structure
|
||||||
|
type Environment = {
|
||||||
|
environmentId: string;
|
||||||
|
name: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Project = {
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
environments: Environment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function that selects the appropriate environment for a user
|
||||||
|
* This matches the logic used in search-command.tsx and show.tsx
|
||||||
|
*/
|
||||||
|
function selectAccessibleEnvironment(
|
||||||
|
project: Project | null | undefined,
|
||||||
|
): Environment | null {
|
||||||
|
if (!project || !project.environments || project.environments.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find default environment from accessible environments, or fall back to first accessible environment
|
||||||
|
const defaultEnvironment =
|
||||||
|
project.environments.find((environment) => environment.isDefault) ||
|
||||||
|
project.environments[0];
|
||||||
|
|
||||||
|
return defaultEnvironment || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Environment Access Fallback", () => {
|
||||||
|
describe("selectAccessibleEnvironment", () => {
|
||||||
|
it("should return default environment when user has access to it", () => {
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
environmentId: "env-prod",
|
||||||
|
name: "production",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-dev",
|
||||||
|
name: "development",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.environmentId).toBe("env-prod");
|
||||||
|
expect(result?.isDefault).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return first accessible environment when user doesn't have access to default", () => {
|
||||||
|
// Simulating filtered environments (user only has access to development)
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: [
|
||||||
|
// Note: production is not in the list because user doesn't have access
|
||||||
|
{
|
||||||
|
environmentId: "env-dev",
|
||||||
|
name: "development",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-staging",
|
||||||
|
name: "staging",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.environmentId).toBe("env-dev");
|
||||||
|
expect(result?.name).toBe("development");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return first environment when no default is marked but environments exist", () => {
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
environmentId: "env-dev",
|
||||||
|
name: "development",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-staging",
|
||||||
|
name: "staging",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.environmentId).toBe("env-dev");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when project has no accessible environments", () => {
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when project is null", () => {
|
||||||
|
const result = selectAccessibleEnvironment(null);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return null when project is undefined", () => {
|
||||||
|
const result = selectAccessibleEnvironment(undefined);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle project with single accessible environment", () => {
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
environmentId: "env-dev",
|
||||||
|
name: "development",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.environmentId).toBe("env-dev");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should prioritize default environment even when it's not first in the array", () => {
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
environmentId: "env-dev",
|
||||||
|
name: "development",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-staging",
|
||||||
|
name: "staging",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-prod",
|
||||||
|
name: "production",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.environmentId).toBe("env-prod");
|
||||||
|
expect(result?.isDefault).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multiple default environments by returning the first one found", () => {
|
||||||
|
// Edge case: multiple environments marked as default (shouldn't happen, but test it)
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
environmentId: "env-prod-1",
|
||||||
|
name: "production-1",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-prod-2",
|
||||||
|
name: "production-2",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.isDefault).toBe(true);
|
||||||
|
// Should return the first default found
|
||||||
|
expect(result?.environmentId).toBe("env-prod-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should work correctly when user has access to multiple environments including default", () => {
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
environmentId: "env-prod",
|
||||||
|
name: "production",
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-dev",
|
||||||
|
name: "development",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environmentId: "env-staging",
|
||||||
|
name: "staging",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.environmentId).toBe("env-prod");
|
||||||
|
expect(result?.isDefault).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle real-world scenario: user with only development access", () => {
|
||||||
|
// This simulates the exact bug we're fixing:
|
||||||
|
// User has access to development but not production (default)
|
||||||
|
// The filtered environments array only contains development
|
||||||
|
const project: Project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "My Project",
|
||||||
|
environments: [
|
||||||
|
// Only development is accessible (production was filtered out)
|
||||||
|
{
|
||||||
|
environmentId: "env-dev-123",
|
||||||
|
name: "development",
|
||||||
|
isDefault: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.environmentId).toBe("env-dev-123");
|
||||||
|
expect(result?.name).toBe("development");
|
||||||
|
// Should not be null even though it's not the default
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Environment selection edge cases", () => {
|
||||||
|
it("should handle project with environments property as undefined", () => {
|
||||||
|
const project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: undefined,
|
||||||
|
} as unknown as Project;
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle project with null environments array", () => {
|
||||||
|
const project = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
name: "Test Project",
|
||||||
|
environments: null,
|
||||||
|
} as unknown as Project;
|
||||||
|
|
||||||
|
const result = selectAccessibleEnvironment(project);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
184
apps/dokploy/__test__/env/stack-environment.test.ts
vendored
Normal file
184
apps/dokploy/__test__/env/stack-environment.test.ts
vendored
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { getEnviromentVariablesObject } from "@dokploy/server/index";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const projectEnv = `
|
||||||
|
ENVIRONMENT=staging
|
||||||
|
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
|
||||||
|
PORT=3000
|
||||||
|
`;
|
||||||
|
|
||||||
|
const environmentEnv = `
|
||||||
|
NODE_ENV=development
|
||||||
|
API_URL=https://api.dev.example.com
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
DATABASE_NAME=dev_database
|
||||||
|
SECRET_KEY=env-secret-123
|
||||||
|
`;
|
||||||
|
|
||||||
|
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
|
||||||
|
it("resolves environment variables correctly for Stack compose", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
FOO=\${{environment.NODE_ENV}}
|
||||||
|
BAR=\${{environment.API_URL}}
|
||||||
|
BAZ=test
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
serviceEnv,
|
||||||
|
projectEnv,
|
||||||
|
environmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
FOO: "development",
|
||||||
|
BAR: "https://api.dev.example.com",
|
||||||
|
BAZ: "test",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves both project and environment variables for Stack compose", () => {
|
||||||
|
const serviceEnv = `
|
||||||
|
ENVIRONMENT=\${{project.ENVIRONMENT}}
|
||||||
|
NODE_ENV=\${{environment.NODE_ENV}}
|
||||||
|
API_URL=\${{environment.API_URL}}
|
||||||
|
DATABASE_URL=\${{project.DATABASE_URL}}
|
||||||
|
SERVICE_PORT=4000
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
serviceEnv,
|
||||||
|
projectEnv,
|
||||||
|
environmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ENVIRONMENT: "staging",
|
||||||
|
NODE_ENV: "development",
|
||||||
|
API_URL: "https://api.dev.example.com",
|
||||||
|
DATABASE_URL: "postgres://postgres:postgres@localhost:5432/project_db",
|
||||||
|
SERVICE_PORT: "4000",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple environment references in single value for Stack compose", () => {
|
||||||
|
const multiRefEnv = `
|
||||||
|
HOST=localhost
|
||||||
|
PORT=5432
|
||||||
|
USERNAME=postgres
|
||||||
|
PASSWORD=secret123
|
||||||
|
`;
|
||||||
|
|
||||||
|
const serviceEnv = `
|
||||||
|
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error for undefined environment variables in Stack compose", () => {
|
||||||
|
const serviceWithUndefined = `
|
||||||
|
UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
|
||||||
|
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows service variables to override environment variables in Stack compose", () => {
|
||||||
|
const serviceOverrideEnv = `
|
||||||
|
NODE_ENV=production
|
||||||
|
API_URL=\${{environment.API_URL}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
serviceOverrideEnv,
|
||||||
|
"",
|
||||||
|
environmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
NODE_ENV: "production",
|
||||||
|
API_URL: "https://api.dev.example.com",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves complex references with project, environment, and service variables for Stack compose", () => {
|
||||||
|
const complexServiceEnv = `
|
||||||
|
FULL_DATABASE_URL=\${{project.DATABASE_URL}}/\${{environment.DATABASE_NAME}}
|
||||||
|
API_ENDPOINT=\${{environment.API_URL}}/\${{project.ENVIRONMENT}}/api
|
||||||
|
SERVICE_NAME=my-service
|
||||||
|
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
complexServiceEnv,
|
||||||
|
projectEnv,
|
||||||
|
environmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
FULL_DATABASE_URL:
|
||||||
|
"postgres://postgres:postgres@localhost:5432/project_db/dev_database",
|
||||||
|
API_ENDPOINT: "https://api.dev.example.com/staging/api",
|
||||||
|
SERVICE_NAME: "my-service",
|
||||||
|
COMPLEX_VAR: "my-service-development-staging",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maintains precedence: service > environment > project in Stack compose", () => {
|
||||||
|
const conflictingProjectEnv = `
|
||||||
|
NODE_ENV=production-project
|
||||||
|
API_URL=https://project.api.com
|
||||||
|
DATABASE_NAME=project_db
|
||||||
|
`;
|
||||||
|
|
||||||
|
const conflictingEnvironmentEnv = `
|
||||||
|
NODE_ENV=development-environment
|
||||||
|
API_URL=https://environment.api.com
|
||||||
|
DATABASE_NAME=env_db
|
||||||
|
`;
|
||||||
|
|
||||||
|
const serviceWithConflicts = `
|
||||||
|
NODE_ENV=service-override
|
||||||
|
PROJECT_ENV=\${{project.NODE_ENV}}
|
||||||
|
ENV_VAR=\${{environment.API_URL}}
|
||||||
|
DB_NAME=\${{environment.DATABASE_NAME}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
serviceWithConflicts,
|
||||||
|
conflictingProjectEnv,
|
||||||
|
conflictingEnvironmentEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
NODE_ENV: "service-override",
|
||||||
|
PROJECT_ENV: "production-project",
|
||||||
|
ENV_VAR: "https://environment.api.com",
|
||||||
|
DB_NAME: "env_db",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty environment variables in Stack compose", () => {
|
||||||
|
const serviceWithEmpty = `
|
||||||
|
SERVICE_VAR=test
|
||||||
|
PROJECT_VAR=\${{project.ENVIRONMENT}}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = getEnviromentVariablesObject(
|
||||||
|
serviceWithEmpty,
|
||||||
|
projectEnv,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
SERVICE_VAR: "test",
|
||||||
|
PROJECT_VAR: "staging",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,4 +54,22 @@ describe("processLogs", () => {
|
|||||||
const result = parseRawConfig(entryWithWhitespace);
|
const result = parseRawConfig(entryWithWhitespace);
|
||||||
expect(result.data).toHaveLength(2);
|
expect(result.data).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should filter out Dokploy dashboard requests", () => {
|
||||||
|
const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`;
|
||||||
|
|
||||||
|
// Test with only Dokploy dashboard entry - should be filtered out
|
||||||
|
const resultOnlyDokploy = parseRawConfig(dokployDashboardEntry);
|
||||||
|
expect(resultOnlyDokploy.data).toHaveLength(0);
|
||||||
|
expect(resultOnlyDokploy.totalCount).toBe(0);
|
||||||
|
|
||||||
|
// Test with mixed entries - Dokploy should be filtered, others should remain
|
||||||
|
const mixedEntries = `${dokployDashboardEntry}\n${sampleLogEntry}`;
|
||||||
|
const resultMixed = parseRawConfig(mixedEntries);
|
||||||
|
expect(resultMixed.data).toHaveLength(1);
|
||||||
|
expect(resultMixed.totalCount).toBe(1);
|
||||||
|
expect(resultMixed.data[0]?.ServiceName).not.toBe(
|
||||||
|
"dokploy-service-app@file",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -161,6 +161,50 @@ describe("helpers functions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Empty string variables", () => {
|
||||||
|
it("should replace variables with empty string values correctly", () => {
|
||||||
|
const variables = {
|
||||||
|
smtp_username: "",
|
||||||
|
smtp_password: "",
|
||||||
|
non_empty: "value",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result1 = processValue("${smtp_username}", variables, mockSchema);
|
||||||
|
expect(result1).toBe("");
|
||||||
|
|
||||||
|
const result2 = processValue("${smtp_password}", variables, mockSchema);
|
||||||
|
expect(result2).toBe("");
|
||||||
|
|
||||||
|
const result3 = processValue("${non_empty}", variables, mockSchema);
|
||||||
|
expect(result3).toBe("value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not replace undefined variables", () => {
|
||||||
|
const variables = {
|
||||||
|
defined_var: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = processValue("${undefined_var}", variables, mockSchema);
|
||||||
|
expect(result).toBe("${undefined_var}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle mixed empty and non-empty variables in template", () => {
|
||||||
|
const variables = {
|
||||||
|
smtp_address: "smtp.example.com",
|
||||||
|
smtp_port: "2525",
|
||||||
|
smtp_username: "",
|
||||||
|
smtp_password: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const template =
|
||||||
|
"SMTP_ADDRESS=${smtp_address} SMTP_PORT=${smtp_port} SMTP_USERNAME=${smtp_username} SMTP_PASSWORD=${smtp_password}";
|
||||||
|
const result = processValue(template, variables, mockSchema);
|
||||||
|
expect(result).toBe(
|
||||||
|
"SMTP_ADDRESS=smtp.example.com SMTP_PORT=2525 SMTP_USERNAME= SMTP_PASSWORD=",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("${jwt}", () => {
|
describe("${jwt}", () => {
|
||||||
it("should generate a JWT string", () => {
|
it("should generate a JWT string", () => {
|
||||||
const jwt = processValue("${jwt}", {}, mockSchema);
|
const jwt = processValue("${jwt}", {}, mockSchema);
|
||||||
|
|||||||
@@ -5,21 +5,27 @@ vi.mock("node:fs", () => ({
|
|||||||
default: fs,
|
default: fs,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import type { FileConfig, User } from "@dokploy/server";
|
import type { FileConfig } from "@dokploy/server";
|
||||||
import {
|
import {
|
||||||
createDefaultServerTraefikConfig,
|
createDefaultServerTraefikConfig,
|
||||||
loadOrCreateConfig,
|
loadOrCreateConfig,
|
||||||
updateServerTraefik,
|
updateServerTraefik,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
import type { webServerSettings } from "@dokploy/server/db/schema";
|
||||||
import { beforeEach, expect, test, vi } from "vitest";
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
const baseAdmin: User = {
|
type WebServerSettings = typeof webServerSettings.$inferSelect;
|
||||||
|
|
||||||
|
const baseSettings: WebServerSettings = {
|
||||||
|
id: "",
|
||||||
https: false,
|
https: false,
|
||||||
enablePaidFeatures: false,
|
certificateType: "none",
|
||||||
allowImpersonation: false,
|
host: null,
|
||||||
role: "user",
|
serverIp: null,
|
||||||
firstName: "",
|
letsEncryptEmail: null,
|
||||||
lastName: "",
|
sshPrivateKey: null,
|
||||||
|
enableDockerCleanup: false,
|
||||||
|
logCleanupCron: null,
|
||||||
metricsConfig: {
|
metricsConfig: {
|
||||||
containers: {
|
containers: {
|
||||||
refreshRate: 20,
|
refreshRate: 20,
|
||||||
@@ -45,29 +51,8 @@ const baseAdmin: User = {
|
|||||||
cleanupCacheApplications: false,
|
cleanupCacheApplications: false,
|
||||||
cleanupCacheOnCompose: false,
|
cleanupCacheOnCompose: false,
|
||||||
cleanupCacheOnPreviews: false,
|
cleanupCacheOnPreviews: false,
|
||||||
createdAt: new Date(),
|
createdAt: null,
|
||||||
serverIp: null,
|
|
||||||
certificateType: "none",
|
|
||||||
host: null,
|
|
||||||
letsEncryptEmail: null,
|
|
||||||
sshPrivateKey: null,
|
|
||||||
enableDockerCleanup: false,
|
|
||||||
logCleanupCron: null,
|
|
||||||
serversQuantity: 0,
|
|
||||||
stripeCustomerId: "",
|
|
||||||
stripeSubscriptionId: "",
|
|
||||||
banExpires: new Date(),
|
|
||||||
banned: true,
|
|
||||||
banReason: "",
|
|
||||||
email: "",
|
|
||||||
expirationDate: "",
|
|
||||||
id: "",
|
|
||||||
isRegistered: false,
|
|
||||||
createdAt2: new Date().toISOString(),
|
|
||||||
emailVerified: false,
|
|
||||||
image: "",
|
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
twoFactorEnabled: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -85,7 +70,7 @@ test("Should read the configuration file", () => {
|
|||||||
test("Should apply redirect-to-https", () => {
|
test("Should apply redirect-to-https", () => {
|
||||||
updateServerTraefik(
|
updateServerTraefik(
|
||||||
{
|
{
|
||||||
...baseAdmin,
|
...baseSettings,
|
||||||
https: true,
|
https: true,
|
||||||
certificateType: "letsencrypt",
|
certificateType: "letsencrypt",
|
||||||
},
|
},
|
||||||
@@ -100,7 +85,7 @@ test("Should apply redirect-to-https", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("Should change only host when no certificate", () => {
|
test("Should change only host when no certificate", () => {
|
||||||
updateServerTraefik(baseAdmin, "example.com");
|
updateServerTraefik(baseSettings, "example.com");
|
||||||
|
|
||||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
@@ -110,7 +95,7 @@ test("Should change only host when no certificate", () => {
|
|||||||
test("Should not touch config without host", () => {
|
test("Should not touch config without host", () => {
|
||||||
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
|
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
updateServerTraefik(baseAdmin, null);
|
updateServerTraefik(baseSettings, null);
|
||||||
|
|
||||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
@@ -119,11 +104,14 @@ test("Should not touch config without host", () => {
|
|||||||
|
|
||||||
test("Should remove websecure if https rollback to http", () => {
|
test("Should remove websecure if https rollback to http", () => {
|
||||||
updateServerTraefik(
|
updateServerTraefik(
|
||||||
{ ...baseAdmin, certificateType: "letsencrypt" },
|
{ ...baseSettings, certificateType: "letsencrypt" },
|
||||||
"example.com",
|
"example.com",
|
||||||
);
|
);
|
||||||
|
|
||||||
updateServerTraefik({ ...baseAdmin, certificateType: "none" }, "example.com");
|
updateServerTraefik(
|
||||||
|
{ ...baseSettings, certificateType: "none" },
|
||||||
|
"example.com",
|
||||||
|
);
|
||||||
|
|
||||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { createRouterConfig } from "@dokploy/server";
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
|
|
||||||
const baseApp: ApplicationNested = {
|
const baseApp: ApplicationNested = {
|
||||||
railpackVersion: "0.2.2",
|
railpackVersion: "0.15.4",
|
||||||
rollbackActive: false,
|
rollbackActive: false,
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
|
createEnvFile: true,
|
||||||
herokuVersion: "",
|
herokuVersion: "",
|
||||||
giteaRepository: "",
|
giteaRepository: "",
|
||||||
giteaOwner: "",
|
giteaOwner: "",
|
||||||
@@ -49,6 +50,7 @@ const baseApp: ApplicationNested = {
|
|||||||
environmentId: "",
|
environmentId: "",
|
||||||
environment: {
|
environment: {
|
||||||
env: "",
|
env: "",
|
||||||
|
isDefault: false,
|
||||||
environmentId: "",
|
environmentId: "",
|
||||||
name: "",
|
name: "",
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,154 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const endpointSpecFormSchema = z.object({
|
||||||
|
Mode: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface EndpointSpecFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EndpointSpecForm = ({ id, type }: EndpointSpecFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(endpointSpecFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Mode: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.endpointSpecSwarm) {
|
||||||
|
const es = data.endpointSpecSwarm;
|
||||||
|
form.reset({
|
||||||
|
Mode: es.Mode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: z.infer<typeof endpointSpecFormSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue =
|
||||||
|
formData.Mode !== undefined &&
|
||||||
|
formData.Mode !== null &&
|
||||||
|
formData.Mode !== "";
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
endpointSpecSwarm: hasAnyValue ? formData : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Endpoint spec updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating endpoint spec");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Mode"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Mode</FormLabel>
|
||||||
|
<FormDescription>Endpoint mode (vip or dnsrr)</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select endpoint mode" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="vip">VIP (Virtual IP)</SelectItem>
|
||||||
|
<SelectItem value="dnsrr">DNS Round Robin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Mode: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Endpoint Spec
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const healthCheckFormSchema = z.object({
|
||||||
|
Test: z.array(z.string()).optional(),
|
||||||
|
Interval: z.coerce.number().optional(),
|
||||||
|
Timeout: z.coerce.number().optional(),
|
||||||
|
StartPeriod: z.coerce.number().optional(),
|
||||||
|
Retries: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface HealthCheckFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [testCommands, setTestCommands] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(healthCheckFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Test: [],
|
||||||
|
Interval: undefined,
|
||||||
|
Timeout: undefined,
|
||||||
|
StartPeriod: undefined,
|
||||||
|
Retries: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.healthCheckSwarm) {
|
||||||
|
const hc = data.healthCheckSwarm;
|
||||||
|
form.reset({
|
||||||
|
Test: hc.Test || [],
|
||||||
|
Interval: hc.Interval,
|
||||||
|
Timeout: hc.Timeout,
|
||||||
|
StartPeriod: hc.StartPeriod,
|
||||||
|
Retries: hc.Retries,
|
||||||
|
});
|
||||||
|
setTestCommands(hc.Test || []);
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: z.infer<typeof healthCheckFormSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue =
|
||||||
|
(formData.Test && formData.Test.length > 0) ||
|
||||||
|
formData.Interval !== undefined ||
|
||||||
|
formData.Timeout !== undefined ||
|
||||||
|
formData.StartPeriod !== undefined ||
|
||||||
|
formData.Retries !== undefined;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
healthCheckSwarm: hasAnyValue ? formData : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Health check updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating health check");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTestCommand = () => {
|
||||||
|
setTestCommands([...testCommands, ""]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTestCommand = (index: number, value: string) => {
|
||||||
|
const newCommands = [...testCommands];
|
||||||
|
newCommands[index] = value;
|
||||||
|
setTestCommands(newCommands);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTestCommand = (index: number) => {
|
||||||
|
setTestCommands(testCommands.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<FormLabel>Test Commands</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Command to run for health check (e.g., ["CMD-SHELL", "curl -f
|
||||||
|
http://localhost:3000/health"])
|
||||||
|
</FormDescription>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{testCommands.map((cmd, index) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={cmd}
|
||||||
|
onChange={(e) => updateTestCommand(index, e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
index === 0
|
||||||
|
? "CMD-SHELL"
|
||||||
|
: "curl -f http://localhost:3000/health"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeTestCommand(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addTestCommand}
|
||||||
|
>
|
||||||
|
Add Command
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Interval"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Interval (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Time between health checks (e.g., 10000000000 for 10 seconds)
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Timeout"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Timeout (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum time to wait for health check response
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="StartPeriod"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Start Period (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Initial grace period before health checks begin
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Retries"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Retries</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Number of consecutive failures needed to consider container
|
||||||
|
unhealthy
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="3" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Test: [],
|
||||||
|
Interval: undefined,
|
||||||
|
Timeout: undefined,
|
||||||
|
StartPeriod: undefined,
|
||||||
|
Retries: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Health Check
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export { HealthCheckForm } from "./health-check-form";
|
||||||
|
export { RestartPolicyForm } from "./restart-policy-form";
|
||||||
|
export { PlacementForm } from "./placement-form";
|
||||||
|
export { UpdateConfigForm } from "./update-config-form";
|
||||||
|
export { RollbackConfigForm } from "./rollback-config-form";
|
||||||
|
export { ModeForm } from "./mode-form";
|
||||||
|
export { LabelsForm } from "./labels-form";
|
||||||
|
export { StopGracePeriodForm } from "./stop-grace-period-form";
|
||||||
|
export { EndpointSpecForm } from "./endpoint-spec-form";
|
||||||
|
export { filterEmptyValues, hasValues } from "./utils";
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const labelsFormSchema = z.object({
|
||||||
|
labels: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
key: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface LabelsFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LabelsForm = ({ id, type }: LabelsFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(labelsFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
labels: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "labels",
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.labelsSwarm && typeof data.labelsSwarm === "object") {
|
||||||
|
const labelEntries = Object.entries(data.labelsSwarm).map(
|
||||||
|
([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: value as string,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
form.reset({ labels: labelEntries });
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: z.infer<typeof labelsFormSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const labelsObject =
|
||||||
|
formData.labels?.reduce(
|
||||||
|
(acc, { key, value }) => {
|
||||||
|
if (key && value) {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
) || {};
|
||||||
|
|
||||||
|
// If no labels, send null to clear the database
|
||||||
|
const labelsToSend =
|
||||||
|
Object.keys(labelsObject).length > 0 ? labelsObject : null;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
labelsSwarm: labelsToSend,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Labels updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating labels");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<FormLabel>Labels</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Add key-value labels to your service
|
||||||
|
</FormDescription>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<div key={field.id} className="flex gap-2">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`labels.${index}.key`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="com.example.app.name" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`labels.${index}.value`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="my-app" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => append({ key: "", value: "" })}
|
||||||
|
>
|
||||||
|
Add Label
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({ labels: [] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Labels
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
interface ModeFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModeForm = ({ id, type }: ModeFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
defaultValues: {
|
||||||
|
type: undefined,
|
||||||
|
Replicas: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const modeType = form.watch("type");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.modeSwarm) {
|
||||||
|
const mode = data.modeSwarm;
|
||||||
|
if (mode.Replicated) {
|
||||||
|
form.reset({
|
||||||
|
type: "Replicated",
|
||||||
|
Replicas: mode.Replicated.Replicas,
|
||||||
|
});
|
||||||
|
} else if (mode.Global) {
|
||||||
|
form.reset({
|
||||||
|
type: "Global",
|
||||||
|
Replicas: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: any) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// If no type is selected, send null to clear the database
|
||||||
|
if (!formData.type) {
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
modeSwarm: null,
|
||||||
|
});
|
||||||
|
toast.success("Mode updated successfully");
|
||||||
|
refetch();
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeData =
|
||||||
|
formData.type === "Replicated"
|
||||||
|
? { Replicated: { Replicas: formData.Replicas } }
|
||||||
|
: { Global: {} };
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
modeSwarm: modeData,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Mode updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating mode");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="type"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Mode Type</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Choose between replicated or global service mode
|
||||||
|
</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select mode type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Replicated">Replicated</SelectItem>
|
||||||
|
<SelectItem value="Global">Global</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{modeType === "Replicated" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Replicas"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Replicas</FormLabel>
|
||||||
|
<FormDescription>Number of replicas to run</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
type: undefined,
|
||||||
|
Replicas: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Mode
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const PreferenceSchema = z.object({
|
||||||
|
Spread: z.object({
|
||||||
|
SpreadDescriptor: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PlatformSchema = z.object({
|
||||||
|
Architecture: z.string(),
|
||||||
|
OS: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const placementFormSchema = z.object({
|
||||||
|
Constraints: z.array(z.string()).optional(),
|
||||||
|
Preferences: z.array(PreferenceSchema).optional(),
|
||||||
|
MaxReplicas: z.coerce.number().optional(),
|
||||||
|
Platforms: z.array(PlatformSchema).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface PlacementFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlacementForm = ({ id, type }: PlacementFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(placementFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Constraints: [],
|
||||||
|
Preferences: [],
|
||||||
|
MaxReplicas: undefined,
|
||||||
|
Platforms: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const constraints = form.watch("Constraints") || [];
|
||||||
|
const preferences = form.watch("Preferences") || [];
|
||||||
|
const platforms = form.watch("Platforms") || [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.placementSwarm) {
|
||||||
|
const placement = data.placementSwarm;
|
||||||
|
form.reset({
|
||||||
|
Constraints: placement.Constraints || [],
|
||||||
|
Preferences:
|
||||||
|
placement.Preferences?.map((p: any) => ({
|
||||||
|
SpreadDescriptor: p.Spread?.SpreadDescriptor || "",
|
||||||
|
})) || [],
|
||||||
|
MaxReplicas: placement.MaxReplicas,
|
||||||
|
Platforms: placement.Platforms || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: z.infer<typeof placementFormSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue =
|
||||||
|
(formData.Constraints && formData.Constraints.length > 0) ||
|
||||||
|
(formData.Preferences && formData.Preferences.length > 0) ||
|
||||||
|
(formData.Platforms && formData.Platforms.length > 0) ||
|
||||||
|
formData.MaxReplicas !== undefined;
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
placementSwarm: hasAnyValue ? formData : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Placement updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating placement");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addConstraint = () => {
|
||||||
|
form.setValue("Constraints", [...constraints, ""]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConstraint = (index: number, value: string) => {
|
||||||
|
const newConstraints = [...constraints];
|
||||||
|
newConstraints[index] = value;
|
||||||
|
form.setValue("Constraints", newConstraints);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeConstraint = (index: number) => {
|
||||||
|
form.setValue(
|
||||||
|
"Constraints",
|
||||||
|
constraints.filter((_: string, i: number) => i !== index),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPreference = () => {
|
||||||
|
form.setValue("Preferences", [...preferences, { SpreadDescriptor: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePreference = (index: number, value: string) => {
|
||||||
|
const newPreferences = [...preferences];
|
||||||
|
if (newPreferences[index]) {
|
||||||
|
newPreferences[index].SpreadDescriptor = value;
|
||||||
|
form.setValue("Preferences", newPreferences);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePreference = (index: number) => {
|
||||||
|
form.setValue(
|
||||||
|
"Preferences",
|
||||||
|
preferences.filter((_: any, i: number) => i !== index),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPlatform = () => {
|
||||||
|
form.setValue("Platforms", [...platforms, { Architecture: "", OS: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePlatform = (
|
||||||
|
index: number,
|
||||||
|
field: "Architecture" | "OS",
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const newPlatforms = [...platforms];
|
||||||
|
if (newPlatforms[index]) {
|
||||||
|
newPlatforms[index][field] = value;
|
||||||
|
form.setValue("Platforms", newPlatforms);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePlatform = (index: number) => {
|
||||||
|
form.setValue(
|
||||||
|
"Platforms",
|
||||||
|
platforms.filter((_: any, i: number) => i !== index),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<FormLabel>Constraints</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Placement constraints (e.g., "node.role==manager")
|
||||||
|
</FormDescription>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{constraints.map((constraint: string, index: number) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={constraint}
|
||||||
|
onChange={(e) => updateConstraint(index, e.target.value)}
|
||||||
|
placeholder="node.role==manager"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeConstraint(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addConstraint}
|
||||||
|
>
|
||||||
|
Add Constraint
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormLabel>Preferences</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Spread preferences for task distribution (e.g.,
|
||||||
|
"node.labels.region")
|
||||||
|
</FormDescription>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{preferences.map((pref: any, index: number) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={pref.SpreadDescriptor}
|
||||||
|
onChange={(e) => updatePreference(index, e.target.value)}
|
||||||
|
placeholder="node.labels.region"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removePreference(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addPreference}
|
||||||
|
>
|
||||||
|
Add Preference
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="MaxReplicas"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Max Replicas</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum number of replicas per node
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FormLabel>Platforms</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Target platforms for task scheduling
|
||||||
|
</FormDescription>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{platforms.map((platform: any, index: number) => (
|
||||||
|
<div key={index} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={platform.Architecture}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePlatform(index, "Architecture", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="amd64"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={platform.OS}
|
||||||
|
onChange={(e) => updatePlatform(index, "OS", e.target.value)}
|
||||||
|
placeholder="linux"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removePlatform(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addPlatform}
|
||||||
|
>
|
||||||
|
Add Platform
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Constraints: [],
|
||||||
|
Preferences: [],
|
||||||
|
MaxReplicas: undefined,
|
||||||
|
Platforms: [],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Placement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const restartPolicyFormSchema = z.object({
|
||||||
|
Condition: z.string().optional(),
|
||||||
|
Delay: z.coerce.number().optional(),
|
||||||
|
MaxAttempts: z.coerce.number().optional(),
|
||||||
|
Window: z.coerce.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RestartPolicyFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RestartPolicyForm = ({ id, type }: RestartPolicyFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(restartPolicyFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Condition: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
MaxAttempts: undefined,
|
||||||
|
Window: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.restartPolicySwarm) {
|
||||||
|
form.reset({
|
||||||
|
Condition: data.restartPolicySwarm.Condition,
|
||||||
|
Delay: data.restartPolicySwarm.Delay,
|
||||||
|
MaxAttempts: data.restartPolicySwarm.MaxAttempts,
|
||||||
|
Window: data.restartPolicySwarm.Window,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (
|
||||||
|
formData: z.infer<typeof restartPolicyFormSchema>,
|
||||||
|
) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue = Object.values(formData).some(
|
||||||
|
(value) => value !== undefined && value !== null && value !== "",
|
||||||
|
);
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
restartPolicySwarm: hasAnyValue ? formData : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Restart policy updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating restart policy");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Condition"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Condition</FormLabel>
|
||||||
|
<FormDescription>When to restart the container</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select restart condition" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">None</SelectItem>
|
||||||
|
<SelectItem value="on-failure">On Failure</SelectItem>
|
||||||
|
<SelectItem value="any">Any</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Delay"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Delay (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Wait time between restart attempts
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="MaxAttempts"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Max Attempts</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum number of restart attempts
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="3" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Window"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Window (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Time window to evaluate restart policy
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Condition: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
MaxAttempts: undefined,
|
||||||
|
Window: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Restart Policy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const rollbackConfigFormSchema = z.object({
|
||||||
|
Parallelism: z.coerce.number().optional(),
|
||||||
|
Delay: z.coerce.number().optional(),
|
||||||
|
FailureAction: z.string().optional(),
|
||||||
|
Monitor: z.coerce.number().optional(),
|
||||||
|
MaxFailureRatio: z.coerce.number().optional(),
|
||||||
|
Order: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface RollbackConfigFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RollbackConfigForm = ({ id, type }: RollbackConfigFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(rollbackConfigFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Parallelism: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
FailureAction: undefined,
|
||||||
|
Monitor: undefined,
|
||||||
|
MaxFailureRatio: undefined,
|
||||||
|
Order: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.rollbackConfigSwarm) {
|
||||||
|
form.reset(data.rollbackConfigSwarm);
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (
|
||||||
|
formData: z.infer<typeof rollbackConfigFormSchema>,
|
||||||
|
) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue = Object.values(formData).some(
|
||||||
|
(value) => value !== undefined && value !== null && value !== "",
|
||||||
|
);
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
rollbackConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Rollback config updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating rollback config");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Parallelism"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Parallelism</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Number of tasks to rollback simultaneously
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Delay"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Delay (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>Delay between task rollbacks</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="FailureAction"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Failure Action</FormLabel>
|
||||||
|
<FormDescription>Action on rollback failure</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select failure action" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pause">Pause</SelectItem>
|
||||||
|
<SelectItem value="continue">Continue</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Monitor"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Monitor (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Duration to monitor for failure after rollback
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="MaxFailureRatio"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Max Failure Ratio</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum failure ratio tolerated (0-1)
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" step="0.01" placeholder="0.1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Order"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Order</FormLabel>
|
||||||
|
<FormDescription>Rollback order strategy</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select order" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="stop-first">Stop First</SelectItem>
|
||||||
|
<SelectItem value="start-first">Start First</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Parallelism: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
FailureAction: undefined,
|
||||||
|
Monitor: undefined,
|
||||||
|
MaxFailureRatio: undefined,
|
||||||
|
Order: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Rollback Config
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const hasStopGracePeriodSwarm = (
|
||||||
|
value: unknown,
|
||||||
|
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"stopGracePeriodSwarm" in value;
|
||||||
|
|
||||||
|
interface StopGracePeriodFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
defaultValues: {
|
||||||
|
value: null as bigint | null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasStopGracePeriodSwarm(data)) {
|
||||||
|
const value = data.stopGracePeriodSwarm;
|
||||||
|
const normalizedValue =
|
||||||
|
value === null || value === undefined
|
||||||
|
? null
|
||||||
|
: typeof value === "bigint"
|
||||||
|
? value
|
||||||
|
: BigInt(value);
|
||||||
|
form.reset({
|
||||||
|
value: normalizedValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: any) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
stopGracePeriodSwarm: formData.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Stop grace period updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating stop grace period");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="value"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Stop Grace Period (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Time to wait before forcefully killing the container
|
||||||
|
<br />
|
||||||
|
Examples: 30000000000 (30s), 120000000000 (2m)
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="30000000000"
|
||||||
|
{...field}
|
||||||
|
value={
|
||||||
|
field?.value !== null && field?.value !== undefined
|
||||||
|
? field.value.toString()
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
field.onChange(
|
||||||
|
e.target.value ? BigInt(e.target.value) : null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
value: null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Stop Grace Period
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
export const updateConfigFormSchema = z.object({
|
||||||
|
Parallelism: z.coerce.number().optional(),
|
||||||
|
Delay: z.coerce.number().optional(),
|
||||||
|
FailureAction: z.string().optional(),
|
||||||
|
Monitor: z.coerce.number().optional(),
|
||||||
|
MaxFailureRatio: z.coerce.number().optional(),
|
||||||
|
Order: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface UpdateConfigFormProps {
|
||||||
|
id: string;
|
||||||
|
type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpdateConfigForm = ({ id, type }: UpdateConfigFormProps) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const queryMap = {
|
||||||
|
postgres: () =>
|
||||||
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
|
redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }),
|
||||||
|
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
|
mariadb: () =>
|
||||||
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
application: () =>
|
||||||
|
api.application.one.useQuery({ applicationId: id }, { enabled: !!id }),
|
||||||
|
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
|
const { data, refetch } = queryMap[type]
|
||||||
|
? queryMap[type]()
|
||||||
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
|
const mutationMap = {
|
||||||
|
postgres: () => api.postgres.update.useMutation(),
|
||||||
|
redis: () => api.redis.update.useMutation(),
|
||||||
|
mysql: () => api.mysql.update.useMutation(),
|
||||||
|
mariadb: () => api.mariadb.update.useMutation(),
|
||||||
|
application: () => api.application.update.useMutation(),
|
||||||
|
mongo: () => api.mongo.update.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { mutateAsync } = mutationMap[type]
|
||||||
|
? mutationMap[type]()
|
||||||
|
: api.mongo.update.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(updateConfigFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
Parallelism: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
FailureAction: undefined,
|
||||||
|
Monitor: undefined,
|
||||||
|
MaxFailureRatio: undefined,
|
||||||
|
Order: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.updateConfigSwarm) {
|
||||||
|
const config = data.updateConfigSwarm;
|
||||||
|
form.reset({
|
||||||
|
Parallelism: config.Parallelism,
|
||||||
|
Delay: config.Delay,
|
||||||
|
FailureAction: config.FailureAction,
|
||||||
|
Monitor: config.Monitor,
|
||||||
|
MaxFailureRatio: config.MaxFailureRatio,
|
||||||
|
Order: config.Order,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, form]);
|
||||||
|
|
||||||
|
const onSubmit = async (formData: z.infer<typeof updateConfigFormSchema>) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
// Check if all values are empty, if so, send null to clear the database
|
||||||
|
const hasAnyValue = Object.values(formData).some(
|
||||||
|
(value) => value !== undefined && value !== null && value !== "",
|
||||||
|
);
|
||||||
|
|
||||||
|
await mutateAsync({
|
||||||
|
applicationId: id || "",
|
||||||
|
postgresId: id || "",
|
||||||
|
redisId: id || "",
|
||||||
|
mysqlId: id || "",
|
||||||
|
mariadbId: id || "",
|
||||||
|
mongoId: id || "",
|
||||||
|
updateConfigSwarm: (hasAnyValue ? formData : null) as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Update config updated successfully");
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
toast.error("Error updating update config");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Parallelism"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Parallelism</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Number of tasks to update simultaneously
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Delay"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Delay (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>Delay between task updates</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="FailureAction"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Failure Action</FormLabel>
|
||||||
|
<FormDescription>Action on update failure</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select failure action" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pause">Pause</SelectItem>
|
||||||
|
<SelectItem value="continue">Continue</SelectItem>
|
||||||
|
<SelectItem value="rollback">Rollback</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Monitor"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Monitor (nanoseconds)</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Duration to monitor for failure after update
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" placeholder="10000000000" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="MaxFailureRatio"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Max Failure Ratio</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum failure ratio tolerated (0-1)
|
||||||
|
</FormDescription>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="number" step="0.01" placeholder="0.1" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="Order"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Order</FormLabel>
|
||||||
|
<FormDescription>Update order strategy</FormDescription>
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select order" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="stop-first">Stop First</SelectItem>
|
||||||
|
<SelectItem value="start-first">Start First</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
form.reset({
|
||||||
|
Parallelism: undefined,
|
||||||
|
Delay: undefined,
|
||||||
|
FailureAction: undefined,
|
||||||
|
Monitor: undefined,
|
||||||
|
MaxFailureRatio: undefined,
|
||||||
|
Order: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isLoading}>
|
||||||
|
Save Update Config
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Filters out undefined, null, and empty string values from form data
|
||||||
|
* Only returns fields that have actual values
|
||||||
|
*/
|
||||||
|
export const filterEmptyValues = (
|
||||||
|
formData: Record<string, any>,
|
||||||
|
): Record<string, any> => {
|
||||||
|
return Object.entries(formData).reduce(
|
||||||
|
(acc, [key, value]) => {
|
||||||
|
// Keep arrays even if empty (they might be intentionally cleared)
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length > 0) {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For other values, filter out undefined, null, and empty strings
|
||||||
|
else if (value !== undefined && value !== null && value !== "") {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, any>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if filtered data has any values to save
|
||||||
|
*/
|
||||||
|
export const hasValues = (data: Record<string, any>): boolean => {
|
||||||
|
return Object.keys(data).length > 0;
|
||||||
|
};
|
||||||
@@ -38,10 +38,31 @@ interface Props {
|
|||||||
applicationId: string;
|
applicationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z
|
||||||
buildServerId: z.string().min(1, "Build server is required"),
|
.object({
|
||||||
buildRegistryId: z.string().min(1, "Build registry is required"),
|
buildServerId: z.string().optional(),
|
||||||
});
|
buildRegistryId: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
// Both empty/none is valid
|
||||||
|
const buildServerIsNone =
|
||||||
|
!data.buildServerId || data.buildServerId === "none";
|
||||||
|
const buildRegistryIsNone =
|
||||||
|
!data.buildRegistryId || data.buildRegistryId === "none";
|
||||||
|
|
||||||
|
// Both should be either filled or empty
|
||||||
|
if (buildServerIsNone && buildRegistryIsNone) return true;
|
||||||
|
if (!buildServerIsNone && !buildRegistryIsNone) return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Both Build Server and Build Registry must be selected together, or both set to None",
|
||||||
|
path: ["buildServerId"], // Show error on buildServerId field
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
type Schema = z.infer<typeof schema>;
|
type Schema = z.infer<typeof schema>;
|
||||||
|
|
||||||
@@ -121,6 +142,11 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
|||||||
container starts running.
|
container starts running.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
|
|
||||||
|
<AlertBlock type="info">
|
||||||
|
<strong>Note:</strong> Build Server and Build Registry must be
|
||||||
|
configured together. You can either select both or set both to None.
|
||||||
|
</AlertBlock>
|
||||||
|
|
||||||
{!registries || registries.length === 0 ? (
|
{!registries || registries.length === 0 ? (
|
||||||
<AlertBlock type="warning">
|
<AlertBlock type="warning">
|
||||||
You need to add at least one registry to use build servers. Please
|
You need to add at least one registry to use build servers. Please
|
||||||
@@ -147,7 +173,13 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Build Server</FormLabel>
|
<FormLabel>Build Server</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
// If setting to "none", also reset build registry to "none"
|
||||||
|
if (value === "none") {
|
||||||
|
form.setValue("buildRegistryId", "none");
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={field.value || "none"}
|
value={field.value || "none"}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -197,7 +229,13 @@ export const ShowBuildServer = ({ applicationId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Build Registry</FormLabel>
|
<FormLabel>Build Registry</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={field.onChange}
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
// If setting to "none", also reset build server to "none"
|
||||||
|
if (value === "none") {
|
||||||
|
form.setValue("buildServerId", "none");
|
||||||
|
}
|
||||||
|
}}
|
||||||
value={field.value || "none"}
|
value={field.value || "none"}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import {
|
||||||
|
createConverter,
|
||||||
|
NumberInputWithSteps,
|
||||||
|
} from "@/components/ui/number-input";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -30,6 +33,23 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const CPU_STEP = 0.25;
|
||||||
|
const MEMORY_STEP_MB = 256;
|
||||||
|
|
||||||
|
const formatNumber = (value: number, decimals = 2): string =>
|
||||||
|
Number.isInteger(value) ? String(value) : value.toFixed(decimals);
|
||||||
|
|
||||||
|
const cpuConverter = createConverter(1_000_000_000, (cpu) =>
|
||||||
|
cpu <= 0 ? "" : `${formatNumber(cpu)} CPU`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const memoryConverter = createConverter(1024 * 1024, (mb) => {
|
||||||
|
if (mb <= 0) return "";
|
||||||
|
return mb >= 1024
|
||||||
|
? `${formatNumber(mb / 1024)} GB`
|
||||||
|
: `${formatNumber(mb)} MB`;
|
||||||
|
});
|
||||||
|
|
||||||
const addResourcesSchema = z.object({
|
const addResourcesSchema = z.object({
|
||||||
memoryReservation: z.string().optional(),
|
memoryReservation: z.string().optional(),
|
||||||
cpuLimit: z.string().optional(),
|
cpuLimit: z.string().optional(),
|
||||||
@@ -51,6 +71,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AddResources = z.infer<typeof addResourcesSchema>;
|
type AddResources = z.infer<typeof addResourcesSchema>;
|
||||||
|
|
||||||
export const ShowResources = ({ id, type }: Props) => {
|
export const ShowResources = ({ id, type }: Props) => {
|
||||||
const queryMap = {
|
const queryMap = {
|
||||||
postgres: () =>
|
postgres: () =>
|
||||||
@@ -163,16 +184,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
Memory hard limit in bytes. Example: 1GB =
|
Memory hard limit in bytes. Example: 1GB =
|
||||||
1073741824 bytes
|
1073741824 bytes. Use +/- buttons to adjust by
|
||||||
|
256 MB.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<NumberInputWithSteps
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
placeholder="1073741824 (1GB in bytes)"
|
placeholder="1073741824 (1GB in bytes)"
|
||||||
{...field}
|
step={MEMORY_STEP_MB}
|
||||||
|
converter={memoryConverter}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -198,16 +223,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
Memory soft limit in bytes. Example: 256MB =
|
Memory soft limit in bytes. Example: 256MB =
|
||||||
268435456 bytes
|
268435456 bytes. Use +/- buttons to adjust by 256
|
||||||
|
MB.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<NumberInputWithSteps
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
placeholder="268435456 (256MB in bytes)"
|
placeholder="268435456 (256MB in bytes)"
|
||||||
{...field}
|
step={MEMORY_STEP_MB}
|
||||||
|
converter={memoryConverter}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -234,17 +263,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
CPU quota in units of 10^-9 CPUs. Example: 2
|
CPU quota in units of 10^-9 CPUs. Example: 2
|
||||||
CPUs = 2000000000
|
CPUs = 2000000000. Use +/- buttons to adjust by
|
||||||
|
0.25 CPU.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<NumberInputWithSteps
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
placeholder="2000000000 (2 CPUs)"
|
placeholder="2000000000 (2 CPUs)"
|
||||||
{...field}
|
step={CPU_STEP}
|
||||||
value={field.value?.toString() || ""}
|
converter={cpuConverter}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@@ -271,14 +303,21 @@ export const ShowResources = ({ id, type }: Props) => {
|
|||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
CPU shares (relative weight). Example: 1 CPU =
|
CPU shares (relative weight). Example: 1 CPU =
|
||||||
1000000000
|
1000000000. Use +/- buttons to adjust by 0.25
|
||||||
|
CPU.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="1000000000 (1 CPU)" {...field} />
|
<NumberInputWithSteps
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
placeholder="1000000000 (1 CPU)"
|
||||||
|
step={CPU_STEP}
|
||||||
|
converter={cpuConverter}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Cog } from "lucide-react";
|
import { Cog } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -20,8 +20,39 @@ import {
|
|||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
// Railpack versions from https://github.com/railwayapp/railpack/releases
|
||||||
|
export const RAILPACK_VERSIONS = [
|
||||||
|
"0.15.4",
|
||||||
|
"0.15.3",
|
||||||
|
"0.15.2",
|
||||||
|
"0.15.1",
|
||||||
|
"0.15.0",
|
||||||
|
"0.14.0",
|
||||||
|
"0.13.0",
|
||||||
|
"0.12.0",
|
||||||
|
"0.11.0",
|
||||||
|
"0.10.0",
|
||||||
|
"0.9.2",
|
||||||
|
"0.9.1",
|
||||||
|
"0.9.0",
|
||||||
|
"0.8.0",
|
||||||
|
"0.7.0",
|
||||||
|
"0.6.0",
|
||||||
|
"0.5.0",
|
||||||
|
"0.4.0",
|
||||||
|
"0.3.0",
|
||||||
|
"0.2.2",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export enum BuildType {
|
export enum BuildType {
|
||||||
dockerfile = "dockerfile",
|
dockerfile = "dockerfile",
|
||||||
heroku_buildpacks = "heroku_buildpacks",
|
heroku_buildpacks = "heroku_buildpacks",
|
||||||
@@ -65,7 +96,7 @@ const mySchema = z.discriminatedUnion("buildType", [
|
|||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal(BuildType.railpack),
|
buildType: z.literal(BuildType.railpack),
|
||||||
railpackVersion: z.string().nullable().default("0.2.2"),
|
railpackVersion: z.string().nullable().default("0.15.4"),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
buildType: z.literal(BuildType.static),
|
buildType: z.literal(BuildType.static),
|
||||||
@@ -152,6 +183,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const buildType = form.watch("buildType");
|
const buildType = form.watch("buildType");
|
||||||
|
const railpackVersion = form.watch("railpackVersion");
|
||||||
|
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -163,9 +196,22 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
form.reset(resetData(typedData));
|
form.reset(resetData(typedData));
|
||||||
|
|
||||||
|
// Check if railpack version is manual (not in the predefined list)
|
||||||
|
if (
|
||||||
|
data.railpackVersion &&
|
||||||
|
!RAILPACK_VERSIONS.includes(data.railpackVersion as any)
|
||||||
|
) {
|
||||||
|
setIsManualRailpackVersion(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
|
|
||||||
|
// Hide builder section when Docker provider is selected
|
||||||
|
if (data?.sourceType === "docker") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const onSubmit = async (data: AddTemplate) => {
|
const onSubmit = async (data: AddTemplate) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
applicationId,
|
applicationId,
|
||||||
@@ -186,7 +232,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
||||||
railpackVersion:
|
railpackVersion:
|
||||||
data.buildType === BuildType.railpack
|
data.buildType === BuildType.railpack
|
||||||
? data.railpackVersion || "0.2.2"
|
? data.railpackVersion || "0.15.4"
|
||||||
: null,
|
: null,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -403,23 +449,88 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{buildType === BuildType.railpack && (
|
{buildType === BuildType.railpack && (
|
||||||
<FormField
|
<>
|
||||||
control={form.control}
|
<FormField
|
||||||
name="railpackVersion"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="railpackVersion"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>Railpack Version</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Railpack Version</FormLabel>
|
||||||
<Input
|
<FormControl>
|
||||||
placeholder="Railpack Version"
|
{isManualRailpackVersion ? (
|
||||||
{...field}
|
<div className="space-y-2">
|
||||||
value={field.value ?? ""}
|
<Input
|
||||||
/>
|
placeholder="Enter custom version (e.g., 0.15.4)"
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage />
|
value={field.value ?? ""}
|
||||||
</FormItem>
|
/>
|
||||||
)}
|
<Button
|
||||||
/>
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsManualRailpackVersion(false);
|
||||||
|
field.onChange("0.15.4");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use predefined versions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === "manual") {
|
||||||
|
setIsManualRailpackVersion(true);
|
||||||
|
field.onChange("");
|
||||||
|
} else {
|
||||||
|
field.onChange(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
value={field.value ?? "0.15.4"}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select Railpack version" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="manual">
|
||||||
|
<span className="font-medium">
|
||||||
|
✏️ Manual (Custom Version)
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
{RAILPACK_VERSIONS.map((version) => (
|
||||||
|
<SelectItem key={version} value={version}>
|
||||||
|
v{version}
|
||||||
|
{version === "0.15.4" && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="ml-2 px-1 text-xs"
|
||||||
|
>
|
||||||
|
Latest
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Select a Railpack version or choose manual to enter a
|
||||||
|
custom version.{" "}
|
||||||
|
<a
|
||||||
|
href="https://github.com/railwayapp/railpack/releases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-primary underline underline-offset-4"
|
||||||
|
>
|
||||||
|
View releases
|
||||||
|
</a>
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button isLoading={isLoading} type="submit">
|
<Button isLoading={isLoading} type="submit">
|
||||||
|
|||||||
@@ -256,9 +256,9 @@ export const ShowDeployments = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={deployment.deploymentId}
|
key={deployment.deploymentId}
|
||||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-1 flex-col min-w-0">
|
||||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||||
{index + 1}. {deployment.status}
|
{index + 1}. {deployment.status}
|
||||||
<StatusTooltip
|
<StatusTooltip
|
||||||
@@ -313,8 +313,8 @@ export const ShowDeployments = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2 max-w-[300px] w-full justify-start">
|
<div className="flex w-full flex-col items-start gap-2 sm:w-auto sm:max-w-[300px] sm:items-end sm:justify-start">
|
||||||
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
<div className="text-sm capitalize text-muted-foreground flex flex-wrap items-center gap-2">
|
||||||
<DateTooltip date={deployment.createdAt} />
|
<DateTooltip date={deployment.createdAt} />
|
||||||
{deployment.startedAt && deployment.finishedAt && (
|
{deployment.startedAt && deployment.finishedAt && (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -333,7 +333,7 @@ export const ShowDeployments = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-end">
|
||||||
{deployment.pid && deployment.status === "running" && (
|
{deployment.pid && deployment.status === "running" && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Kill Process"
|
title="Kill Process"
|
||||||
@@ -355,6 +355,7 @@ export const ShowDeployments = ({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={isKillingProcess}
|
isLoading={isKillingProcess}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
Kill Process
|
Kill Process
|
||||||
</Button>
|
</Button>
|
||||||
@@ -364,6 +365,7 @@ export const ShowDeployments = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActiveLog(deployment);
|
setActiveLog(deployment);
|
||||||
}}
|
}}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
@@ -405,6 +407,7 @@ export const ShowDeployments = ({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={isRollingBack}
|
isLoading={isRollingBack}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||||
Rollback
|
Rollback
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
const certificateType = form.watch("certificateType");
|
const certificateType = form.watch("certificateType");
|
||||||
const https = form.watch("https");
|
const https = form.watch("https");
|
||||||
const domainType = form.watch("domainType");
|
const domainType = form.watch("domainType");
|
||||||
|
const host = form.watch("host");
|
||||||
|
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -502,6 +504,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
to make your traefik.me domain work.
|
to make your traefik.me domain work.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
|
{isTraefikMeDomain && (
|
||||||
|
<AlertBlock type="info">
|
||||||
|
<strong>Note:</strong> traefik.me is a public HTTP
|
||||||
|
service and does not support SSL/HTTPS. HTTPS and
|
||||||
|
certificate options will not have any effect.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
<FormLabel>Host</FormLabel>
|
<FormLabel>Host</FormLabel>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -5,14 +5,23 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Form } from "@/components/ui/form";
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from "@/components/ui/form";
|
||||||
import { Secrets } from "@/components/ui/secrets";
|
import { Secrets } from "@/components/ui/secrets";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const addEnvironmentSchema = z.object({
|
const addEnvironmentSchema = z.object({
|
||||||
env: z.string(),
|
env: z.string(),
|
||||||
buildArgs: z.string(),
|
buildArgs: z.string(),
|
||||||
buildSecrets: z.string(),
|
buildSecrets: z.string(),
|
||||||
|
createEnvFile: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
||||||
@@ -39,6 +48,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
env: "",
|
env: "",
|
||||||
buildArgs: "",
|
buildArgs: "",
|
||||||
buildSecrets: "",
|
buildSecrets: "",
|
||||||
|
createEnvFile: true,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addEnvironmentSchema),
|
resolver: zodResolver(addEnvironmentSchema),
|
||||||
});
|
});
|
||||||
@@ -47,10 +57,12 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
const currentEnv = form.watch("env");
|
const currentEnv = form.watch("env");
|
||||||
const currentBuildArgs = form.watch("buildArgs");
|
const currentBuildArgs = form.watch("buildArgs");
|
||||||
const currentBuildSecrets = form.watch("buildSecrets");
|
const currentBuildSecrets = form.watch("buildSecrets");
|
||||||
|
const currentCreateEnvFile = form.watch("createEnvFile");
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
currentEnv !== (data?.env || "") ||
|
currentEnv !== (data?.env || "") ||
|
||||||
currentBuildArgs !== (data?.buildArgs || "") ||
|
currentBuildArgs !== (data?.buildArgs || "") ||
|
||||||
currentBuildSecrets !== (data?.buildSecrets || "");
|
currentBuildSecrets !== (data?.buildSecrets || "") ||
|
||||||
|
currentCreateEnvFile !== (data?.createEnvFile ?? true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -58,6 +70,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
env: data.env || "",
|
env: data.env || "",
|
||||||
buildArgs: data.buildArgs || "",
|
buildArgs: data.buildArgs || "",
|
||||||
buildSecrets: data.buildSecrets || "",
|
buildSecrets: data.buildSecrets || "",
|
||||||
|
createEnvFile: data.createEnvFile ?? true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
@@ -67,6 +80,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
env: formData.env,
|
env: formData.env,
|
||||||
buildArgs: formData.buildArgs,
|
buildArgs: formData.buildArgs,
|
||||||
buildSecrets: formData.buildSecrets,
|
buildSecrets: formData.buildSecrets,
|
||||||
|
createEnvFile: formData.createEnvFile,
|
||||||
applicationId,
|
applicationId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -83,6 +97,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
env: data?.env || "",
|
env: data?.env || "",
|
||||||
buildArgs: data?.buildArgs || "",
|
buildArgs: data?.buildArgs || "",
|
||||||
buildSecrets: data?.buildSecrets || "",
|
buildSecrets: data?.buildSecrets || "",
|
||||||
|
createEnvFile: data?.createEnvFile ?? true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,6 +182,31 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
placeholder="NPM_TOKEN=xyz"
|
placeholder="NPM_TOKEN=xyz"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{data?.buildType === "dockerfile" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="createEnvFile"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Create Environment File</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
When enabled, an .env file will be created in the same
|
||||||
|
directory as your Dockerfile during the build process.
|
||||||
|
Disable this if you don't want to generate an environment
|
||||||
|
file.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex flex-row justify-end gap-2">
|
<div className="flex flex-row justify-end gap-2">
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||||
|
|||||||
@@ -232,9 +232,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<FormItem className="md:col-span-2 flex flex-col">
|
<FormItem className="md:col-span-2 flex flex-col">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<FormLabel>Repository</FormLabel>
|
<FormLabel>Repository</FormLabel>
|
||||||
{field.value.owner && field.value.repo && (
|
{field.value.gitlabPathNamespace && (
|
||||||
<Link
|
<Link
|
||||||
href={`${gitlabUrl}/${field.value.owner}/${field.value.repo}`}
|
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ export const AddPreviewDomain = ({
|
|||||||
resolver: zodResolver(domain),
|
resolver: zodResolver(domain),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const host = form.watch("host");
|
||||||
|
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -157,6 +160,13 @@ export const AddPreviewDomain = ({
|
|||||||
name="host"
|
name="host"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
{isTraefikMeDomain && (
|
||||||
|
<AlertBlock type="info">
|
||||||
|
<strong>Note:</strong> traefik.me is a public HTTP
|
||||||
|
service and does not support SSL/HTTPS. HTTPS and
|
||||||
|
certificate options will not have any effect.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
<FormLabel>Host</FormLabel>
|
<FormLabel>Host</FormLabel>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
import {
|
import {
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
GitPullRequest,
|
GitPullRequest,
|
||||||
|
Hammer,
|
||||||
Loader2,
|
Loader2,
|
||||||
PenSquare,
|
PenSquare,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
@@ -22,6 +24,12 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
||||||
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
||||||
@@ -38,6 +46,9 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
||||||
api.previewDeployment.delete.useMutation();
|
api.previewDeployment.delete.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: redeployPreviewDeployment } =
|
||||||
|
api.previewDeployment.redeploy.useMutation();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: previewDeployments,
|
data: previewDeployments,
|
||||||
refetch: refetchPreviewDeployments,
|
refetch: refetchPreviewDeployments,
|
||||||
@@ -46,6 +57,8 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
{ applicationId },
|
{ applicationId },
|
||||||
{
|
{
|
||||||
enabled: !!applicationId,
|
enabled: !!applicationId,
|
||||||
|
refetchInterval: (data) =>
|
||||||
|
data?.some((d) => d.previewStatus === "running") ? 2000 : false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -193,6 +206,58 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</ShowDeploymentsModal>
|
</ShowDeploymentsModal>
|
||||||
|
|
||||||
|
<DialogAction
|
||||||
|
title="Rebuild Preview Deployment"
|
||||||
|
description="Are you sure you want to rebuild this preview deployment?"
|
||||||
|
type="default"
|
||||||
|
onClick={async () => {
|
||||||
|
await redeployPreviewDeployment({
|
||||||
|
previewDeploymentId:
|
||||||
|
deployment.previewDeploymentId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
"Preview deployment rebuild started",
|
||||||
|
);
|
||||||
|
refetchPreviewDeployments();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(
|
||||||
|
"Error rebuilding preview deployment",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
isLoading={status === "running"}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Hammer className="size-4" />
|
||||||
|
Rebuild
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipContent
|
||||||
|
sideOffset={5}
|
||||||
|
className="z-[60]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Rebuild the preview deployment without
|
||||||
|
downloading new code
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
|
||||||
<AddPreviewDomain
|
<AddPreviewDomain
|
||||||
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
||||||
domainId={deployment.domain?.domainId}
|
domainId={deployment.domain?.domainId}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -100,6 +101,8 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const previewHttps = form.watch("previewHttps");
|
const previewHttps = form.watch("previewHttps");
|
||||||
|
const wildcardDomain = form.watch("wildcardDomain");
|
||||||
|
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
||||||
@@ -120,7 +123,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
previewCertificateType: data.previewCertificateType || "none",
|
previewCertificateType: data.previewCertificateType || "none",
|
||||||
previewCustomCertResolver: data.previewCustomCertResolver || "",
|
previewCustomCertResolver: data.previewCustomCertResolver || "",
|
||||||
previewRequireCollaboratorPermissions:
|
previewRequireCollaboratorPermissions:
|
||||||
data.previewRequireCollaboratorPermissions || true,
|
data.previewRequireCollaboratorPermissions ?? true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data]);
|
||||||
@@ -168,6 +171,13 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
|
{isTraefikMeDomain && (
|
||||||
|
<AlertBlock type="info">
|
||||||
|
<strong>Note:</strong> traefik.me is a public HTTP service and
|
||||||
|
does not support SSL/HTTPS. HTTPS and certificate options will
|
||||||
|
not have any effect.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
ChevronsUpDown,
|
||||||
DatabaseZap,
|
DatabaseZap,
|
||||||
Info,
|
Info,
|
||||||
PenBoxIcon,
|
PenBoxIcon,
|
||||||
@@ -13,6 +15,14 @@ import { z } from "zod";
|
|||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { CodeEditor } from "@/components/shared/code-editor";
|
import { CodeEditor } from "@/components/shared/code-editor";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -31,6 +41,12 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -48,6 +64,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import type { CacheType } from "../domains/handle-domain";
|
import type { CacheType } from "../domains/handle-domain";
|
||||||
|
import { getTimezoneLabel, TIMEZONES } from "./timezones";
|
||||||
|
|
||||||
export const commonCronExpressions = [
|
export const commonCronExpressions = [
|
||||||
{ label: "Every minute", value: "* * * * *" },
|
{ label: "Every minute", value: "* * * * *" },
|
||||||
@@ -60,30 +77,6 @@ export const commonCronExpressions = [
|
|||||||
{ label: "Custom", value: "custom" },
|
{ label: "Custom", value: "custom" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const commonTimezones = [
|
|
||||||
{ label: "UTC (Coordinated Universal Time)", value: "UTC" },
|
|
||||||
{ label: "America/New_York (Eastern Time)", value: "America/New_York" },
|
|
||||||
{ label: "America/Chicago (Central Time)", value: "America/Chicago" },
|
|
||||||
{ label: "America/Denver (Mountain Time)", value: "America/Denver" },
|
|
||||||
{ label: "America/Los_Angeles (Pacific Time)", value: "America/Los_Angeles" },
|
|
||||||
{
|
|
||||||
label: "America/Mexico_City (Central Mexico)",
|
|
||||||
value: "America/Mexico_City",
|
|
||||||
},
|
|
||||||
{ label: "America/Sao_Paulo (Brasilia Time)", value: "America/Sao_Paulo" },
|
|
||||||
{ label: "Europe/London (Greenwich Mean Time)", value: "Europe/London" },
|
|
||||||
{ label: "Europe/Paris (Central European Time)", value: "Europe/Paris" },
|
|
||||||
{ label: "Europe/Berlin (Central European Time)", value: "Europe/Berlin" },
|
|
||||||
{ label: "Asia/Tokyo (Japan Standard Time)", value: "Asia/Tokyo" },
|
|
||||||
{ label: "Asia/Shanghai (China Standard Time)", value: "Asia/Shanghai" },
|
|
||||||
{ label: "Asia/Dubai (Gulf Standard Time)", value: "Asia/Dubai" },
|
|
||||||
{ label: "Asia/Kolkata (India Standard Time)", value: "Asia/Kolkata" },
|
|
||||||
{
|
|
||||||
label: "Australia/Sydney (Australian Eastern Time)",
|
|
||||||
value: "Australia/Sydney",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
@@ -512,25 +505,60 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Select
|
<Popover>
|
||||||
onValueChange={(value) => {
|
<PopoverTrigger asChild>
|
||||||
field.onChange(value);
|
<FormControl>
|
||||||
}}
|
<Button
|
||||||
value={field.value}
|
variant="outline"
|
||||||
>
|
className={cn(
|
||||||
<FormControl>
|
"w-full justify-between !bg-input",
|
||||||
<SelectTrigger>
|
!field.value && "text-muted-foreground",
|
||||||
<SelectValue placeholder="UTC (default)" />
|
)}
|
||||||
</SelectTrigger>
|
>
|
||||||
</FormControl>
|
{getTimezoneLabel(field.value)}
|
||||||
<SelectContent>
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
{commonTimezones.map((tz) => (
|
</Button>
|
||||||
<SelectItem key={tz.value} value={tz.value}>
|
</FormControl>
|
||||||
{tz.label}
|
</PopoverTrigger>
|
||||||
</SelectItem>
|
<PopoverContent className="w-[400px] p-0" align="start">
|
||||||
))}
|
<Command>
|
||||||
</SelectContent>
|
<CommandInput
|
||||||
</Select>
|
placeholder="Search timezone..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No timezone found.</CommandEmpty>
|
||||||
|
<ScrollArea className="h-72">
|
||||||
|
{Object.entries(TIMEZONES).map(
|
||||||
|
([region, zones]) => (
|
||||||
|
<CommandGroup key={region} heading={region}>
|
||||||
|
{zones.map((tz) => (
|
||||||
|
<CommandItem
|
||||||
|
key={tz.value}
|
||||||
|
value={`${region} ${tz.label} ${tz.value}`}
|
||||||
|
onSelect={() => {
|
||||||
|
field.onChange(tz.value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tz.value}
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
field.value === tz.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Optional: Choose a timezone for the schedule execution time
|
Optional: Choose a timezone for the schedule execution time
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
// Complete list of IANA timezones grouped by region
|
||||||
|
export const TIMEZONES: Record<
|
||||||
|
string,
|
||||||
|
Array<{ label: string; value: string }>
|
||||||
|
> = {
|
||||||
|
Common: [{ label: "UTC (Coordinated Universal Time)", value: "UTC" }],
|
||||||
|
Africa: [
|
||||||
|
{ label: "Abidjan", value: "Africa/Abidjan" },
|
||||||
|
{ label: "Accra", value: "Africa/Accra" },
|
||||||
|
{ label: "Addis Ababa", value: "Africa/Addis_Ababa" },
|
||||||
|
{ label: "Algiers", value: "Africa/Algiers" },
|
||||||
|
{ label: "Asmara", value: "Africa/Asmara" },
|
||||||
|
{ label: "Bamako", value: "Africa/Bamako" },
|
||||||
|
{ label: "Bangui", value: "Africa/Bangui" },
|
||||||
|
{ label: "Banjul", value: "Africa/Banjul" },
|
||||||
|
{ label: "Bissau", value: "Africa/Bissau" },
|
||||||
|
{ label: "Blantyre", value: "Africa/Blantyre" },
|
||||||
|
{ label: "Brazzaville", value: "Africa/Brazzaville" },
|
||||||
|
{ label: "Bujumbura", value: "Africa/Bujumbura" },
|
||||||
|
{ label: "Cairo", value: "Africa/Cairo" },
|
||||||
|
{ label: "Casablanca", value: "Africa/Casablanca" },
|
||||||
|
{ label: "Ceuta", value: "Africa/Ceuta" },
|
||||||
|
{ label: "Conakry", value: "Africa/Conakry" },
|
||||||
|
{ label: "Dakar", value: "Africa/Dakar" },
|
||||||
|
{ label: "Dar es Salaam", value: "Africa/Dar_es_Salaam" },
|
||||||
|
{ label: "Djibouti", value: "Africa/Djibouti" },
|
||||||
|
{ label: "Douala", value: "Africa/Douala" },
|
||||||
|
{ label: "El Aaiun", value: "Africa/El_Aaiun" },
|
||||||
|
{ label: "Freetown", value: "Africa/Freetown" },
|
||||||
|
{ label: "Gaborone", value: "Africa/Gaborone" },
|
||||||
|
{ label: "Harare", value: "Africa/Harare" },
|
||||||
|
{ label: "Johannesburg", value: "Africa/Johannesburg" },
|
||||||
|
{ label: "Juba", value: "Africa/Juba" },
|
||||||
|
{ label: "Kampala", value: "Africa/Kampala" },
|
||||||
|
{ label: "Khartoum", value: "Africa/Khartoum" },
|
||||||
|
{ label: "Kigali", value: "Africa/Kigali" },
|
||||||
|
{ label: "Kinshasa", value: "Africa/Kinshasa" },
|
||||||
|
{ label: "Lagos", value: "Africa/Lagos" },
|
||||||
|
{ label: "Libreville", value: "Africa/Libreville" },
|
||||||
|
{ label: "Lome", value: "Africa/Lome" },
|
||||||
|
{ label: "Luanda", value: "Africa/Luanda" },
|
||||||
|
{ label: "Lubumbashi", value: "Africa/Lubumbashi" },
|
||||||
|
{ label: "Lusaka", value: "Africa/Lusaka" },
|
||||||
|
{ label: "Malabo", value: "Africa/Malabo" },
|
||||||
|
{ label: "Maputo", value: "Africa/Maputo" },
|
||||||
|
{ label: "Maseru", value: "Africa/Maseru" },
|
||||||
|
{ label: "Mbabane", value: "Africa/Mbabane" },
|
||||||
|
{ label: "Mogadishu", value: "Africa/Mogadishu" },
|
||||||
|
{ label: "Monrovia", value: "Africa/Monrovia" },
|
||||||
|
{ label: "Nairobi", value: "Africa/Nairobi" },
|
||||||
|
{ label: "Ndjamena", value: "Africa/Ndjamena" },
|
||||||
|
{ label: "Niamey", value: "Africa/Niamey" },
|
||||||
|
{ label: "Nouakchott", value: "Africa/Nouakchott" },
|
||||||
|
{ label: "Ouagadougou", value: "Africa/Ouagadougou" },
|
||||||
|
{ label: "Porto-Novo", value: "Africa/Porto-Novo" },
|
||||||
|
{ label: "Sao Tome", value: "Africa/Sao_Tome" },
|
||||||
|
{ label: "Tripoli", value: "Africa/Tripoli" },
|
||||||
|
{ label: "Tunis", value: "Africa/Tunis" },
|
||||||
|
{ label: "Windhoek", value: "Africa/Windhoek" },
|
||||||
|
],
|
||||||
|
America: [
|
||||||
|
{ label: "Adak", value: "America/Adak" },
|
||||||
|
{ label: "Anchorage", value: "America/Anchorage" },
|
||||||
|
{ label: "Anguilla", value: "America/Anguilla" },
|
||||||
|
{ label: "Antigua", value: "America/Antigua" },
|
||||||
|
{ label: "Araguaina", value: "America/Araguaina" },
|
||||||
|
{
|
||||||
|
label: "Argentina/Buenos Aires",
|
||||||
|
value: "America/Argentina/Buenos_Aires",
|
||||||
|
},
|
||||||
|
{ label: "Argentina/Catamarca", value: "America/Argentina/Catamarca" },
|
||||||
|
{ label: "Argentina/Cordoba", value: "America/Argentina/Cordoba" },
|
||||||
|
{ label: "Argentina/Jujuy", value: "America/Argentina/Jujuy" },
|
||||||
|
{ label: "Argentina/La Rioja", value: "America/Argentina/La_Rioja" },
|
||||||
|
{ label: "Argentina/Mendoza", value: "America/Argentina/Mendoza" },
|
||||||
|
{
|
||||||
|
label: "Argentina/Rio Gallegos",
|
||||||
|
value: "America/Argentina/Rio_Gallegos",
|
||||||
|
},
|
||||||
|
{ label: "Argentina/Salta", value: "America/Argentina/Salta" },
|
||||||
|
{ label: "Argentina/San Juan", value: "America/Argentina/San_Juan" },
|
||||||
|
{ label: "Argentina/San Luis", value: "America/Argentina/San_Luis" },
|
||||||
|
{ label: "Argentina/Tucuman", value: "America/Argentina/Tucuman" },
|
||||||
|
{ label: "Argentina/Ushuaia", value: "America/Argentina/Ushuaia" },
|
||||||
|
{ label: "Aruba", value: "America/Aruba" },
|
||||||
|
{ label: "Asuncion", value: "America/Asuncion" },
|
||||||
|
{ label: "Atikokan", value: "America/Atikokan" },
|
||||||
|
{ label: "Bahia", value: "America/Bahia" },
|
||||||
|
{ label: "Bahia Banderas", value: "America/Bahia_Banderas" },
|
||||||
|
{ label: "Barbados", value: "America/Barbados" },
|
||||||
|
{ label: "Belem", value: "America/Belem" },
|
||||||
|
{ label: "Belize", value: "America/Belize" },
|
||||||
|
{ label: "Blanc-Sablon", value: "America/Blanc-Sablon" },
|
||||||
|
{ label: "Boa Vista", value: "America/Boa_Vista" },
|
||||||
|
{ label: "Bogota", value: "America/Bogota" },
|
||||||
|
{ label: "Boise", value: "America/Boise" },
|
||||||
|
{ label: "Cambridge Bay", value: "America/Cambridge_Bay" },
|
||||||
|
{ label: "Campo Grande", value: "America/Campo_Grande" },
|
||||||
|
{ label: "Cancun", value: "America/Cancun" },
|
||||||
|
{ label: "Caracas", value: "America/Caracas" },
|
||||||
|
{ label: "Cayenne", value: "America/Cayenne" },
|
||||||
|
{ label: "Cayman", value: "America/Cayman" },
|
||||||
|
{ label: "Chicago (Central Time)", value: "America/Chicago" },
|
||||||
|
{ label: "Chihuahua", value: "America/Chihuahua" },
|
||||||
|
{ label: "Ciudad Juarez", value: "America/Ciudad_Juarez" },
|
||||||
|
{ label: "Costa Rica", value: "America/Costa_Rica" },
|
||||||
|
{ label: "Creston", value: "America/Creston" },
|
||||||
|
{ label: "Cuiaba", value: "America/Cuiaba" },
|
||||||
|
{ label: "Curacao", value: "America/Curacao" },
|
||||||
|
{ label: "Danmarkshavn", value: "America/Danmarkshavn" },
|
||||||
|
{ label: "Dawson", value: "America/Dawson" },
|
||||||
|
{ label: "Dawson Creek", value: "America/Dawson_Creek" },
|
||||||
|
{ label: "Denver (Mountain Time)", value: "America/Denver" },
|
||||||
|
{ label: "Detroit", value: "America/Detroit" },
|
||||||
|
{ label: "Dominica", value: "America/Dominica" },
|
||||||
|
{ label: "Edmonton", value: "America/Edmonton" },
|
||||||
|
{ label: "Eirunepe", value: "America/Eirunepe" },
|
||||||
|
{ label: "El Salvador", value: "America/El_Salvador" },
|
||||||
|
{ label: "Fort Nelson", value: "America/Fort_Nelson" },
|
||||||
|
{ label: "Fortaleza", value: "America/Fortaleza" },
|
||||||
|
{ label: "Glace Bay", value: "America/Glace_Bay" },
|
||||||
|
{ label: "Goose Bay", value: "America/Goose_Bay" },
|
||||||
|
{ label: "Grand Turk", value: "America/Grand_Turk" },
|
||||||
|
{ label: "Grenada", value: "America/Grenada" },
|
||||||
|
{ label: "Guadeloupe", value: "America/Guadeloupe" },
|
||||||
|
{ label: "Guatemala", value: "America/Guatemala" },
|
||||||
|
{ label: "Guayaquil", value: "America/Guayaquil" },
|
||||||
|
{ label: "Guyana", value: "America/Guyana" },
|
||||||
|
{ label: "Halifax", value: "America/Halifax" },
|
||||||
|
{ label: "Havana", value: "America/Havana" },
|
||||||
|
{ label: "Hermosillo", value: "America/Hermosillo" },
|
||||||
|
{ label: "Indiana/Indianapolis", value: "America/Indiana/Indianapolis" },
|
||||||
|
{ label: "Indiana/Knox", value: "America/Indiana/Knox" },
|
||||||
|
{ label: "Indiana/Marengo", value: "America/Indiana/Marengo" },
|
||||||
|
{ label: "Indiana/Petersburg", value: "America/Indiana/Petersburg" },
|
||||||
|
{ label: "Indiana/Tell City", value: "America/Indiana/Tell_City" },
|
||||||
|
{ label: "Indiana/Vevay", value: "America/Indiana/Vevay" },
|
||||||
|
{ label: "Indiana/Vincennes", value: "America/Indiana/Vincennes" },
|
||||||
|
{ label: "Indiana/Winamac", value: "America/Indiana/Winamac" },
|
||||||
|
{ label: "Inuvik", value: "America/Inuvik" },
|
||||||
|
{ label: "Iqaluit", value: "America/Iqaluit" },
|
||||||
|
{ label: "Jamaica", value: "America/Jamaica" },
|
||||||
|
{ label: "Juneau", value: "America/Juneau" },
|
||||||
|
{ label: "Kentucky/Louisville", value: "America/Kentucky/Louisville" },
|
||||||
|
{ label: "Kentucky/Monticello", value: "America/Kentucky/Monticello" },
|
||||||
|
{ label: "Kralendijk", value: "America/Kralendijk" },
|
||||||
|
{ label: "La Paz", value: "America/La_Paz" },
|
||||||
|
{ label: "Lima", value: "America/Lima" },
|
||||||
|
{ label: "Los Angeles (Pacific Time)", value: "America/Los_Angeles" },
|
||||||
|
{ label: "Lower Princes", value: "America/Lower_Princes" },
|
||||||
|
{ label: "Maceio", value: "America/Maceio" },
|
||||||
|
{ label: "Managua", value: "America/Managua" },
|
||||||
|
{ label: "Manaus", value: "America/Manaus" },
|
||||||
|
{ label: "Marigot", value: "America/Marigot" },
|
||||||
|
{ label: "Martinique", value: "America/Martinique" },
|
||||||
|
{ label: "Matamoros", value: "America/Matamoros" },
|
||||||
|
{ label: "Mazatlan", value: "America/Mazatlan" },
|
||||||
|
{ label: "Menominee", value: "America/Menominee" },
|
||||||
|
{ label: "Merida", value: "America/Merida" },
|
||||||
|
{ label: "Metlakatla", value: "America/Metlakatla" },
|
||||||
|
{ label: "Mexico City (Central Mexico)", value: "America/Mexico_City" },
|
||||||
|
{ label: "Miquelon", value: "America/Miquelon" },
|
||||||
|
{ label: "Moncton", value: "America/Moncton" },
|
||||||
|
{ label: "Monterrey", value: "America/Monterrey" },
|
||||||
|
{ label: "Montevideo", value: "America/Montevideo" },
|
||||||
|
{ label: "Montserrat", value: "America/Montserrat" },
|
||||||
|
{ label: "Nassau", value: "America/Nassau" },
|
||||||
|
{ label: "New York (Eastern Time)", value: "America/New_York" },
|
||||||
|
{ label: "Nome", value: "America/Nome" },
|
||||||
|
{ label: "Noronha", value: "America/Noronha" },
|
||||||
|
{ label: "North Dakota/Beulah", value: "America/North_Dakota/Beulah" },
|
||||||
|
{ label: "North Dakota/Center", value: "America/North_Dakota/Center" },
|
||||||
|
{
|
||||||
|
label: "North Dakota/New Salem",
|
||||||
|
value: "America/North_Dakota/New_Salem",
|
||||||
|
},
|
||||||
|
{ label: "Nuuk", value: "America/Nuuk" },
|
||||||
|
{ label: "Ojinaga", value: "America/Ojinaga" },
|
||||||
|
{ label: "Panama", value: "America/Panama" },
|
||||||
|
{ label: "Paramaribo", value: "America/Paramaribo" },
|
||||||
|
{ label: "Phoenix", value: "America/Phoenix" },
|
||||||
|
{ label: "Port-au-Prince", value: "America/Port-au-Prince" },
|
||||||
|
{ label: "Port of Spain", value: "America/Port_of_Spain" },
|
||||||
|
{ label: "Porto Velho", value: "America/Porto_Velho" },
|
||||||
|
{ label: "Puerto Rico", value: "America/Puerto_Rico" },
|
||||||
|
{ label: "Punta Arenas", value: "America/Punta_Arenas" },
|
||||||
|
{ label: "Rankin Inlet", value: "America/Rankin_Inlet" },
|
||||||
|
{ label: "Recife", value: "America/Recife" },
|
||||||
|
{ label: "Regina", value: "America/Regina" },
|
||||||
|
{ label: "Resolute", value: "America/Resolute" },
|
||||||
|
{ label: "Rio Branco", value: "America/Rio_Branco" },
|
||||||
|
{ label: "Santarem", value: "America/Santarem" },
|
||||||
|
{ label: "Santiago", value: "America/Santiago" },
|
||||||
|
{ label: "Santo Domingo", value: "America/Santo_Domingo" },
|
||||||
|
{ label: "Sao Paulo (Brasilia Time)", value: "America/Sao_Paulo" },
|
||||||
|
{ label: "Scoresbysund", value: "America/Scoresbysund" },
|
||||||
|
{ label: "Sitka", value: "America/Sitka" },
|
||||||
|
{ label: "St Barthelemy", value: "America/St_Barthelemy" },
|
||||||
|
{ label: "St Johns", value: "America/St_Johns" },
|
||||||
|
{ label: "St Kitts", value: "America/St_Kitts" },
|
||||||
|
{ label: "St Lucia", value: "America/St_Lucia" },
|
||||||
|
{ label: "St Thomas", value: "America/St_Thomas" },
|
||||||
|
{ label: "St Vincent", value: "America/St_Vincent" },
|
||||||
|
{ label: "Swift Current", value: "America/Swift_Current" },
|
||||||
|
{ label: "Tegucigalpa", value: "America/Tegucigalpa" },
|
||||||
|
{ label: "Thule", value: "America/Thule" },
|
||||||
|
{ label: "Tijuana", value: "America/Tijuana" },
|
||||||
|
{ label: "Toronto", value: "America/Toronto" },
|
||||||
|
{ label: "Tortola", value: "America/Tortola" },
|
||||||
|
{ label: "Vancouver", value: "America/Vancouver" },
|
||||||
|
{ label: "Whitehorse", value: "America/Whitehorse" },
|
||||||
|
{ label: "Winnipeg", value: "America/Winnipeg" },
|
||||||
|
{ label: "Yakutat", value: "America/Yakutat" },
|
||||||
|
],
|
||||||
|
Antarctica: [
|
||||||
|
{ label: "Casey", value: "Antarctica/Casey" },
|
||||||
|
{ label: "Davis", value: "Antarctica/Davis" },
|
||||||
|
{ label: "DumontDUrville", value: "Antarctica/DumontDUrville" },
|
||||||
|
{ label: "Macquarie", value: "Antarctica/Macquarie" },
|
||||||
|
{ label: "Mawson", value: "Antarctica/Mawson" },
|
||||||
|
{ label: "McMurdo", value: "Antarctica/McMurdo" },
|
||||||
|
{ label: "Palmer", value: "Antarctica/Palmer" },
|
||||||
|
{ label: "Rothera", value: "Antarctica/Rothera" },
|
||||||
|
{ label: "Syowa", value: "Antarctica/Syowa" },
|
||||||
|
{ label: "Troll", value: "Antarctica/Troll" },
|
||||||
|
{ label: "Vostok", value: "Antarctica/Vostok" },
|
||||||
|
],
|
||||||
|
Arctic: [{ label: "Longyearbyen", value: "Arctic/Longyearbyen" }],
|
||||||
|
Asia: [
|
||||||
|
{ label: "Aden", value: "Asia/Aden" },
|
||||||
|
{ label: "Almaty", value: "Asia/Almaty" },
|
||||||
|
{ label: "Amman", value: "Asia/Amman" },
|
||||||
|
{ label: "Anadyr", value: "Asia/Anadyr" },
|
||||||
|
{ label: "Aqtau", value: "Asia/Aqtau" },
|
||||||
|
{ label: "Aqtobe", value: "Asia/Aqtobe" },
|
||||||
|
{ label: "Ashgabat", value: "Asia/Ashgabat" },
|
||||||
|
{ label: "Atyrau", value: "Asia/Atyrau" },
|
||||||
|
{ label: "Baghdad", value: "Asia/Baghdad" },
|
||||||
|
{ label: "Bahrain", value: "Asia/Bahrain" },
|
||||||
|
{ label: "Baku", value: "Asia/Baku" },
|
||||||
|
{ label: "Bangkok", value: "Asia/Bangkok" },
|
||||||
|
{ label: "Barnaul", value: "Asia/Barnaul" },
|
||||||
|
{ label: "Beirut", value: "Asia/Beirut" },
|
||||||
|
{ label: "Bishkek", value: "Asia/Bishkek" },
|
||||||
|
{ label: "Brunei", value: "Asia/Brunei" },
|
||||||
|
{ label: "Chita", value: "Asia/Chita" },
|
||||||
|
{ label: "Choibalsan", value: "Asia/Choibalsan" },
|
||||||
|
{ label: "Colombo", value: "Asia/Colombo" },
|
||||||
|
{ label: "Damascus", value: "Asia/Damascus" },
|
||||||
|
{ label: "Dhaka", value: "Asia/Dhaka" },
|
||||||
|
{ label: "Dili", value: "Asia/Dili" },
|
||||||
|
{ label: "Dubai (Gulf Standard Time)", value: "Asia/Dubai" },
|
||||||
|
{ label: "Dushanbe", value: "Asia/Dushanbe" },
|
||||||
|
{ label: "Famagusta", value: "Asia/Famagusta" },
|
||||||
|
{ label: "Gaza", value: "Asia/Gaza" },
|
||||||
|
{ label: "Hebron", value: "Asia/Hebron" },
|
||||||
|
{ label: "Ho Chi Minh", value: "Asia/Ho_Chi_Minh" },
|
||||||
|
{ label: "Hong Kong", value: "Asia/Hong_Kong" },
|
||||||
|
{ label: "Hovd", value: "Asia/Hovd" },
|
||||||
|
{ label: "Irkutsk", value: "Asia/Irkutsk" },
|
||||||
|
{ label: "Jakarta", value: "Asia/Jakarta" },
|
||||||
|
{ label: "Jayapura", value: "Asia/Jayapura" },
|
||||||
|
{ label: "Jerusalem", value: "Asia/Jerusalem" },
|
||||||
|
{ label: "Kabul", value: "Asia/Kabul" },
|
||||||
|
{ label: "Kamchatka", value: "Asia/Kamchatka" },
|
||||||
|
{ label: "Karachi", value: "Asia/Karachi" },
|
||||||
|
{ label: "Kathmandu", value: "Asia/Kathmandu" },
|
||||||
|
{ label: "Khandyga", value: "Asia/Khandyga" },
|
||||||
|
{ label: "Kolkata (India Standard Time)", value: "Asia/Kolkata" },
|
||||||
|
{ label: "Krasnoyarsk", value: "Asia/Krasnoyarsk" },
|
||||||
|
{ label: "Kuala Lumpur", value: "Asia/Kuala_Lumpur" },
|
||||||
|
{ label: "Kuching", value: "Asia/Kuching" },
|
||||||
|
{ label: "Kuwait", value: "Asia/Kuwait" },
|
||||||
|
{ label: "Macau", value: "Asia/Macau" },
|
||||||
|
{ label: "Magadan", value: "Asia/Magadan" },
|
||||||
|
{ label: "Makassar", value: "Asia/Makassar" },
|
||||||
|
{ label: "Manila", value: "Asia/Manila" },
|
||||||
|
{ label: "Muscat", value: "Asia/Muscat" },
|
||||||
|
{ label: "Nicosia", value: "Asia/Nicosia" },
|
||||||
|
{ label: "Novokuznetsk", value: "Asia/Novokuznetsk" },
|
||||||
|
{ label: "Novosibirsk", value: "Asia/Novosibirsk" },
|
||||||
|
{ label: "Omsk", value: "Asia/Omsk" },
|
||||||
|
{ label: "Oral", value: "Asia/Oral" },
|
||||||
|
{ label: "Phnom Penh", value: "Asia/Phnom_Penh" },
|
||||||
|
{ label: "Pontianak", value: "Asia/Pontianak" },
|
||||||
|
{ label: "Pyongyang", value: "Asia/Pyongyang" },
|
||||||
|
{ label: "Qatar", value: "Asia/Qatar" },
|
||||||
|
{ label: "Qostanay", value: "Asia/Qostanay" },
|
||||||
|
{ label: "Qyzylorda", value: "Asia/Qyzylorda" },
|
||||||
|
{ label: "Riyadh", value: "Asia/Riyadh" },
|
||||||
|
{ label: "Sakhalin", value: "Asia/Sakhalin" },
|
||||||
|
{ label: "Samarkand", value: "Asia/Samarkand" },
|
||||||
|
{ label: "Seoul", value: "Asia/Seoul" },
|
||||||
|
{ label: "Shanghai (China Standard Time)", value: "Asia/Shanghai" },
|
||||||
|
{ label: "Singapore", value: "Asia/Singapore" },
|
||||||
|
{ label: "Srednekolymsk", value: "Asia/Srednekolymsk" },
|
||||||
|
{ label: "Taipei", value: "Asia/Taipei" },
|
||||||
|
{ label: "Tashkent", value: "Asia/Tashkent" },
|
||||||
|
{ label: "Tbilisi", value: "Asia/Tbilisi" },
|
||||||
|
{ label: "Tehran", value: "Asia/Tehran" },
|
||||||
|
{ label: "Thimphu", value: "Asia/Thimphu" },
|
||||||
|
{ label: "Tokyo (Japan Standard Time)", value: "Asia/Tokyo" },
|
||||||
|
{ label: "Tomsk", value: "Asia/Tomsk" },
|
||||||
|
{ label: "Ulaanbaatar", value: "Asia/Ulaanbaatar" },
|
||||||
|
{ label: "Urumqi", value: "Asia/Urumqi" },
|
||||||
|
{ label: "Ust-Nera", value: "Asia/Ust-Nera" },
|
||||||
|
{ label: "Vientiane", value: "Asia/Vientiane" },
|
||||||
|
{ label: "Vladivostok", value: "Asia/Vladivostok" },
|
||||||
|
{ label: "Yakutsk", value: "Asia/Yakutsk" },
|
||||||
|
{ label: "Yangon", value: "Asia/Yangon" },
|
||||||
|
{ label: "Yekaterinburg", value: "Asia/Yekaterinburg" },
|
||||||
|
{ label: "Yerevan", value: "Asia/Yerevan" },
|
||||||
|
],
|
||||||
|
Atlantic: [
|
||||||
|
{ label: "Azores", value: "Atlantic/Azores" },
|
||||||
|
{ label: "Bermuda", value: "Atlantic/Bermuda" },
|
||||||
|
{ label: "Canary", value: "Atlantic/Canary" },
|
||||||
|
{ label: "Cape Verde", value: "Atlantic/Cape_Verde" },
|
||||||
|
{ label: "Faroe", value: "Atlantic/Faroe" },
|
||||||
|
{ label: "Madeira", value: "Atlantic/Madeira" },
|
||||||
|
{ label: "Reykjavik", value: "Atlantic/Reykjavik" },
|
||||||
|
{ label: "South Georgia", value: "Atlantic/South_Georgia" },
|
||||||
|
{ label: "St Helena", value: "Atlantic/St_Helena" },
|
||||||
|
{ label: "Stanley", value: "Atlantic/Stanley" },
|
||||||
|
],
|
||||||
|
Australia: [
|
||||||
|
{ label: "Adelaide", value: "Australia/Adelaide" },
|
||||||
|
{ label: "Brisbane", value: "Australia/Brisbane" },
|
||||||
|
{ label: "Broken Hill", value: "Australia/Broken_Hill" },
|
||||||
|
{ label: "Darwin", value: "Australia/Darwin" },
|
||||||
|
{ label: "Eucla", value: "Australia/Eucla" },
|
||||||
|
{ label: "Hobart", value: "Australia/Hobart" },
|
||||||
|
{ label: "Lindeman", value: "Australia/Lindeman" },
|
||||||
|
{ label: "Lord Howe", value: "Australia/Lord_Howe" },
|
||||||
|
{ label: "Melbourne", value: "Australia/Melbourne" },
|
||||||
|
{ label: "Perth", value: "Australia/Perth" },
|
||||||
|
{ label: "Sydney (Australian Eastern Time)", value: "Australia/Sydney" },
|
||||||
|
],
|
||||||
|
Europe: [
|
||||||
|
{ label: "Amsterdam", value: "Europe/Amsterdam" },
|
||||||
|
{ label: "Andorra", value: "Europe/Andorra" },
|
||||||
|
{ label: "Astrakhan", value: "Europe/Astrakhan" },
|
||||||
|
{ label: "Athens", value: "Europe/Athens" },
|
||||||
|
{ label: "Belgrade", value: "Europe/Belgrade" },
|
||||||
|
{ label: "Berlin (Central European Time)", value: "Europe/Berlin" },
|
||||||
|
{ label: "Bratislava", value: "Europe/Bratislava" },
|
||||||
|
{ label: "Brussels", value: "Europe/Brussels" },
|
||||||
|
{ label: "Bucharest", value: "Europe/Bucharest" },
|
||||||
|
{ label: "Budapest", value: "Europe/Budapest" },
|
||||||
|
{ label: "Busingen", value: "Europe/Busingen" },
|
||||||
|
{ label: "Chisinau", value: "Europe/Chisinau" },
|
||||||
|
{ label: "Copenhagen", value: "Europe/Copenhagen" },
|
||||||
|
{ label: "Dublin", value: "Europe/Dublin" },
|
||||||
|
{ label: "Gibraltar", value: "Europe/Gibraltar" },
|
||||||
|
{ label: "Guernsey", value: "Europe/Guernsey" },
|
||||||
|
{ label: "Helsinki", value: "Europe/Helsinki" },
|
||||||
|
{ label: "Isle of Man", value: "Europe/Isle_of_Man" },
|
||||||
|
{ label: "Istanbul", value: "Europe/Istanbul" },
|
||||||
|
{ label: "Jersey", value: "Europe/Jersey" },
|
||||||
|
{ label: "Kaliningrad", value: "Europe/Kaliningrad" },
|
||||||
|
{ label: "Kirov", value: "Europe/Kirov" },
|
||||||
|
{ label: "Kyiv", value: "Europe/Kyiv" },
|
||||||
|
{ label: "Lisbon", value: "Europe/Lisbon" },
|
||||||
|
{ label: "Ljubljana", value: "Europe/Ljubljana" },
|
||||||
|
{ label: "London (Greenwich Mean Time)", value: "Europe/London" },
|
||||||
|
{ label: "Luxembourg", value: "Europe/Luxembourg" },
|
||||||
|
{ label: "Madrid", value: "Europe/Madrid" },
|
||||||
|
{ label: "Malta", value: "Europe/Malta" },
|
||||||
|
{ label: "Mariehamn", value: "Europe/Mariehamn" },
|
||||||
|
{ label: "Minsk", value: "Europe/Minsk" },
|
||||||
|
{ label: "Monaco", value: "Europe/Monaco" },
|
||||||
|
{ label: "Moscow", value: "Europe/Moscow" },
|
||||||
|
{ label: "Oslo", value: "Europe/Oslo" },
|
||||||
|
{ label: "Paris (Central European Time)", value: "Europe/Paris" },
|
||||||
|
{ label: "Podgorica", value: "Europe/Podgorica" },
|
||||||
|
{ label: "Prague", value: "Europe/Prague" },
|
||||||
|
{ label: "Riga", value: "Europe/Riga" },
|
||||||
|
{ label: "Rome", value: "Europe/Rome" },
|
||||||
|
{ label: "Samara", value: "Europe/Samara" },
|
||||||
|
{ label: "San Marino", value: "Europe/San_Marino" },
|
||||||
|
{ label: "Sarajevo", value: "Europe/Sarajevo" },
|
||||||
|
{ label: "Saratov", value: "Europe/Saratov" },
|
||||||
|
{ label: "Simferopol", value: "Europe/Simferopol" },
|
||||||
|
{ label: "Skopje", value: "Europe/Skopje" },
|
||||||
|
{ label: "Sofia", value: "Europe/Sofia" },
|
||||||
|
{ label: "Stockholm", value: "Europe/Stockholm" },
|
||||||
|
{ label: "Tallinn", value: "Europe/Tallinn" },
|
||||||
|
{ label: "Tirane", value: "Europe/Tirane" },
|
||||||
|
{ label: "Ulyanovsk", value: "Europe/Ulyanovsk" },
|
||||||
|
{ label: "Vaduz", value: "Europe/Vaduz" },
|
||||||
|
{ label: "Vatican", value: "Europe/Vatican" },
|
||||||
|
{ label: "Vienna", value: "Europe/Vienna" },
|
||||||
|
{ label: "Vilnius", value: "Europe/Vilnius" },
|
||||||
|
{ label: "Volgograd", value: "Europe/Volgograd" },
|
||||||
|
{ label: "Warsaw", value: "Europe/Warsaw" },
|
||||||
|
{ label: "Zagreb", value: "Europe/Zagreb" },
|
||||||
|
{ label: "Zurich", value: "Europe/Zurich" },
|
||||||
|
],
|
||||||
|
Indian: [
|
||||||
|
{ label: "Antananarivo", value: "Indian/Antananarivo" },
|
||||||
|
{ label: "Chagos", value: "Indian/Chagos" },
|
||||||
|
{ label: "Christmas", value: "Indian/Christmas" },
|
||||||
|
{ label: "Cocos", value: "Indian/Cocos" },
|
||||||
|
{ label: "Comoro", value: "Indian/Comoro" },
|
||||||
|
{ label: "Kerguelen", value: "Indian/Kerguelen" },
|
||||||
|
{ label: "Mahe", value: "Indian/Mahe" },
|
||||||
|
{ label: "Maldives", value: "Indian/Maldives" },
|
||||||
|
{ label: "Mauritius", value: "Indian/Mauritius" },
|
||||||
|
{ label: "Mayotte", value: "Indian/Mayotte" },
|
||||||
|
{ label: "Reunion", value: "Indian/Reunion" },
|
||||||
|
],
|
||||||
|
Pacific: [
|
||||||
|
{ label: "Apia", value: "Pacific/Apia" },
|
||||||
|
{ label: "Auckland", value: "Pacific/Auckland" },
|
||||||
|
{ label: "Bougainville", value: "Pacific/Bougainville" },
|
||||||
|
{ label: "Chatham", value: "Pacific/Chatham" },
|
||||||
|
{ label: "Chuuk", value: "Pacific/Chuuk" },
|
||||||
|
{ label: "Easter", value: "Pacific/Easter" },
|
||||||
|
{ label: "Efate", value: "Pacific/Efate" },
|
||||||
|
{ label: "Fakaofo", value: "Pacific/Fakaofo" },
|
||||||
|
{ label: "Fiji", value: "Pacific/Fiji" },
|
||||||
|
{ label: "Funafuti", value: "Pacific/Funafuti" },
|
||||||
|
{ label: "Galapagos", value: "Pacific/Galapagos" },
|
||||||
|
{ label: "Gambier", value: "Pacific/Gambier" },
|
||||||
|
{ label: "Guadalcanal", value: "Pacific/Guadalcanal" },
|
||||||
|
{ label: "Guam", value: "Pacific/Guam" },
|
||||||
|
{ label: "Honolulu", value: "Pacific/Honolulu" },
|
||||||
|
{ label: "Kanton", value: "Pacific/Kanton" },
|
||||||
|
{ label: "Kiritimati", value: "Pacific/Kiritimati" },
|
||||||
|
{ label: "Kosrae", value: "Pacific/Kosrae" },
|
||||||
|
{ label: "Kwajalein", value: "Pacific/Kwajalein" },
|
||||||
|
{ label: "Majuro", value: "Pacific/Majuro" },
|
||||||
|
{ label: "Marquesas", value: "Pacific/Marquesas" },
|
||||||
|
{ label: "Midway", value: "Pacific/Midway" },
|
||||||
|
{ label: "Nauru", value: "Pacific/Nauru" },
|
||||||
|
{ label: "Niue", value: "Pacific/Niue" },
|
||||||
|
{ label: "Norfolk", value: "Pacific/Norfolk" },
|
||||||
|
{ label: "Noumea", value: "Pacific/Noumea" },
|
||||||
|
{ label: "Pago Pago", value: "Pacific/Pago_Pago" },
|
||||||
|
{ label: "Palau", value: "Pacific/Palau" },
|
||||||
|
{ label: "Pitcairn", value: "Pacific/Pitcairn" },
|
||||||
|
{ label: "Pohnpei", value: "Pacific/Pohnpei" },
|
||||||
|
{ label: "Port Moresby", value: "Pacific/Port_Moresby" },
|
||||||
|
{ label: "Rarotonga", value: "Pacific/Rarotonga" },
|
||||||
|
{ label: "Saipan", value: "Pacific/Saipan" },
|
||||||
|
{ label: "Tahiti", value: "Pacific/Tahiti" },
|
||||||
|
{ label: "Tarawa", value: "Pacific/Tarawa" },
|
||||||
|
{ label: "Tongatapu", value: "Pacific/Tongatapu" },
|
||||||
|
{ label: "Wake", value: "Pacific/Wake" },
|
||||||
|
{ label: "Wallis", value: "Pacific/Wallis" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get display label for a timezone value
|
||||||
|
export function getTimezoneLabel(value: string | undefined): string {
|
||||||
|
if (!value) return "UTC (default)";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -97,6 +97,16 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
const repository = form.watch("repository");
|
const repository = form.watch("repository");
|
||||||
const gitlabId = form.watch("gitlabId");
|
const gitlabId = form.watch("gitlabId");
|
||||||
|
|
||||||
|
const gitlabUrl = useMemo(() => {
|
||||||
|
const url = gitlabProviders?.find(
|
||||||
|
(provider) => provider.gitlabId === gitlabId,
|
||||||
|
)?.gitlabUrl;
|
||||||
|
|
||||||
|
const gitlabUrl = url?.replace(/\/$/, "");
|
||||||
|
|
||||||
|
return gitlabUrl || "https://gitlab.com";
|
||||||
|
}, [gitlabId, gitlabProviders]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: repositories,
|
data: repositories,
|
||||||
isLoading: isLoadingRepositories,
|
isLoading: isLoadingRepositories,
|
||||||
@@ -224,9 +234,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormItem className="md:col-span-2 flex flex-col">
|
<FormItem className="md:col-span-2 flex flex-col">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<FormLabel>Repository</FormLabel>
|
<FormLabel>Repository</FormLabel>
|
||||||
{field.value.owner && field.value.repo && (
|
{field.value.gitlabPathNamespace && (
|
||||||
<Link
|
<Link
|
||||||
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
|
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ export const getLogType = (message: string): LogStyle => {
|
|||||||
/(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) ||
|
/(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) ||
|
||||||
/(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) ||
|
/(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) ||
|
||||||
/\b(?:deprecated|obsolete)\b/i.test(lowerMessage) ||
|
/\b(?:deprecated|obsolete)\b/i.test(lowerMessage) ||
|
||||||
/\b(?:unstable|experimental)\b/i.test(lowerMessage)
|
/\b(?:unstable|experimental)\b/i.test(lowerMessage) ||
|
||||||
|
/⚠|⚠️/i.test(lowerMessage)
|
||||||
) {
|
) {
|
||||||
return LOG_STYLES.warning;
|
return LOG_STYLES.warning;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -559,6 +559,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder="******************"
|
placeholder="******************"
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
|
enablePasswordGenerator={true}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -578,6 +579,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
|||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="******************"
|
placeholder="******************"
|
||||||
|
enablePasswordGenerator={true}
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -102,7 +102,9 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to create environment");
|
toast.error(
|
||||||
|
`Failed to create environment: ${error instanceof Error ? error.message : error}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,7 +125,9 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to update environment");
|
toast.error(
|
||||||
|
`Failed to update environment: ${error instanceof Error ? error.message : error}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -140,15 +144,18 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
setIsDeleteDialogOpen(false);
|
setIsDeleteDialogOpen(false);
|
||||||
setSelectedEnvironment(null);
|
setSelectedEnvironment(null);
|
||||||
|
|
||||||
// Redirect to production if we deleted the current environment
|
// Redirect to first available environment if we deleted the current environment
|
||||||
if (selectedEnvironment.environmentId === currentEnvironmentId) {
|
if (selectedEnvironment.environmentId === currentEnvironmentId) {
|
||||||
const productionEnv = environments?.find(
|
const firstEnv = environments?.find(
|
||||||
(env) => env.name === "production",
|
(env) => env.environmentId !== selectedEnvironment.environmentId,
|
||||||
);
|
);
|
||||||
if (productionEnv) {
|
if (firstEnv) {
|
||||||
router.push(
|
router.push(
|
||||||
`/dashboard/project/${projectId}/environment/${productionEnv.environmentId}`,
|
`/dashboard/project/${projectId}/environment/${firstEnv.environmentId}`,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// No other environments, redirect to project page
|
||||||
|
router.push(`/dashboard/project/${projectId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -239,8 +246,8 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{environment.name !== "production" && (
|
<div className="flex items-center gap-1 px-2">
|
||||||
<div className="flex items-center gap-1 px-2">
|
{!environment.isDefault && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -252,22 +259,21 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
>
|
>
|
||||||
<PencilIcon className="h-3 w-3" />
|
<PencilIcon className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
{canDeleteEnvironments && (
|
{canDeleteEnvironments && !environment.isDefault && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openDeleteDialog(environment);
|
openDeleteDialog(environment);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TrashIcon className="h-3 w-3" />
|
<TrashIcon className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -190,7 +190,9 @@ export const ShowProjects = () => {
|
|||||||
Create and manage your projects
|
Create and manage your projects
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
{(auth?.role === "owner" || auth?.canCreateProjects) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canCreateProjects) && (
|
||||||
<div className="">
|
<div className="">
|
||||||
<HandleProject />
|
<HandleProject />
|
||||||
</div>
|
</div>
|
||||||
@@ -286,13 +288,29 @@ export const ShowProjects = () => {
|
|||||||
)
|
)
|
||||||
.some(Boolean);
|
.some(Boolean);
|
||||||
|
|
||||||
|
// Find default environment from accessible environments, or fall back to first accessible environment
|
||||||
|
const accessibleEnvironment =
|
||||||
|
project?.environments.find((env) => env.isDefault) ||
|
||||||
|
project?.environments?.[0];
|
||||||
|
|
||||||
|
const hasNoEnvironments = !accessibleEnvironment;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={project.projectId}
|
key={project.projectId}
|
||||||
className="w-full lg:max-w-md"
|
className="w-full lg:max-w-md"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/project/${project.projectId}/environment/${project?.environments?.[0]?.environmentId}`}
|
href={
|
||||||
|
hasNoEnvironments
|
||||||
|
? "#"
|
||||||
|
: `/dashboard/project/${project.projectId}/environment/${accessibleEnvironment?.environmentId}`
|
||||||
|
}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (hasNoEnvironments) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
|
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
|
||||||
{haveServicesWithDomains ? (
|
{haveServicesWithDomains ? (
|
||||||
@@ -413,7 +431,7 @@ export const ShowProjects = () => {
|
|||||||
) : null}
|
) : null}
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center justify-between gap-2">
|
<CardTitle className="flex items-center justify-between gap-2">
|
||||||
<span className="flex flex-col gap-1.5">
|
<span className="flex flex-col gap-1.5 ">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BookIcon className="size-4 text-muted-foreground" />
|
<BookIcon className="size-4 text-muted-foreground" />
|
||||||
<span className="text-base font-medium leading-none">
|
<span className="text-base font-medium leading-none">
|
||||||
@@ -421,9 +439,19 @@ export const ShowProjects = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">
|
<span className="text-sm font-medium text-muted-foreground break-all">
|
||||||
{project.description}
|
{project.description}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{hasNoEnvironments && (
|
||||||
|
<div className="flex flex-row gap-2 items-center rounded-lg bg-yellow-50 p-2 mt-2 dark:bg-yellow-950">
|
||||||
|
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
|
||||||
|
<span className="text-xs text-yellow-600 dark:text-yellow-400">
|
||||||
|
You have access to this project but no
|
||||||
|
environments are available
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex self-start space-x-1">
|
<div className="flex self-start space-x-1">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -89,24 +89,26 @@ export const SearchCommand = () => {
|
|||||||
<CommandGroup heading={"Projects"}>
|
<CommandGroup heading={"Projects"}>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
{data?.map((project) => {
|
{data?.map((project) => {
|
||||||
const productionEnvironment = project.environments.find(
|
// Find default environment from accessible environments, or fall back to first accessible environment
|
||||||
(environment) => environment.name === "production",
|
const defaultEnvironment =
|
||||||
);
|
project.environments.find(
|
||||||
|
(environment) => environment.isDefault,
|
||||||
|
) || project?.environments?.[0];
|
||||||
|
|
||||||
if (!productionEnvironment) return null;
|
if (!defaultEnvironment) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={project.projectId}
|
key={project.projectId}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
router.push(
|
router.push(
|
||||||
`/dashboard/project/${project.projectId}/environment/${productionEnvironment!.environmentId}`,
|
`/dashboard/project/${project.projectId}/environment/${defaultEnvironment.environmentId}`,
|
||||||
);
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BookIcon className="size-4 text-muted-foreground mr-2" />
|
<BookIcon className="size-4 text-muted-foreground mr-2" />
|
||||||
{project.name} / {productionEnvironment!.name}
|
{project.name} / {defaultEnvironment.name}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { CreditCard, FileText } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ShowInvoices } from "./show-invoices";
|
||||||
|
|
||||||
|
const navigationItems = [
|
||||||
|
{
|
||||||
|
name: "Subscription",
|
||||||
|
href: "/dashboard/settings/billing",
|
||||||
|
icon: CreditCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invoices",
|
||||||
|
href: "/dashboard/settings/invoices",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ShowBillingInvoices = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||||
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
|
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||||
|
Billing
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage your subscription and invoices
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4 py-4 border-t">
|
||||||
|
<nav className="flex space-x-2 border-b">
|
||||||
|
{navigationItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = router.pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||||
|
isActive
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<ShowInvoices />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,11 +4,13 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
MinusIcon,
|
MinusIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -37,7 +39,22 @@ export const calculatePrice = (count: number, isAnnual = false) => {
|
|||||||
if (count <= 1) return 4.5;
|
if (count <= 1) return 4.5;
|
||||||
return count * 3.5;
|
return count * 3.5;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const navigationItems = [
|
||||||
|
{
|
||||||
|
name: "Subscription",
|
||||||
|
href: "/dashboard/settings/billing",
|
||||||
|
icon: CreditCard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invoices",
|
||||||
|
href: "/dashboard/settings/invoices",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export const ShowBilling = () => {
|
export const ShowBilling = () => {
|
||||||
|
const router = useRouter();
|
||||||
const { data: servers } = api.server.count.useQuery();
|
const { data: servers } = api.server.count.useQuery();
|
||||||
const { data: admin } = api.user.get.useQuery();
|
const { data: admin } = api.user.get.useQuery();
|
||||||
const { data, isLoading } = api.stripe.getProducts.useQuery();
|
const { data, isLoading } = api.stripe.getProducts.useQuery();
|
||||||
@@ -76,17 +93,41 @@ export const ShowBilling = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||||
<div className="rounded-xl bg-background shadow-md ">
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
<CardHeader className="">
|
<CardHeader>
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||||
Billing
|
Billing
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Manage your subscription</CardDescription>
|
<CardDescription>
|
||||||
|
Manage your subscription and invoices
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
<CardContent className="space-y-4 py-4 border-t">
|
||||||
<div className="flex flex-col gap-4 w-full">
|
<nav className="flex space-x-2 border-b">
|
||||||
|
{navigationItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = router.pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||||
|
isActive
|
||||||
|
? "border-primary text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 w-full mt-6">
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="monthly"
|
defaultValue="monthly"
|
||||||
value={isAnnual ? "annual" : "monthly"}
|
value={isAnnual ? "annual" : "monthly"}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { Download, ExternalLink, FileText, Loader2 } from "lucide-react";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const formatDate = (timestamp: number | null) => {
|
||||||
|
if (!timestamp) return "-";
|
||||||
|
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAmount = (amount: number, currency: string) => {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: currency.toUpperCase(),
|
||||||
|
}).format(amount / 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: Stripe.Invoice.Status | null) => {
|
||||||
|
const statusConfig: Record<
|
||||||
|
Stripe.Invoice.Status,
|
||||||
|
{ label: string; variant: "default" | "secondary" | "destructive" }
|
||||||
|
> = {
|
||||||
|
paid: { label: "Paid", variant: "default" },
|
||||||
|
open: { label: "Open", variant: "secondary" },
|
||||||
|
draft: { label: "Draft", variant: "secondary" },
|
||||||
|
void: { label: "Void", variant: "destructive" },
|
||||||
|
uncollectible: { label: "Uncollectible", variant: "destructive" },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return <Badge variant="secondary">Unknown</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = statusConfig[status] || {
|
||||||
|
label: status,
|
||||||
|
variant: "secondary" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ShowInvoices = () => {
|
||||||
|
const { data: invoices, isLoading } = api.stripe.getInvoices.useQuery();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center min-h-[20vh]">
|
||||||
|
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center">
|
||||||
|
Loading invoices...
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : invoices && invoices.length > 0 ? (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Invoice</TableHead>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Due Date</TableHead>
|
||||||
|
<TableHead>Amount</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{invoices.map((invoice) => (
|
||||||
|
<TableRow key={invoice.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{invoice.number || invoice.id.slice(0, 12)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatDate(invoice.created)}</TableCell>
|
||||||
|
<TableCell>{formatDate(invoice.dueDate)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatAmount(invoice.amountDue, invoice.currency)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(invoice.status)}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{invoice.hostedInvoiceUrl && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(
|
||||||
|
invoice.hostedInvoiceUrl || "",
|
||||||
|
"_blank",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{invoice.invoicePdf && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
window.open(invoice.invoicePdf || "", "_blank")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[20vh] gap-2">
|
||||||
|
<FileText className="size-12 text-muted-foreground" />
|
||||||
|
<p className="text-base text-muted-foreground">No invoices found</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Your invoices will appear here once you have a subscription
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -42,12 +42,38 @@ const AddRegistrySchema = z.object({
|
|||||||
username: z.string().min(1, {
|
username: z.string().min(1, {
|
||||||
message: "Username is required",
|
message: "Username is required",
|
||||||
}),
|
}),
|
||||||
password: z.string().min(1, {
|
password: z.string(),
|
||||||
message: "Password is required",
|
registryUrl: z
|
||||||
}),
|
.string()
|
||||||
registryUrl: z.string(),
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(val) => {
|
||||||
|
// If empty or undefined, skip validation (field is optional)
|
||||||
|
if (!val || val.trim().length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Validate that it's a valid hostname (no protocol, no path, optional port)
|
||||||
|
// Valid formats: example.com, registry.example.com, [::1], example.com:5000
|
||||||
|
// Invalid: https://example.com, example.com/path
|
||||||
|
const trimmed = val.trim();
|
||||||
|
// Check for protocol or path - these are not allowed
|
||||||
|
if (/^https?:\/\//i.test(trimmed) || trimmed.includes("/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Basic hostname validation: allow alphanumeric, dots, hyphens, underscores, and IPv6 in brackets
|
||||||
|
// Allow optional port at the end
|
||||||
|
const hostnameRegex =
|
||||||
|
/^(?:\[[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,253}[a-zA-Z0-9])?)(?::\d+)?$/;
|
||||||
|
return hostnameRegex.test(trimmed);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid registry URL. Please enter only the hostname (e.g., example.com or registry.example.com). Do not include protocol (https://) or paths.",
|
||||||
|
},
|
||||||
|
),
|
||||||
imagePrefix: z.string(),
|
imagePrefix: z.string(),
|
||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
|
isEditing: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type AddRegistry = z.infer<typeof AddRegistrySchema>;
|
type AddRegistry = z.infer<typeof AddRegistrySchema>;
|
||||||
@@ -74,13 +100,21 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
const { mutateAsync, error, isError } = registryId
|
const { mutateAsync, error, isError } = registryId
|
||||||
? api.registry.update.useMutation()
|
? api.registry.update.useMutation()
|
||||||
: api.registry.create.useMutation();
|
: api.registry.create.useMutation();
|
||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
const { data: deployServers } = api.server.withSSHKey.useQuery();
|
||||||
|
const { data: buildServers } = api.server.buildServers.useQuery();
|
||||||
|
const servers = [...(deployServers || []), ...(buildServers || [])];
|
||||||
const {
|
const {
|
||||||
mutateAsync: testRegistry,
|
mutateAsync: testRegistry,
|
||||||
isLoading,
|
isLoading,
|
||||||
error: testRegistryError,
|
error: testRegistryError,
|
||||||
isError: testRegistryIsError,
|
isError: testRegistryIsError,
|
||||||
} = api.registry.testRegistry.useMutation();
|
} = api.registry.testRegistry.useMutation();
|
||||||
|
const {
|
||||||
|
mutateAsync: testRegistryById,
|
||||||
|
isLoading: isLoadingById,
|
||||||
|
error: testRegistryByIdError,
|
||||||
|
isError: testRegistryByIdIsError,
|
||||||
|
} = api.registry.testRegistryById.useMutation();
|
||||||
const form = useForm<AddRegistry>({
|
const form = useForm<AddRegistry>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: "",
|
username: "",
|
||||||
@@ -89,8 +123,26 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
imagePrefix: "",
|
imagePrefix: "",
|
||||||
registryName: "",
|
registryName: "",
|
||||||
serverId: "",
|
serverId: "",
|
||||||
|
isEditing: !!registryId,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(AddRegistrySchema),
|
resolver: zodResolver(
|
||||||
|
AddRegistrySchema.refine(
|
||||||
|
(data) => {
|
||||||
|
// When creating a new registry, password is required
|
||||||
|
if (
|
||||||
|
!data.isEditing &&
|
||||||
|
(!data.password || data.password.length === 0)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Password is required",
|
||||||
|
path: ["password"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const password = form.watch("password");
|
const password = form.watch("password");
|
||||||
@@ -99,6 +151,9 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
const registryName = form.watch("registryName");
|
const registryName = form.watch("registryName");
|
||||||
const imagePrefix = form.watch("imagePrefix");
|
const imagePrefix = form.watch("imagePrefix");
|
||||||
const serverId = form.watch("serverId");
|
const serverId = form.watch("serverId");
|
||||||
|
const selectedServer = servers?.find(
|
||||||
|
(server) => server.serverId === serverId,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (registry) {
|
if (registry) {
|
||||||
@@ -108,6 +163,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
registryUrl: registry.registryUrl,
|
registryUrl: registry.registryUrl,
|
||||||
imagePrefix: registry.imagePrefix || "",
|
imagePrefix: registry.imagePrefix || "",
|
||||||
registryName: registry.registryName,
|
registryName: registry.registryName,
|
||||||
|
isEditing: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -116,21 +172,29 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
registryUrl: "",
|
registryUrl: "",
|
||||||
imagePrefix: "",
|
imagePrefix: "",
|
||||||
serverId: "",
|
serverId: "",
|
||||||
|
isEditing: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful, registry]);
|
}, [form, form.reset, form.formState.isSubmitSuccessful, registry]);
|
||||||
|
|
||||||
const onSubmit = async (data: AddRegistry) => {
|
const onSubmit = async (data: AddRegistry) => {
|
||||||
await mutateAsync({
|
const payload: any = {
|
||||||
password: data.password,
|
|
||||||
registryName: data.registryName,
|
registryName: data.registryName,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
registryUrl: data.registryUrl,
|
registryUrl: data.registryUrl || "",
|
||||||
registryType: "cloud",
|
registryType: "cloud",
|
||||||
imagePrefix: data.imagePrefix,
|
imagePrefix: data.imagePrefix,
|
||||||
serverId: data.serverId,
|
serverId: data.serverId,
|
||||||
registryId: registryId || "",
|
registryId: registryId || "",
|
||||||
})
|
};
|
||||||
|
|
||||||
|
// Only include password if it's been provided (not empty)
|
||||||
|
// When editing, empty password means "keep the existing password"
|
||||||
|
if (data.password && data.password.length > 0) {
|
||||||
|
payload.password = data.password;
|
||||||
|
}
|
||||||
|
|
||||||
|
await mutateAsync(payload)
|
||||||
.then(async (_data) => {
|
.then(async (_data) => {
|
||||||
await utils.registry.all.invalidate();
|
await utils.registry.all.invalidate();
|
||||||
toast.success(registryId ? "Registry updated" : "Registry added");
|
toast.success(registryId ? "Registry updated" : "Registry added");
|
||||||
@@ -168,11 +232,14 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
Fill the next fields to add a external registry.
|
Fill the next fields to add a external registry.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{(isError || testRegistryIsError) && (
|
{(isError || testRegistryIsError || testRegistryByIdIsError) && (
|
||||||
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
<div className="flex flex-row gap-4 rounded-lg bg-red-50 p-2 dark:bg-red-950">
|
||||||
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
<AlertTriangle className="text-red-600 dark:text-red-400" />
|
||||||
<span className="text-sm text-red-600 dark:text-red-400">
|
<span className="text-sm text-red-600 dark:text-red-400">
|
||||||
{testRegistryError?.message || error?.message || ""}
|
{testRegistryError?.message ||
|
||||||
|
testRegistryByIdError?.message ||
|
||||||
|
error?.message ||
|
||||||
|
""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -223,10 +290,20 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
name="password"
|
name="password"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Password</FormLabel>
|
<FormLabel>Password{registryId && " (Optional)"}</FormLabel>
|
||||||
|
{registryId && (
|
||||||
|
<FormDescription>
|
||||||
|
Leave blank to keep existing password. Enter new
|
||||||
|
password to test or update it.
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Password"
|
placeholder={
|
||||||
|
registryId
|
||||||
|
? "Leave blank to keep existing"
|
||||||
|
: "Password"
|
||||||
|
}
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
{...field}
|
{...field}
|
||||||
type="password"
|
type="password"
|
||||||
@@ -261,6 +338,10 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Registry URL</FormLabel>
|
<FormLabel>Registry URL</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Enter only the hostname (e.g.,
|
||||||
|
aws_account_id.dkr.ecr.us-west-2.amazonaws.com).
|
||||||
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="aws_account_id.dkr.ecr.us-west-2.amazonaws.com"
|
placeholder="aws_account_id.dkr.ecr.us-west-2.amazonaws.com"
|
||||||
@@ -282,8 +363,40 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Server {!isCloud && "(Optional)"}</FormLabel>
|
<FormLabel>Server {!isCloud && "(Optional)"}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Select a server to test the registry. this will run the
|
{!isCloud ? (
|
||||||
following command on the server
|
<>
|
||||||
|
{serverId && serverId !== "none" && selectedServer ? (
|
||||||
|
<>
|
||||||
|
Authentication will be performed on{" "}
|
||||||
|
<strong>{selectedServer.name}</strong>. This
|
||||||
|
registry will be available on this server.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Choose where to authenticate with the registry. By
|
||||||
|
default, authentication occurs on the Dokploy
|
||||||
|
server. Select a specific server to authenticate
|
||||||
|
from that server instead.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{serverId && serverId !== "none" && selectedServer ? (
|
||||||
|
<>
|
||||||
|
Authentication will be performed on{" "}
|
||||||
|
<strong>{selectedServer.name}</strong>. This
|
||||||
|
registry will be available on this server.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Select a server to authenticate with the registry.
|
||||||
|
The authentication will be performed from the
|
||||||
|
selected server.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
@@ -294,16 +407,33 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
<SelectValue placeholder="Select a server" />
|
<SelectValue placeholder="Select a server" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
{deployServers && deployServers.length > 0 && (
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Deploy Servers</SelectLabel>
|
||||||
|
{deployServers.map((server) => (
|
||||||
|
<SelectItem
|
||||||
|
key={server.serverId}
|
||||||
|
value={server.serverId}
|
||||||
|
>
|
||||||
|
{server.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
)}
|
||||||
|
{buildServers && buildServers.length > 0 && (
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Build Servers</SelectLabel>
|
||||||
|
{buildServers.map((server) => (
|
||||||
|
<SelectItem
|
||||||
|
key={server.serverId}
|
||||||
|
value={server.serverId}
|
||||||
|
>
|
||||||
|
{server.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
)}
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
<SelectLabel>Servers</SelectLabel>
|
|
||||||
{servers?.map((server) => (
|
|
||||||
<SelectItem
|
|
||||||
key={server.serverId}
|
|
||||||
value={server.serverId}
|
|
||||||
>
|
|
||||||
{server.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectItem value={"none"}>None</SelectItem>
|
<SelectItem value={"none"}>None</SelectItem>
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -321,8 +451,37 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={"secondary"}
|
variant={"secondary"}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading || isLoadingById}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
// When editing with empty password, use the existing password from DB
|
||||||
|
if (registryId && (!password || password.length === 0)) {
|
||||||
|
await testRegistryById({
|
||||||
|
registryId: registryId || "",
|
||||||
|
...(serverId && { serverId }),
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (data) {
|
||||||
|
toast.success("Registry Tested Successfully");
|
||||||
|
} else {
|
||||||
|
toast.error("Registry Test Failed");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error testing the registry");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When creating, password is required
|
||||||
|
if (!registryId && (!password || password.length === 0)) {
|
||||||
|
form.setError("password", {
|
||||||
|
type: "manual",
|
||||||
|
message: "Password is required",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When creating or editing with new password, validate and test with provided credentials
|
||||||
const validationResult = AddRegistrySchema.safeParse({
|
const validationResult = AddRegistrySchema.safeParse({
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
@@ -330,6 +489,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
registryName: "Dokploy Registry",
|
registryName: "Dokploy Registry",
|
||||||
imagePrefix,
|
imagePrefix,
|
||||||
serverId,
|
serverId,
|
||||||
|
isEditing: !!registryId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validationResult.success) {
|
if (!validationResult.success) {
|
||||||
@@ -345,7 +505,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
await testRegistry({
|
await testRegistry({
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
registryUrl: registryUrl,
|
registryUrl: registryUrl || "",
|
||||||
registryName: registryName,
|
registryName: registryName,
|
||||||
registryType: "cloud",
|
registryType: "cloud",
|
||||||
imagePrefix: imagePrefix,
|
imagePrefix: imagePrefix,
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ export const HandleDestinations = ({ destinationId }: Props) => {
|
|||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
|
toast.success(`Destination ${destinationId ? "Updated" : "Created"}`);
|
||||||
await utils.destination.all.invalidate();
|
await utils.destination.all.invalidate();
|
||||||
|
if (destinationId) {
|
||||||
|
await utils.destination.one.invalidate({ destinationId });
|
||||||
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -369,6 +369,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
webhookUrl: notification.lark?.webhookUrl,
|
webhookUrl: notification.lark?.webhookUrl,
|
||||||
name: notification.name,
|
name: notification.name,
|
||||||
dockerCleanup: notification.dockerCleanup,
|
dockerCleanup: notification.dockerCleanup,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
serverThreshold: notification.serverThreshold,
|
serverThreshold: notification.serverThreshold,
|
||||||
});
|
});
|
||||||
} else if (notification.notificationType === "custom") {
|
} else if (notification.notificationType === "custom") {
|
||||||
@@ -388,6 +389,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
name: notification.name,
|
name: notification.name,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
dockerCleanup: notification.dockerCleanup,
|
dockerCleanup: notification.dockerCleanup,
|
||||||
serverThreshold: notification.serverThreshold,
|
serverThreshold: notification.serverThreshold,
|
||||||
});
|
});
|
||||||
@@ -522,6 +524,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
dockerCleanup: dockerCleanup,
|
dockerCleanup: dockerCleanup,
|
||||||
@@ -547,6 +550,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
endpoint: data.endpoint,
|
endpoint: data.endpoint,
|
||||||
headers: headersRecord,
|
headers: headersRecord,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Loader2, User } from "lucide-react";
|
import { Loader2, Palette, User } from "lucide-react";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { getAvatarType, isSolidColorAvatar } from "@/lib/avatar-utils";
|
||||||
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
|
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { Configure2FA } from "./configure-2fa";
|
import { Configure2FA } from "./configure-2fa";
|
||||||
@@ -74,6 +75,7 @@ export const ProfileForm = () => {
|
|||||||
} = api.user.update.useMutation();
|
} = api.user.update.useMutation();
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
||||||
|
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const availableAvatars = useMemo(() => {
|
const availableAvatars = useMemo(() => {
|
||||||
if (gravatarHash === null) return randomImages;
|
if (gravatarHash === null) return randomImages;
|
||||||
@@ -274,16 +276,8 @@ export const ProfileForm = () => {
|
|||||||
onValueChange={(e) => {
|
onValueChange={(e) => {
|
||||||
field.onChange(e);
|
field.onChange(e);
|
||||||
}}
|
}}
|
||||||
defaultValue={
|
defaultValue={getAvatarType(field.value)}
|
||||||
field.value?.startsWith("data:")
|
value={getAvatarType(field.value)}
|
||||||
? "upload"
|
|
||||||
: field.value
|
|
||||||
}
|
|
||||||
value={
|
|
||||||
field.value?.startsWith("data:")
|
|
||||||
? "upload"
|
|
||||||
: field.value
|
|
||||||
}
|
|
||||||
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
|
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
|
||||||
>
|
>
|
||||||
<FormItem key="no-avatar">
|
<FormItem key="no-avatar">
|
||||||
@@ -370,6 +364,40 @@ export const ProfileForm = () => {
|
|||||||
/>
|
/>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
<FormItem key="color-avatar">
|
||||||
|
<FormLabel className="[&:has([data-state=checked])>.color-avatar]:border-primary [&:has([data-state=checked])>.color-avatar]:border-1 [&:has([data-state=checked])>.color-avatar]:p-px cursor-pointer relative">
|
||||||
|
<FormControl>
|
||||||
|
<RadioGroupItem
|
||||||
|
value="color"
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div
|
||||||
|
className="color-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-colors flex items-center justify-center overflow-hidden cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isSolidColorAvatar(
|
||||||
|
field.value,
|
||||||
|
)
|
||||||
|
? field.value
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
|
colorInputRef.current?.click()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!isSolidColorAvatar(field.value) && (
|
||||||
|
<Palette className="h-5 w-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={colorInputRef}
|
||||||
|
type="color"
|
||||||
|
className="absolute opacity-0 pointer-events-none w-12 h-12 top-0 left-0"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormLabel>
|
||||||
|
</FormItem>
|
||||||
{availableAvatars.map((image) => (
|
{availableAvatars.map((image) => (
|
||||||
<FormItem key={image}>
|
<FormItem key={image}>
|
||||||
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px cursor-pointer">
|
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px cursor-pointer">
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { Activity } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -13,20 +15,30 @@ import { ToggleDockerCleanup } from "./toggle-docker-cleanup";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
asButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowServerActions = ({ serverId }: Props) => {
|
export const ShowServerActions = ({ serverId, asButton = false }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
{asButton ? (
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
) : (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer"
|
className="w-full cursor-pointer"
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
View Actions
|
View Actions
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
)}
|
||||||
<DialogContent className="sm:max-w-xl">
|
<DialogContent className="sm:max-w-xl">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<DialogTitle className="text-xl">Web server settings</DialogTitle>
|
<DialogTitle className="text-xl">Web server settings</DialogTitle>
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
|||||||
serverId: serverId,
|
serverId: serverId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Cleaned all");
|
toast.success("Cleaning in progress... Please wait");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error cleaning all");
|
toast.error("Error cleaning all");
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ interface Props {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||||
const { data, refetch } = api.user.get.useQuery(undefined, {
|
const { data, refetch } = api.settings.getWebServerSettings.useQuery(
|
||||||
enabled: !serverId,
|
undefined,
|
||||||
});
|
{
|
||||||
|
enabled: !serverId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const { data: server, refetch: refetchServer } = api.server.one.useQuery(
|
const { data: server, refetch: refetchServer } = api.server.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -22,7 +25,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
|||||||
|
|
||||||
const enabled = serverId
|
const enabled = serverId
|
||||||
? server?.enableDockerCleanup
|
? server?.enableDockerCleanup
|
||||||
: data?.user.enableDockerCleanup;
|
: data?.enableDockerCleanup;
|
||||||
|
|
||||||
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
|
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
|
||||||
|
|
||||||
@@ -30,7 +33,10 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
|||||||
try {
|
try {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
enableDockerCleanup: checked,
|
enableDockerCleanup: checked,
|
||||||
serverId: serverId,
|
...(serverId && { serverId }),
|
||||||
|
} as {
|
||||||
|
enableDockerCleanup: boolean;
|
||||||
|
serverId?: string;
|
||||||
});
|
});
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
await refetchServer();
|
await refetchServer();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { PlusIcon } from "lucide-react";
|
import { Pencil, PlusIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -59,9 +59,10 @@ type Schema = z.infer<typeof Schema>;
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
|
asButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HandleServers = ({ serverId }: Props) => {
|
export const HandleServers = ({ serverId, asButton = false }: Props) => {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
@@ -137,21 +138,32 @@ export const HandleServers = ({ serverId }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
{serverId ? (
|
||||||
{serverId ? (
|
asButton ? (
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
) : (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer "
|
className="w-full cursor-pointer "
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Edit Server
|
Edit Server
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
)
|
||||||
|
) : (
|
||||||
|
<DialogTrigger asChild>
|
||||||
<Button className="cursor-pointer space-x-3">
|
<Button className="cursor-pointer space-x-3">
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
Create Server
|
Create Server
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</DialogTrigger>
|
||||||
</DialogTrigger>
|
)}
|
||||||
<DialogContent className="sm:max-w-3xl ">
|
<DialogContent className="sm:max-w-3xl ">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
|
<DialogTitle>{serverId ? "Edit" : "Create"} Server</DialogTitle>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const Schema = z.object({
|
|||||||
type Schema = z.infer<typeof Schema>;
|
type Schema = z.infer<typeof Schema>;
|
||||||
|
|
||||||
export const SetupMonitoring = ({ serverId }: Props) => {
|
export const SetupMonitoring = ({ serverId }: Props) => {
|
||||||
const { data } = serverId
|
const { data: serverData } = serverId
|
||||||
? api.server.one.useQuery(
|
? api.server.one.useQuery(
|
||||||
{
|
{
|
||||||
serverId: serverId || "",
|
serverId: serverId || "",
|
||||||
@@ -89,7 +89,14 @@ export const SetupMonitoring = ({ serverId }: Props) => {
|
|||||||
enabled: !!serverId,
|
enabled: !!serverId,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: api.user.getServerMetrics.useQuery();
|
: { data: null };
|
||||||
|
|
||||||
|
const { data: webServerSettings } =
|
||||||
|
api.settings.getWebServerSettings.useQuery(undefined, {
|
||||||
|
enabled: !serverId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = serverId ? serverData : webServerSettings;
|
||||||
|
|
||||||
const url = useUrl();
|
const url = useUrl();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { CopyIcon, ExternalLinkIcon, ServerIcon } from "lucide-react";
|
import { CopyIcon, ExternalLinkIcon, ServerIcon, Settings } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
@@ -36,9 +35,10 @@ import { ValidateServer } from "./validate-server";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
asButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SetupServer = ({ serverId }: Props) => {
|
export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { data: server } = api.server.one.useQuery(
|
const { data: server } = api.server.one.useQuery(
|
||||||
{
|
{
|
||||||
@@ -81,14 +81,23 @@ export const SetupServer = ({ serverId }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
{asButton ? (
|
||||||
<DropdownMenuItem
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
className="w-full cursor-pointer "
|
className="w-full cursor-pointer "
|
||||||
onSelect={(e) => e.preventDefault()}
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Setup Server
|
Setup Server <Settings className="size-4" />
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
</DialogTrigger>
|
)}
|
||||||
<DialogContent className="sm:max-w-4xl ">
|
<DialogContent className="sm:max-w-4xl ">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { KeyIcon, Loader2, MoreHorizontal, ServerIcon } from "lucide-react";
|
import {
|
||||||
|
Clock,
|
||||||
|
Key,
|
||||||
|
KeyIcon,
|
||||||
|
Loader2,
|
||||||
|
MoreHorizontal,
|
||||||
|
Network,
|
||||||
|
ServerIcon,
|
||||||
|
Terminal,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
@@ -18,20 +29,15 @@ import {
|
|||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
Table,
|
Tooltip,
|
||||||
TableBody,
|
TooltipContent,
|
||||||
TableCaption,
|
TooltipProvider,
|
||||||
TableCell,
|
TooltipTrigger,
|
||||||
TableHead,
|
} from "@/components/ui/tooltip";
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
|
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
|
||||||
import { TerminalModal } from "../web-server/terminal-modal";
|
import { TerminalModal } from "../web-server/terminal-modal";
|
||||||
@@ -59,7 +65,7 @@ export const ShowServers = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{query?.success && isCloud && <WelcomeSuscription />}
|
{query?.success && isCloud && <WelcomeSuscription />}
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
<Card className="h-full p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||||
<div className="rounded-xl bg-background shadow-md ">
|
<div className="rounded-xl bg-background shadow-md ">
|
||||||
<CardHeader className="">
|
<CardHeader className="">
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
<CardTitle className="text-xl flex flex-row gap-2">
|
||||||
@@ -114,240 +120,320 @@ export const ShowServers = () => {
|
|||||||
<HandleServers />
|
<HandleServers />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||||
<Table>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<TableCaption>
|
{data?.map((server) => {
|
||||||
<div className="flex flex-col gap-4">
|
const canDelete = server.totalSum === 0;
|
||||||
See all servers
|
const isActive = server.serverStatus === "active";
|
||||||
</div>
|
const isBuildServer = server.serverType === "build";
|
||||||
</TableCaption>
|
return (
|
||||||
<TableHeader>
|
<Card
|
||||||
<TableRow>
|
key={server.serverId}
|
||||||
<TableHead className="text-left">Name</TableHead>
|
className="relative hover:shadow-lg transition-shadow flex flex-col bg-transparent"
|
||||||
{isCloud && (
|
>
|
||||||
<TableHead className="text-center">
|
<CardHeader className="pb-3">
|
||||||
Status
|
<div className="flex items-start justify-between">
|
||||||
</TableHead>
|
<div className="flex items-center gap-2">
|
||||||
)}
|
<ServerIcon className="size-5 text-muted-foreground" />
|
||||||
<TableHead className="text-center">
|
<CardTitle className="text-lg">
|
||||||
Type
|
{server.name}
|
||||||
</TableHead>
|
</CardTitle>
|
||||||
<TableHead className="text-center">
|
</div>
|
||||||
IP Address
|
{isActive &&
|
||||||
</TableHead>
|
server.sshKeyId &&
|
||||||
<TableHead className="text-center">
|
!isBuildServer && (
|
||||||
Port
|
<DropdownMenu>
|
||||||
</TableHead>
|
<DropdownMenuTrigger asChild>
|
||||||
<TableHead className="text-center">
|
<Button
|
||||||
Username
|
variant="ghost"
|
||||||
</TableHead>
|
className="h-8 w-8 p-0"
|
||||||
<TableHead className="text-center">
|
>
|
||||||
SSH Key
|
<span className="sr-only">
|
||||||
</TableHead>
|
More options
|
||||||
<TableHead className="text-center">
|
</span>
|
||||||
Created
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</TableHead>
|
</Button>
|
||||||
<TableHead className="text-right">
|
</DropdownMenuTrigger>
|
||||||
Actions
|
<DropdownMenuContent align="end">
|
||||||
</TableHead>
|
<DropdownMenuLabel>
|
||||||
</TableRow>
|
Advanced
|
||||||
</TableHeader>
|
</DropdownMenuLabel>
|
||||||
<TableBody>
|
<ShowTraefikFileSystemModal
|
||||||
{data?.map((server) => {
|
serverId={server.serverId}
|
||||||
const canDelete = server.totalSum === 0;
|
/>
|
||||||
const isActive = server.serverStatus === "active";
|
<ShowDockerContainersModal
|
||||||
const isBuildServer =
|
serverId={server.serverId}
|
||||||
server.serverType === "build";
|
/>
|
||||||
return (
|
{isCloud && (
|
||||||
<TableRow key={server.serverId}>
|
<ShowMonitoringModal
|
||||||
<TableCell className="text-left">
|
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
||||||
{server.name}
|
token={
|
||||||
</TableCell>
|
server?.metricsConfig?.server
|
||||||
{isCloud && (
|
?.token
|
||||||
<TableHead className="text-center">
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ShowSwarmOverviewModal
|
||||||
|
serverId={server.serverId}
|
||||||
|
/>
|
||||||
|
<ShowNodesModal
|
||||||
|
serverId={server.serverId}
|
||||||
|
/>
|
||||||
|
<ShowSchedulesModal
|
||||||
|
serverId={server.serverId}
|
||||||
|
/>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<TooltipProvider>
|
||||||
|
<div className="flex gap-2 mt-2 flex-wrap">
|
||||||
|
{isCloud && (
|
||||||
|
<>
|
||||||
|
{server.serverStatus === "active" ? (
|
||||||
|
<Badge variant="default">
|
||||||
|
{server.serverStatus}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="inline-block">
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="cursor-help"
|
||||||
|
>
|
||||||
|
{server.serverStatus}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="max-w-xs"
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
<p className="text-sm">
|
||||||
|
This server is deactivated due
|
||||||
|
to lack of payment. Please pay
|
||||||
|
your invoice to reactivate it.
|
||||||
|
If you think this is an error,
|
||||||
|
please contact support.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
server.serverStatus === "active"
|
isBuildServer
|
||||||
? "default"
|
? "secondary"
|
||||||
: "destructive"
|
: "default"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{server.serverStatus}
|
{server.serverType}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableHead>
|
</div>
|
||||||
)}
|
</TooltipProvider>
|
||||||
<TableCell className="text-center">
|
</CardHeader>
|
||||||
<Badge
|
<CardContent className="space-y-3 flex-1 flex flex-col">
|
||||||
variant={
|
<div className="flex items-center gap-2 text-sm">
|
||||||
isBuildServer ? "secondary" : "default"
|
<Network className="size-4 text-muted-foreground" />
|
||||||
}
|
<span className="text-muted-foreground">
|
||||||
>
|
IP:
|
||||||
{server.serverType}
|
</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{server.ipAddress}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
<span className="text-muted-foreground">
|
||||||
<TableCell className="text-center">
|
Port:
|
||||||
<Badge>{server.ipAddress}</Badge>
|
</span>
|
||||||
</TableCell>
|
<span className="font-medium">
|
||||||
<TableCell className="text-center">
|
{server.port}
|
||||||
{server.port}
|
</span>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell className="text-center">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
{server.username}
|
<User className="size-4 text-muted-foreground" />
|
||||||
</TableCell>
|
<span className="text-muted-foreground">
|
||||||
<TableCell className="text-right">
|
User:
|
||||||
<span className="text-sm text-muted-foreground">
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{server.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Key className="size-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
SSH Key:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
{server.sshKeyId ? "Yes" : "No"}
|
{server.sshKeyId ? "Yes" : "No"}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell className="text-right">
|
<div className="flex items-center gap-2 text-sm pt-2 border-t">
|
||||||
<span className="text-sm text-muted-foreground">
|
<Clock className="size-4 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Created{" "}
|
||||||
{format(
|
{format(
|
||||||
new Date(server.createdAt),
|
new Date(server.createdAt),
|
||||||
"PPpp",
|
"PPp",
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</div>
|
||||||
|
|
||||||
<TableCell className="text-right flex justify-end">
|
{/* Compact Actions */}
|
||||||
<DropdownMenu>
|
{isActive && (
|
||||||
<DropdownMenuTrigger asChild>
|
<div className="flex items-center gap-2 pt-3 border-t mt-auto flex-wrap">
|
||||||
<Button
|
<div className="flex items-center gap-2 w-full">
|
||||||
variant="ghost"
|
<Tooltip>
|
||||||
className="h-8 w-8 p-0"
|
<TooltipTrigger asChild>
|
||||||
>
|
|
||||||
<span className="sr-only">
|
|
||||||
Open menu
|
|
||||||
</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
Actions
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
|
|
||||||
{isActive && (
|
|
||||||
<>
|
|
||||||
{server.sshKeyId && (
|
|
||||||
<TerminalModal
|
|
||||||
serverId={server.serverId}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{t(
|
|
||||||
"settings.common.enterTerminal",
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</TerminalModal>
|
|
||||||
)}
|
|
||||||
<SetupServer
|
<SetupServer
|
||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
/>
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
className="max-w-xs"
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold">
|
||||||
|
Setup Server
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Configure and initialize your
|
||||||
|
server with Docker, Traefik, and
|
||||||
|
other essential services
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<HandleServers
|
<TooltipProvider>
|
||||||
serverId={server.serverId}
|
{server.sshKeyId && (
|
||||||
/>
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
{server.sshKeyId &&
|
<div>
|
||||||
!isBuildServer && (
|
<TerminalModal
|
||||||
<ShowServerActions
|
|
||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
/>
|
asButton={true}
|
||||||
)}
|
>
|
||||||
</>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
>
|
||||||
|
<Terminal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TerminalModal>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Terminal</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogAction
|
<Tooltip>
|
||||||
disabled={!canDelete}
|
<TooltipTrigger asChild>
|
||||||
title={
|
<div>
|
||||||
canDelete
|
<HandleServers
|
||||||
? "Delete Server"
|
|
||||||
: "Server has active services"
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
canDelete ? (
|
|
||||||
"This will delete the server and all associated data"
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
You can not delete this server
|
|
||||||
because it has active services.
|
|
||||||
<AlertBlock type="warning">
|
|
||||||
You have active services
|
|
||||||
associated with this server,
|
|
||||||
please delete them first.
|
|
||||||
</AlertBlock>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={async () => {
|
|
||||||
await mutateAsync({
|
|
||||||
serverId: server.serverId,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
refetch();
|
|
||||||
toast.success(
|
|
||||||
`Server ${server.name} deleted successfully`,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error(err.message);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
Delete Server
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DialogAction>
|
|
||||||
|
|
||||||
{isActive &&
|
|
||||||
server.sshKeyId &&
|
|
||||||
!isBuildServer && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
Extra
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
|
|
||||||
<ShowTraefikFileSystemModal
|
|
||||||
serverId={server.serverId}
|
serverId={server.serverId}
|
||||||
|
asButton={true}
|
||||||
/>
|
/>
|
||||||
<ShowDockerContainersModal
|
</div>
|
||||||
serverId={server.serverId}
|
</TooltipTrigger>
|
||||||
/>
|
<TooltipContent>
|
||||||
{isCloud && (
|
<p>Edit Server</p>
|
||||||
<ShowMonitoringModal
|
</TooltipContent>
|
||||||
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
|
</Tooltip>
|
||||||
token={
|
|
||||||
server?.metricsConfig
|
{server.sshKeyId && !isBuildServer && (
|
||||||
?.server?.token
|
<Tooltip>
|
||||||
}
|
<TooltipTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<ShowServerActions
|
||||||
|
serverId={server.serverId}
|
||||||
|
asButton={true}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Web Server Actions</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<ShowSwarmOverviewModal
|
<div className="flex-1" />
|
||||||
serverId={server.serverId}
|
|
||||||
/>
|
|
||||||
<ShowNodesModal
|
|
||||||
serverId={server.serverId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ShowSchedulesModal
|
<Tooltip>
|
||||||
serverId={server.serverId}
|
<TooltipTrigger asChild>
|
||||||
/>
|
<div>
|
||||||
</>
|
<DialogAction
|
||||||
)}
|
disabled={!canDelete}
|
||||||
</DropdownMenuContent>
|
title={
|
||||||
</DropdownMenu>
|
canDelete
|
||||||
</TableCell>
|
? "Delete Server"
|
||||||
</TableRow>
|
: "Server has active services"
|
||||||
);
|
}
|
||||||
})}
|
description={
|
||||||
</TableBody>
|
canDelete ? (
|
||||||
</Table>
|
"This will delete the server and all associated data"
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
You can not delete this
|
||||||
|
server because it has
|
||||||
|
active services.
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
You have active services
|
||||||
|
associated with this
|
||||||
|
server, please delete
|
||||||
|
them first.
|
||||||
|
</AlertBlock>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={async () => {
|
||||||
|
await mutateAsync({
|
||||||
|
serverId: server.serverId,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
refetch();
|
||||||
|
toast.success(
|
||||||
|
`Server ${server.name} deleted successfully`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(err.message);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`h-9 w-9 ${canDelete ? "text-destructive hover:text-destructive hover:bg-destructive/10" : "text-muted-foreground hover:bg-muted"}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{canDelete
|
||||||
|
? "Delete Server"
|
||||||
|
: "Cannot delete - has active services"}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mt-4">
|
||||||
{data && data?.length > 0 && (
|
{data && data?.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<HandleServers />
|
<HandleServers />
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ type AddServerDomain = z.infer<typeof addServerDomain>;
|
|||||||
|
|
||||||
export const WebDomain = () => {
|
export const WebDomain = () => {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const { data, refetch } = api.user.get.useQuery();
|
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||||
const { mutateAsync, isLoading } =
|
const { mutateAsync, isLoading } =
|
||||||
api.settings.assignDomainServer.useMutation();
|
api.settings.assignDomainServer.useMutation();
|
||||||
|
|
||||||
@@ -82,15 +82,15 @@ export const WebDomain = () => {
|
|||||||
});
|
});
|
||||||
const https = form.watch("https");
|
const https = form.watch("https");
|
||||||
const domain = form.watch("domain") || "";
|
const domain = form.watch("domain") || "";
|
||||||
const host = data?.user?.host || "";
|
const host = data?.host || "";
|
||||||
const hasChanged = domain !== host;
|
const hasChanged = domain !== host;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
domain: data?.user?.host || "",
|
domain: data?.host || "",
|
||||||
certificateType: data?.user?.certificateType,
|
certificateType: data?.certificateType || "none",
|
||||||
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
|
letsEncryptEmail: data?.letsEncryptEmail || "",
|
||||||
https: data?.user?.https || false,
|
https: data?.https || false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, data]);
|
}, [form, form.reset, data]);
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import { UpdateServer } from "./web-server/update-server";
|
|||||||
|
|
||||||
export const WebServer = () => {
|
export const WebServer = () => {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const { data } = api.user.get.useQuery();
|
const { data: webServerSettings } =
|
||||||
|
api.settings.getWebServerSettings.useQuery();
|
||||||
|
|
||||||
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||||
|
|
||||||
@@ -53,7 +54,7 @@ export const WebServer = () => {
|
|||||||
|
|
||||||
<div className="flex items-center flex-wrap justify-between gap-4">
|
<div className="flex items-center flex-wrap justify-between gap-4">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Server IP: {data?.user.serverIp}
|
Server IP: {webServerSettings?.serverIp}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Version: {dokployVersion}
|
Version: {dokployVersion}
|
||||||
|
|||||||
@@ -24,10 +24,16 @@ const getTerminalKey = () => {
|
|||||||
interface Props {
|
interface Props {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
asButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TerminalModal = ({ children, serverId }: Props) => {
|
export const TerminalModal = ({
|
||||||
|
children,
|
||||||
|
serverId,
|
||||||
|
asButton = false,
|
||||||
|
}: Props) => {
|
||||||
const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey());
|
const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey());
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const isLocalServer = serverId === "local";
|
const isLocalServer = serverId === "local";
|
||||||
|
|
||||||
const { data } = api.server.one.useQuery(
|
const { data } = api.server.one.useQuery(
|
||||||
@@ -43,15 +49,20 @@ export const TerminalModal = ({ children, serverId }: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
{asButton ? (
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
) : (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="w-full cursor-pointer space-x-3"
|
className="w-full cursor-pointer space-x-3"
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DialogTrigger>
|
)}
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="sm:max-w-7xl"
|
className="sm:max-w-7xl"
|
||||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||||
|
|||||||
@@ -46,15 +46,15 @@ interface Props {
|
|||||||
export const UpdateServerIp = ({ children }: Props) => {
|
export const UpdateServerIp = ({ children }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const { data } = api.user.get.useQuery();
|
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||||
const { data: ip } = api.server.publicIp.useQuery();
|
const { data: ip } = api.server.publicIp.useQuery();
|
||||||
|
|
||||||
const { mutateAsync, isLoading, error, isError } =
|
const { mutateAsync, isLoading, error, isError } =
|
||||||
api.user.update.useMutation();
|
api.settings.updateServerIp.useMutation();
|
||||||
|
|
||||||
const form = useForm<Schema>({
|
const form = useForm<Schema>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
serverIp: data?.user.serverIp || "",
|
serverIp: data?.serverIp || "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
});
|
});
|
||||||
@@ -62,13 +62,11 @@ export const UpdateServerIp = ({ children }: Props) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
serverIp: data.user.serverIp || "",
|
serverIp: data.serverIp || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, data]);
|
}, [form, form.reset, data]);
|
||||||
|
|
||||||
const utils = api.useUtils();
|
|
||||||
|
|
||||||
const setCurrentIp = () => {
|
const setCurrentIp = () => {
|
||||||
if (!ip) return;
|
if (!ip) return;
|
||||||
form.setValue("serverIp", ip);
|
form.setValue("serverIp", ip);
|
||||||
@@ -80,7 +78,7 @@ export const UpdateServerIp = ({ children }: Props) => {
|
|||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Server IP Updated");
|
toast.success("Server IP Updated");
|
||||||
await utils.user.get.invalidate();
|
await refetch();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
||||||
import { ChatwootWidget } from "../shared/ChatwootWidget";
|
import { HubSpotWidget } from "../shared/HubSpotWidget";
|
||||||
import Page from "./side";
|
import Page from "./side";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -25,7 +25,9 @@ export const DashboardLayout = ({ children }: Props) => {
|
|||||||
<>
|
<>
|
||||||
<Page>{children}</Page>
|
<Page>{children}</Page>
|
||||||
{isCloud === true && isUserSubscribed === true && (
|
{isCloud === true && isUserSubscribed === true && (
|
||||||
<ChatwootWidget websiteToken="USCpQRKzHvFMssf3p6Eacae5" />
|
<>
|
||||||
|
<HubSpotWidget />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{haveRootAccess === true && <ImpersonationBar />}
|
{haveRootAccess === true && <ImpersonationBar />}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
Forward,
|
Forward,
|
||||||
GalleryVerticalEnd,
|
GalleryVerticalEnd,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
HeartIcon,
|
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Loader2,
|
Loader2,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -410,18 +409,6 @@ const MENU: Menu = {
|
|||||||
url: "https://discord.gg/2tBnJ3jDJc",
|
url: "https://discord.gg/2tBnJ3jDJc",
|
||||||
icon: CircleHelp,
|
icon: CircleHelp,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Sponsor",
|
|
||||||
url: "https://opencollective.com/dokploy",
|
|
||||||
icon: ({ className }) => (
|
|
||||||
<HeartIcon
|
|
||||||
className={cn(
|
|
||||||
"text-red-500 fill-red-600 animate-heartbeat",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
14
apps/dokploy/components/shared/HubSpotWidget.tsx
Normal file
14
apps/dokploy/components/shared/HubSpotWidget.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Script from "next/script";
|
||||||
|
|
||||||
|
export const HubSpotWidget = () => {
|
||||||
|
return (
|
||||||
|
<Script
|
||||||
|
id="hs-script-loader"
|
||||||
|
type="text/javascript"
|
||||||
|
src="//js-eu1.hs-scripts.com/147033433.js"
|
||||||
|
strategy="lazyOnload"
|
||||||
|
async
|
||||||
|
defer
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -5,18 +6,31 @@ import {
|
|||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
BreadcrumbLink,
|
BreadcrumbLink,
|
||||||
BreadcrumbList,
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator,
|
||||||
} from "@/components/ui/breadcrumb";
|
} from "@/components/ui/breadcrumb";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
|
|
||||||
interface Props {
|
interface BreadcrumbEntry {
|
||||||
list: {
|
name: string;
|
||||||
|
href?: string;
|
||||||
|
dropdownItems?: {
|
||||||
name: string;
|
name: string;
|
||||||
href?: string;
|
href: string;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
list: BreadcrumbEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
export const BreadcrumbSidebar = ({ list }: Props) => {
|
export const BreadcrumbSidebar = ({ list }: Props) => {
|
||||||
return (
|
return (
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
|
||||||
@@ -29,13 +43,29 @@ export const BreadcrumbSidebar = ({ list }: Props) => {
|
|||||||
{list.map((item, index) => (
|
{list.map((item, index) => (
|
||||||
<Fragment key={item.name}>
|
<Fragment key={item.name}>
|
||||||
<BreadcrumbItem className="block">
|
<BreadcrumbItem className="block">
|
||||||
<BreadcrumbLink href={item?.href} asChild={!!item?.href}>
|
{item.dropdownItems && item.dropdownItems.length > 0 ? (
|
||||||
{item.href ? (
|
<DropdownMenu>
|
||||||
<Link href={item?.href}>{item?.name}</Link>
|
<DropdownMenuTrigger className="flex items-center gap-1 hover:text-foreground transition-colors outline-none">
|
||||||
) : (
|
{item.name}
|
||||||
item?.name
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
)}
|
</DropdownMenuTrigger>
|
||||||
</BreadcrumbLink>
|
<DropdownMenuContent align="start">
|
||||||
|
{item.dropdownItems.map((subItem) => (
|
||||||
|
<DropdownMenuItem key={subItem.href} asChild>
|
||||||
|
<Link href={subItem.href}>{subItem.name}</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbLink href={item?.href} asChild={!!item?.href}>
|
||||||
|
{item.href ? (
|
||||||
|
<Link href={item?.href}>{item?.name}</Link>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbPage>{item?.name}</BreadcrumbPage>
|
||||||
|
)}
|
||||||
|
</BreadcrumbLink>
|
||||||
|
)}
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
{index + 1 < list.length && (
|
{index + 1 < list.length && (
|
||||||
<BreadcrumbSeparator className="block" />
|
<BreadcrumbSeparator className="block" />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const ToggleVisibilityInput = ({ ...props }: InputProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center space-x-2">
|
<div className="flex w-full items-center space-x-2">
|
||||||
<Input ref={inputRef} type={"password"} {...props} />
|
<Input ref={inputRef} {...props} type="password" />
|
||||||
<Button
|
<Button
|
||||||
variant={"secondary"}
|
variant={"secondary"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { isSolidColorAvatar } from "@/lib/avatar-utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Avatar = React.forwardRef<
|
const Avatar = React.forwardRef<
|
||||||
@@ -20,14 +20,33 @@ Avatar.displayName = AvatarPrimitive.Root.displayName;
|
|||||||
|
|
||||||
const AvatarImage = React.forwardRef<
|
const AvatarImage = React.forwardRef<
|
||||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> & {
|
||||||
>(({ className, ...props }, ref) => (
|
src?: string | null;
|
||||||
<AvatarPrimitive.Image
|
}
|
||||||
ref={ref}
|
>(({ className, src, ...props }, ref) => {
|
||||||
className={cn("aspect-square h-full w-full", className)}
|
if (isSolidColorAvatar(src)) {
|
||||||
{...props}
|
return (
|
||||||
/>
|
<div
|
||||||
));
|
key={`solid-${src}`}
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full rounded-full", className)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: src,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
src={src ?? ""}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||||
|
|
||||||
const AvatarFallback = React.forwardRef<
|
const AvatarFallback = React.forwardRef<
|
||||||
|
|||||||
@@ -1,18 +1,75 @@
|
|||||||
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
import { EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { generateRandomPassword } from "@/lib/password-utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface InputProps
|
export interface InputProps
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
enablePasswordGenerator?: boolean;
|
||||||
|
passwordGeneratorLength?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
({ className, errorMessage, type, ...props }, ref) => {
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
errorMessage,
|
||||||
|
type,
|
||||||
|
enablePasswordGenerator = false,
|
||||||
|
passwordGeneratorLength,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
const [showPassword, setShowPassword] = React.useState(false);
|
const [showPassword, setShowPassword] = React.useState(false);
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
const isPassword = type === "password";
|
const isPassword = type === "password";
|
||||||
|
const shouldShowGenerator =
|
||||||
|
isPassword &&
|
||||||
|
enablePasswordGenerator !== false &&
|
||||||
|
!props.disabled &&
|
||||||
|
!props.readOnly;
|
||||||
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
|
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
|
||||||
|
|
||||||
|
const setRefs = React.useCallback(
|
||||||
|
(node: HTMLInputElement | null) => {
|
||||||
|
// @ts-ignore
|
||||||
|
inputRef.current = node;
|
||||||
|
if (typeof ref === "function") {
|
||||||
|
ref(node);
|
||||||
|
} else if (ref) {
|
||||||
|
ref.current = node;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGeneratePassword = () => {
|
||||||
|
const nextValue =
|
||||||
|
typeof passwordGeneratorLength === "number" &&
|
||||||
|
passwordGeneratorLength > 0
|
||||||
|
? generateRandomPassword(Math.floor(passwordGeneratorLength))
|
||||||
|
: generateRandomPassword();
|
||||||
|
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
HTMLInputElement.prototype,
|
||||||
|
"value",
|
||||||
|
)?.set;
|
||||||
|
if (valueSetter) {
|
||||||
|
valueSetter.call(input, nextValue);
|
||||||
|
} else {
|
||||||
|
input.value = nextValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
@@ -21,25 +78,39 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
className={cn(
|
className={cn(
|
||||||
// bg-gray
|
// bg-gray
|
||||||
"flex h-10 w-full rounded-md bg-input px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-md bg-input px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
isPassword && "pr-10", // Add padding for the eye icon
|
isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"),
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={setRefs}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
{isPassword && (
|
{isPassword && (
|
||||||
<button
|
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground">
|
||||||
type="button"
|
{shouldShowGenerator && (
|
||||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground focus:outline-none"
|
<button
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
type="button"
|
||||||
tabIndex={-1}
|
className="hover:text-foreground focus:outline-none"
|
||||||
>
|
onClick={handleGeneratePassword}
|
||||||
{showPassword ? (
|
aria-label="Generate password"
|
||||||
<EyeOffIcon className="h-4 w-4" />
|
title="Generate password"
|
||||||
) : (
|
tabIndex={-1}
|
||||||
<EyeIcon className="h-4 w-4" />
|
>
|
||||||
|
<RefreshCcw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="hover:text-foreground focus:outline-none"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOffIcon className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
|
|||||||
84
apps/dokploy/components/ui/number-input.tsx
Normal file
84
apps/dokploy/components/ui/number-input.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { MinusIcon, PlusIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export interface UnitConverter {
|
||||||
|
toValue: (raw: string | undefined) => number;
|
||||||
|
fromValue: (value: number) => string;
|
||||||
|
formatDisplay: (value: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createConverter = (
|
||||||
|
multiplier: number,
|
||||||
|
formatDisplay: (value: number) => string,
|
||||||
|
): UnitConverter => ({
|
||||||
|
toValue: (raw) => {
|
||||||
|
if (!raw) return 0;
|
||||||
|
const value = Number.parseInt(raw, 10);
|
||||||
|
return Number.isNaN(value) ? 0 : value / multiplier;
|
||||||
|
},
|
||||||
|
fromValue: (value) =>
|
||||||
|
value <= 0 ? "" : String(Math.round(value * multiplier)),
|
||||||
|
formatDisplay,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface NumberInputWithStepsProps {
|
||||||
|
value: string | undefined;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
step: number;
|
||||||
|
converter: UnitConverter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NumberInputWithSteps = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
step,
|
||||||
|
converter,
|
||||||
|
}: NumberInputWithStepsProps) => {
|
||||||
|
const numericValue = converter.toValue(value);
|
||||||
|
const displayValue = converter.formatDisplay(numericValue);
|
||||||
|
|
||||||
|
const handleIncrement = () =>
|
||||||
|
onChange(converter.fromValue(numericValue + step));
|
||||||
|
const handleDecrement = () =>
|
||||||
|
onChange(converter.fromValue(Math.max(0, numericValue - step)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 shrink-0"
|
||||||
|
onClick={handleDecrement}
|
||||||
|
disabled={numericValue <= 0}
|
||||||
|
>
|
||||||
|
<MinusIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="text-center"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 shrink-0"
|
||||||
|
onClick={handleIncrement}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{displayValue && (
|
||||||
|
<span className="text-xs text-muted-foreground text-center">
|
||||||
|
{displayValue}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS "ai" (
|
|
||||||
"aiId" text PRIMARY KEY NOT NULL,
|
|
||||||
"name" text NOT NULL,
|
|
||||||
"apiUrl" text NOT NULL,
|
|
||||||
"apiKey" text NOT NULL,
|
|
||||||
"model" text NOT NULL,
|
|
||||||
"isEnabled" boolean DEFAULT true NOT NULL,
|
|
||||||
"adminId" text NOT NULL,
|
|
||||||
"createdAt" text NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
DO $$ BEGIN
|
|
||||||
ALTER TABLE "ai" ADD CONSTRAINT "ai_adminId_admin_adminId_fk" FOREIGN KEY ("adminId") REFERENCES "public"."admin"("adminId") ON DELETE cascade ON UPDATE no action;
|
|
||||||
EXCEPTION
|
|
||||||
WHEN duplicate_object THEN null;
|
|
||||||
END $$;
|
|
||||||
1
apps/dokploy/drizzle/0131_volatile_beast.sql
Normal file
1
apps/dokploy/drizzle/0131_volatile_beast.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "application" ADD COLUMN "createEnvFile" boolean DEFAULT true NOT NULL;
|
||||||
4
apps/dokploy/drizzle/0132_clean_layla_miller.sql
Normal file
4
apps/dokploy/drizzle/0132_clean_layla_miller.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE "environment" ADD COLUMN "isDefault" boolean DEFAULT false NOT NULL;
|
||||||
|
|
||||||
|
-- Set isDefault to true for existing production environments
|
||||||
|
UPDATE "environment" SET "isDefault" = true WHERE "name" = 'production';
|
||||||
114
apps/dokploy/drizzle/0133_striped_the_order.sql
Normal file
114
apps/dokploy/drizzle/0133_striped_the_order.sql
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
CREATE TABLE "webServerSettings" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"serverIp" text,
|
||||||
|
"certificateType" "certificateType" DEFAULT 'none' NOT NULL,
|
||||||
|
"https" boolean DEFAULT false NOT NULL,
|
||||||
|
"host" text,
|
||||||
|
"letsEncryptEmail" text,
|
||||||
|
"sshPrivateKey" text,
|
||||||
|
"enableDockerCleanup" boolean DEFAULT true NOT NULL,
|
||||||
|
"logCleanupCron" text DEFAULT '0 0 * * *',
|
||||||
|
"metricsConfig" jsonb DEFAULT '{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb NOT NULL,
|
||||||
|
"cleanupCacheApplications" boolean DEFAULT false NOT NULL,
|
||||||
|
"cleanupCacheOnPreviews" boolean DEFAULT false NOT NULL,
|
||||||
|
"cleanupCacheOnCompose" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now(),
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Migrate data from user table to webServerSettings
|
||||||
|
-- Get the owner user's data and insert into webServerSettings
|
||||||
|
INSERT INTO "webServerSettings" (
|
||||||
|
"id",
|
||||||
|
"serverIp",
|
||||||
|
"certificateType",
|
||||||
|
"https",
|
||||||
|
"host",
|
||||||
|
"letsEncryptEmail",
|
||||||
|
"sshPrivateKey",
|
||||||
|
"enableDockerCleanup",
|
||||||
|
"logCleanupCron",
|
||||||
|
"metricsConfig",
|
||||||
|
"cleanupCacheApplications",
|
||||||
|
"cleanupCacheOnPreviews",
|
||||||
|
"cleanupCacheOnCompose",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid()::text as "id",
|
||||||
|
u."serverIp",
|
||||||
|
COALESCE(u."certificateType", 'none') as "certificateType",
|
||||||
|
COALESCE(u."https", false) as "https",
|
||||||
|
u."host",
|
||||||
|
u."letsEncryptEmail",
|
||||||
|
u."sshPrivateKey",
|
||||||
|
COALESCE(u."enableDockerCleanup", true) as "enableDockerCleanup",
|
||||||
|
COALESCE(u."logCleanupCron", '0 0 * * *') as "logCleanupCron",
|
||||||
|
COALESCE(
|
||||||
|
u."metricsConfig",
|
||||||
|
'{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb
|
||||||
|
) as "metricsConfig",
|
||||||
|
COALESCE(u."cleanupCacheApplications", false) as "cleanupCacheApplications",
|
||||||
|
COALESCE(u."cleanupCacheOnPreviews", false) as "cleanupCacheOnPreviews",
|
||||||
|
COALESCE(u."cleanupCacheOnCompose", false) as "cleanupCacheOnCompose",
|
||||||
|
NOW() as "created_at",
|
||||||
|
NOW() as "updated_at"
|
||||||
|
FROM "user" u
|
||||||
|
INNER JOIN "member" m ON u."id" = m."user_id"
|
||||||
|
WHERE m."role" = 'owner'
|
||||||
|
ORDER BY m."created_at" ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- If no owner found, create a default entry
|
||||||
|
INSERT INTO "webServerSettings" (
|
||||||
|
"id",
|
||||||
|
"serverIp",
|
||||||
|
"certificateType",
|
||||||
|
"https",
|
||||||
|
"host",
|
||||||
|
"letsEncryptEmail",
|
||||||
|
"sshPrivateKey",
|
||||||
|
"enableDockerCleanup",
|
||||||
|
"logCleanupCron",
|
||||||
|
"metricsConfig",
|
||||||
|
"cleanupCacheApplications",
|
||||||
|
"cleanupCacheOnPreviews",
|
||||||
|
"cleanupCacheOnCompose",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
gen_random_uuid()::text as "id",
|
||||||
|
NULL as "serverIp",
|
||||||
|
'none' as "certificateType",
|
||||||
|
false as "https",
|
||||||
|
NULL as "host",
|
||||||
|
NULL as "letsEncryptEmail",
|
||||||
|
NULL as "sshPrivateKey",
|
||||||
|
true as "enableDockerCleanup",
|
||||||
|
'0 0 * * *' as "logCleanupCron",
|
||||||
|
'{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb as "metricsConfig",
|
||||||
|
false as "cleanupCacheApplications",
|
||||||
|
false as "cleanupCacheOnPreviews",
|
||||||
|
false as "cleanupCacheOnCompose",
|
||||||
|
NOW() as "created_at",
|
||||||
|
NOW() as "updated_at"
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM "webServerSettings"
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "serverIp";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "certificateType";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "https";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "host";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "letsEncryptEmail";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "sshPrivateKey";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "enableDockerCleanup";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "logCleanupCron";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "metricsConfig";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "cleanupCacheApplications";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "cleanupCacheOnPreviews";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "cleanupCacheOnCompose";
|
||||||
1
apps/dokploy/drizzle/0134_strong_hercules.sql
Normal file
1
apps/dokploy/drizzle/0134_strong_hercules.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "application" ALTER COLUMN "railpackVersion" SET DEFAULT '0.15.4';
|
||||||
6928
apps/dokploy/drizzle/meta/0131_snapshot.json
Normal file
6928
apps/dokploy/drizzle/meta/0131_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6935
apps/dokploy/drizzle/meta/0132_snapshot.json
Normal file
6935
apps/dokploy/drizzle/meta/0132_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6968
apps/dokploy/drizzle/meta/0133_snapshot.json
Normal file
6968
apps/dokploy/drizzle/meta/0133_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6968
apps/dokploy/drizzle/meta/0134_snapshot.json
Normal file
6968
apps/dokploy/drizzle/meta/0134_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -918,6 +918,34 @@
|
|||||||
"when": 1765167657813,
|
"when": 1765167657813,
|
||||||
"tag": "0130_perpetual_screwball",
|
"tag": "0130_perpetual_screwball",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 131,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1765342621312,
|
||||||
|
"tag": "0131_volatile_beast",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 132,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1765346573500,
|
||||||
|
"tag": "0132_clean_layla_miller",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 133,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1766301478005,
|
||||||
|
"tag": "0133_striped_the_order",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 134,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1767871040249,
|
||||||
|
"tag": "0134_strong_hercules",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
30
apps/dokploy/lib/avatar-utils.ts
Normal file
30
apps/dokploy/lib/avatar-utils.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Checks if the given avatar value represents a solid color in hexadecimal format.
|
||||||
|
*
|
||||||
|
* @param value Avatar value to check.
|
||||||
|
*
|
||||||
|
* @return True if the avatar is a solid color, false otherwise.
|
||||||
|
*/
|
||||||
|
export function isSolidColorAvatar(value?: string | null) {
|
||||||
|
return (
|
||||||
|
(value?.startsWith("#") && /^#[0-9A-Fa-f]{6}$/.test(value)) ||
|
||||||
|
value?.startsWith("color:") ||
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the avatar type for form selection (RadioGroup value).
|
||||||
|
*
|
||||||
|
* @param value Avatar value.
|
||||||
|
*
|
||||||
|
* @return "upload" for base64 images, "color" for solid colors, or the original value for other types.
|
||||||
|
*/
|
||||||
|
export function getAvatarType(value?: string | null) {
|
||||||
|
if (!value) return "";
|
||||||
|
|
||||||
|
if (value.startsWith("data:")) return "upload";
|
||||||
|
if (isSolidColorAvatar(value)) return "color";
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
38
apps/dokploy/lib/password-utils.ts
Normal file
38
apps/dokploy/lib/password-utils.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
const DEFAULT_PASSWORD_LENGTH = 20;
|
||||||
|
const DEFAULT_PASSWORD_CHARSET =
|
||||||
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||||
|
|
||||||
|
export const generateRandomPassword = (
|
||||||
|
length: number = DEFAULT_PASSWORD_LENGTH,
|
||||||
|
charset: string = DEFAULT_PASSWORD_CHARSET,
|
||||||
|
) => {
|
||||||
|
const safeLength =
|
||||||
|
Number.isFinite(length) && length > 0
|
||||||
|
? Math.floor(length)
|
||||||
|
: DEFAULT_PASSWORD_LENGTH;
|
||||||
|
|
||||||
|
if (safeLength <= 0 || charset.length === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const cryptoApi =
|
||||||
|
typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
|
||||||
|
|
||||||
|
if (!cryptoApi?.getRandomValues) {
|
||||||
|
let fallback = "";
|
||||||
|
for (let i = 0; i < safeLength; i += 1) {
|
||||||
|
fallback += charset[Math.floor(Math.random() * charset.length)];
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = new Uint32Array(safeLength);
|
||||||
|
cryptoApi.getRandomValues(values);
|
||||||
|
|
||||||
|
let result = "";
|
||||||
|
for (const value of values) {
|
||||||
|
result += charset[value % charset.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
|
import { dbUrl } from "@dokploy/server/db";
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL!;
|
const sql = postgres(dbUrl, { max: 1 });
|
||||||
|
|
||||||
const sql = postgres(connectionString, { max: 1 });
|
|
||||||
const db = drizzle(sql);
|
const db = drizzle(sql);
|
||||||
|
|
||||||
await migrate(db, { migrationsFolder: "drizzle" })
|
await migrate(db, { migrationsFolder: "drizzle" })
|
||||||
|
|||||||
@@ -19,6 +19,32 @@ const nextConfig = {
|
|||||||
locales: ["en"],
|
locales: ["en"],
|
||||||
defaultLocale: "en",
|
defaultLocale: "en",
|
||||||
},
|
},
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
// Apply security headers to all routes
|
||||||
|
source: "/:path*",
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: "X-Frame-Options",
|
||||||
|
value: "DENY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Content-Security-Policy",
|
||||||
|
value: "frame-ancestors 'none'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "X-Content-Type-Options",
|
||||||
|
value: "nosniff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Referrer-Policy",
|
||||||
|
value: "strict-origin-when-cross-origin",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.26.0",
|
"version": "v0.26.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -13,7 +13,6 @@
|
|||||||
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
|
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
|
||||||
"reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs",
|
"reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs",
|
||||||
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
|
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
|
||||||
"dev-turbopack": "TURBOPACK=1 tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json",
|
|
||||||
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
|
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
|
||||||
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
|
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
|
||||||
"migration:run": "tsx -r dotenv/config migration.ts",
|
"migration:run": "tsx -r dotenv/config migration.ts",
|
||||||
@@ -110,7 +109,6 @@
|
|||||||
"drizzle-orm": "^0.39.3",
|
"drizzle-orm": "^0.39.3",
|
||||||
"drizzle-zod": "0.5.1",
|
"drizzle-zod": "0.5.1",
|
||||||
"fancy-ansi": "^0.1.3",
|
"fancy-ansi": "^0.1.3",
|
||||||
"hi-base32": "^0.5.1",
|
|
||||||
"i18next": "^23.16.8",
|
"i18next": "^23.16.8",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
@@ -118,7 +116,7 @@
|
|||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
"next": "^16.0.7",
|
"next": "^16.0.10",
|
||||||
"next-i18next": "^15.4.2",
|
"next-i18next": "^15.4.2",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nextjs-toploader": "^3.9.17",
|
"nextjs-toploader": "^3.9.17",
|
||||||
@@ -127,7 +125,6 @@
|
|||||||
"node-schedule": "2.1.1",
|
"node-schedule": "2.1.1",
|
||||||
"nodemailer": "6.9.14",
|
"nodemailer": "6.9.14",
|
||||||
"octokit": "3.1.2",
|
"octokit": "3.1.2",
|
||||||
"otpauth": "^9.4.0",
|
|
||||||
"pino": "9.4.0",
|
"pino": "9.4.0",
|
||||||
"pino-pretty": "11.2.2",
|
"pino-pretty": "11.2.2",
|
||||||
"postgres": "3.4.4",
|
"postgres": "3.4.4",
|
||||||
@@ -141,7 +138,6 @@
|
|||||||
"react-i18next": "^15.5.2",
|
"react-i18next": "^15.5.2",
|
||||||
"react-markdown": "^9.1.0",
|
"react-markdown": "^9.1.0",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
"rotating-file-stream": "3.2.3",
|
|
||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"ssh2": "1.15.0",
|
"ssh2": "1.15.0",
|
||||||
@@ -157,9 +153,11 @@
|
|||||||
"xterm-addon-fit": "^0.8.0",
|
"xterm-addon-fit": "^0.8.0",
|
||||||
"yaml": "2.8.1",
|
"yaml": "2.8.1",
|
||||||
"zod": "^3.25.32",
|
"zod": "^3.25.32",
|
||||||
"zod-form-data": "^2.0.7"
|
"zod-form-data": "^2.0.7",
|
||||||
|
"semver": "7.7.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/semver": "7.7.1",
|
||||||
"@types/shell-quote": "^1.7.5",
|
"@types/shell-quote": "^1.7.5",
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
@@ -198,10 +196,5 @@
|
|||||||
"*": [
|
"*": [
|
||||||
"biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
|
"biome check --write --no-errors-on-unmatched --files-ignore-unknown=true"
|
||||||
]
|
]
|
||||||
},
|
|
||||||
"commitlint": {
|
|
||||||
"extends": [
|
|
||||||
"@commitlint/config-conventional"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,41 +81,34 @@ export default async function handler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!webhookImageName) {
|
// If webhook provides image information, validate it matches the configured image
|
||||||
res.status(301).json({
|
// If webhook doesn't provide image information, fall back to using the configured image (backward compatibility)
|
||||||
message: "Webhook Docker Image Name Not Found",
|
if (webhookImageName) {
|
||||||
});
|
// Validate image name matches
|
||||||
return;
|
if (webhookImageName !== applicationImageName) {
|
||||||
}
|
res.status(301).json({
|
||||||
|
message: `Application Image Name (${applicationImageName}) doesn't match request event payload Image Name (${webhookImageName}).`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate image name matches
|
if (!applicationDockerTag) {
|
||||||
if (webhookImageName !== applicationImageName) {
|
res.status(301).json({
|
||||||
res.status(301).json({
|
message: "Application Docker Tag Not Found",
|
||||||
message: `Application Image Name (${applicationImageName}) doesn't match request event payload Image Name (${webhookImageName}).`,
|
});
|
||||||
});
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!applicationDockerTag) {
|
if (webhookDockerTag) {
|
||||||
res.status(301).json({
|
if (webhookDockerTag !== applicationDockerTag) {
|
||||||
message: "Application Docker Tag Not Found",
|
res.status(301).json({
|
||||||
});
|
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
|
||||||
return;
|
});
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
if (!webhookDockerTag) {
|
}
|
||||||
res.status(301).json({
|
|
||||||
message: "Webhook Docker Tag Not Found",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (webhookDockerTag !== applicationDockerTag) {
|
|
||||||
res.status(301).json({
|
|
||||||
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
// If webhook doesn't provide image info, we'll use the configured image (old behavior)
|
||||||
} else if (sourceType === "github") {
|
} else if (sourceType === "github") {
|
||||||
const normalizedCommits = req.body?.commits?.flatMap(
|
const normalizedCommits = req.body?.commits?.flatMap(
|
||||||
(commit: any) => commit.modified,
|
(commit: any) => commit.modified,
|
||||||
@@ -249,17 +242,19 @@ export default async function handler(
|
|||||||
|
|
||||||
if (IS_CLOUD && application.serverId) {
|
if (IS_CLOUD && application.serverId) {
|
||||||
jobData.serverId = application.serverId;
|
jobData.serverId = application.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
return true;
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await myQueue.add(
|
||||||
|
"deployments",
|
||||||
|
{ ...jobData },
|
||||||
|
{
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await myQueue.add(
|
|
||||||
"deployments",
|
|
||||||
{ ...jobData },
|
|
||||||
{
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ message: "Error deploying Application", error });
|
res.status(400).json({ message: "Error deploying Application", error });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -179,17 +179,19 @@ export default async function handler(
|
|||||||
|
|
||||||
if (IS_CLOUD && composeResult.serverId) {
|
if (IS_CLOUD && composeResult.serverId) {
|
||||||
jobData.serverId = composeResult.serverId;
|
jobData.serverId = composeResult.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
return true;
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await myQueue.add(
|
||||||
|
"deployments",
|
||||||
|
{ ...jobData },
|
||||||
|
{
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await myQueue.add(
|
|
||||||
"deployments",
|
|
||||||
{ ...jobData },
|
|
||||||
{
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ message: "Error deploying Compose", error });
|
res.status(400).json({ message: "Error deploying Compose", error });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -128,7 +128,9 @@ export default async function handler(
|
|||||||
|
|
||||||
if (IS_CLOUD && app.serverId) {
|
if (IS_CLOUD && app.serverId) {
|
||||||
jobData.serverId = app.serverId;
|
jobData.serverId = app.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await myQueue.add(
|
await myQueue.add(
|
||||||
@@ -165,7 +167,9 @@ export default async function handler(
|
|||||||
|
|
||||||
if (IS_CLOUD && composeApp.serverId) {
|
if (IS_CLOUD && composeApp.serverId) {
|
||||||
jobData.serverId = composeApp.serverId;
|
jobData.serverId = composeApp.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +250,9 @@ export default async function handler(
|
|||||||
|
|
||||||
if (IS_CLOUD && app.serverId) {
|
if (IS_CLOUD && app.serverId) {
|
||||||
jobData.serverId = app.serverId;
|
jobData.serverId = app.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await myQueue.add(
|
await myQueue.add(
|
||||||
@@ -291,7 +297,9 @@ export default async function handler(
|
|||||||
}
|
}
|
||||||
if (IS_CLOUD && composeApp.serverId) {
|
if (IS_CLOUD && composeApp.serverId) {
|
||||||
jobData.serverId = composeApp.serverId;
|
jobData.serverId = composeApp.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,7 +499,9 @@ export default async function handler(
|
|||||||
|
|
||||||
if (IS_CLOUD && app.serverId) {
|
if (IS_CLOUD && app.serverId) {
|
||||||
jobData.serverId = app.serverId;
|
jobData.serverId = app.serverId;
|
||||||
await deploy(jobData);
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await myQueue.add(
|
await myQueue.add(
|
||||||
|
|||||||
@@ -279,6 +279,16 @@ const EnvironmentPage = (
|
|||||||
const [isBulkActionLoading, setIsBulkActionLoading] = useState(false);
|
const [isBulkActionLoading, setIsBulkActionLoading] = useState(false);
|
||||||
const { projectId, environmentId } = props;
|
const { projectId, environmentId } = props;
|
||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
|
|
||||||
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
|
projectId: projectId,
|
||||||
|
});
|
||||||
|
const environmentDropdownItems =
|
||||||
|
environments?.map((env) => ({
|
||||||
|
name: env.name,
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
const [sortBy, setSortBy] = useState<string>(() => {
|
const [sortBy, setSortBy] = useState<string>(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
return localStorage.getItem("servicesSort") || "lastDeploy-desc";
|
return localStorage.getItem("servicesSort") || "lastDeploy-desc";
|
||||||
@@ -863,6 +873,7 @@ const EnvironmentPage = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: currentEnvironment.name,
|
name: currentEnvironment.name,
|
||||||
|
dropdownItems: environmentDropdownItems,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -898,7 +909,9 @@ const EnvironmentPage = (
|
|||||||
<ProjectEnvironment projectId={projectId}>
|
<ProjectEnvironment projectId={projectId}>
|
||||||
<Button variant="outline">Project Environment</Button>
|
<Button variant="outline">Project Environment</Button>
|
||||||
</ProjectEnvironment>
|
</ProjectEnvironment>
|
||||||
{(auth?.role === "owner" || auth?.canCreateServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canCreateServices) && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button>
|
<Button>
|
||||||
@@ -1021,6 +1034,7 @@ const EnvironmentPage = (
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
{(auth?.role === "owner" ||
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
auth?.canDeleteServices) && (
|
auth?.canDeleteServices) && (
|
||||||
<>
|
<>
|
||||||
<DialogAction
|
<DialogAction
|
||||||
@@ -1610,9 +1624,39 @@ export async function getServerSideProps(
|
|||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await helpers.environment.one.fetch({
|
// Try to fetch the requested environment
|
||||||
environmentId: params.environmentId,
|
try {
|
||||||
});
|
await helpers.environment.one.fetch({
|
||||||
|
environmentId: params.environmentId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// If user doesn't have access to requested environment, redirect to accessible one
|
||||||
|
const accessibleEnvironments =
|
||||||
|
await helpers.environment.byProjectId.fetch({
|
||||||
|
projectId: params.projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (accessibleEnvironments.length > 0) {
|
||||||
|
// Try to find default, otherwise use first accessible
|
||||||
|
const targetEnv =
|
||||||
|
accessibleEnvironments.find((env) => env.isDefault) ||
|
||||||
|
accessibleEnvironments[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: false,
|
||||||
|
destination: `/dashboard/project/${params.projectId}/environment/${targetEnv.environmentId}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// No accessible environments, redirect to home
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: false,
|
||||||
|
destination: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await helpers.environment.byProjectId.fetch({
|
await helpers.environment.byProjectId.fetch({
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
|
|||||||
@@ -91,6 +91,15 @@ const Service = (
|
|||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
|
|
||||||
|
const { data: environments } = api.environment.byProjectId.useQuery({
|
||||||
|
projectId: data?.environment?.project?.projectId || "",
|
||||||
|
});
|
||||||
|
const environmentDropdownItems =
|
||||||
|
environments?.map((env) => ({
|
||||||
|
name: env.name,
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${env.environmentId}`,
|
||||||
|
})) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-10">
|
<div className="pb-10">
|
||||||
<UseKeyboardNav forPage="application" />
|
<UseKeyboardNav forPage="application" />
|
||||||
@@ -98,11 +107,12 @@ const Service = (
|
|||||||
list={[
|
list={[
|
||||||
{ name: "Projects", href: "/dashboard/projects" },
|
{ name: "Projects", href: "/dashboard/projects" },
|
||||||
{
|
{
|
||||||
name: data?.environment.project.name || "",
|
name: data?.environment?.project?.name || "",
|
||||||
|
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.environment?.name || "",
|
name: data?.environment?.name || "",
|
||||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
dropdownItems: environmentDropdownItems,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: data?.name || "",
|
name: data?.name || "",
|
||||||
@@ -183,7 +193,9 @@ const Service = (
|
|||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateApplication applicationId={applicationId} />
|
<UpdateApplication applicationId={applicationId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={applicationId} type="application" />
|
<DeleteService id={applicationId} type="application" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user