mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-26 01:25:22 +02:00
Compare commits
127 Commits
v0.26.3
...
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 | ||
|
|
67d5e1a350 | ||
|
|
93fa19213e | ||
|
|
1988a14b24 | ||
|
|
3bdf029155 | ||
|
|
e1896c2498 | ||
|
|
a8064afd60 | ||
|
|
3849a206e8 | ||
|
|
bb0a53d976 | ||
|
|
0a8753d0a9 | ||
|
|
23b14cf0cf | ||
|
|
ed701df6ac | ||
|
|
dfc15cd621 | ||
|
|
1ac3d1c1b0 | ||
|
|
f6b756e711 | ||
|
|
9f84dd4e0d | ||
|
|
2e32b0a4af | ||
|
|
0f69bbbd20 | ||
|
|
9e79314ef4 | ||
|
|
540b4039ac | ||
|
|
9e89edf167 | ||
|
|
e31d5a723b |
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.
|
||||||
60
README.md
60
README.md
@@ -68,53 +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 |
|
||||||
<a href="https://awesome.tools/" target="_blank">
|
| [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 |
|
||||||
<img src=".github/sponsors/awesome.png" width="200" height="150" />
|
| [Cloudblast](https://cloudblast.io/?ref=dokploy) | <img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" alt="Cloudblast.io" width="150"/> | 🥉 Supporting Member |
|
||||||
</a>
|
| [Synexa](https://synexa.ai/?ref=dokploy) | <img src=".github/sponsors/synexa.png" alt="Synexa" width="100"/> | 🥉 Supporting Member |
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Premium Supporters 🥇 -->
|
|
||||||
|
|
||||||
<!-- Add Premium Supporters here -->
|
|
||||||
|
|
||||||
### Premium Supporters 🥇
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<a href="https://supafort.com/?ref=dokploy"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="300"/></a>
|
|
||||||
<a href="https://agentdock.ai/?ref=dokploy"><img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Elite Contributors 🥈 -->
|
|
||||||
|
|
||||||
<!-- Add Elite Contributors here -->
|
|
||||||
|
|
||||||
### Elite Contributors 🥈
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<a href="https://americancloud.com/?ref=dokploy"><img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="300"/></a>
|
|
||||||
<a href="https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy"><img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### Supporting Members 🥉
|
|
||||||
|
|
||||||
<div>
|
|
||||||
|
|
||||||
<a href="https://cloudblast.io/?ref=dokploy"><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
|
|
||||||
|
|
||||||
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
### Community Backers 🤝
|
### 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
|
||||||
@@ -25,7 +25,7 @@ if (typeof window === "undefined") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseApp: ApplicationNested = {
|
const baseApp: ApplicationNested = {
|
||||||
railpackVersion: "0.2.2",
|
railpackVersion: "0.15.4",
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
createEnvFile: true,
|
createEnvFile: true,
|
||||||
|
|||||||
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@ 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: [],
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -288,9 +288,12 @@ export const ShowProjects = () => {
|
|||||||
)
|
)
|
||||||
.some(Boolean);
|
.some(Boolean);
|
||||||
|
|
||||||
const productionEnvironment = project?.environments.find(
|
// Find default environment from accessible environments, or fall back to first accessible environment
|
||||||
(env) => env.isDefault,
|
const accessibleEnvironment =
|
||||||
);
|
project?.environments.find((env) => env.isDefault) ||
|
||||||
|
project?.environments?.[0];
|
||||||
|
|
||||||
|
const hasNoEnvironments = !accessibleEnvironment;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -298,7 +301,16 @@ export const ShowProjects = () => {
|
|||||||
className="w-full lg:max-w-md"
|
className="w-full lg:max-w-md"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={`/dashboard/project/${project.projectId}/environment/${productionEnvironment?.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 ? (
|
||||||
@@ -419,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">
|
||||||
@@ -427,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,7 +89,7 @@ export const SearchCommand = () => {
|
|||||||
<CommandGroup heading={"Projects"}>
|
<CommandGroup heading={"Projects"}>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
{data?.map((project) => {
|
{data?.map((project) => {
|
||||||
// Find default environment, or fall back to first environment
|
// Find default environment from accessible environments, or fall back to first accessible environment
|
||||||
const defaultEnvironment =
|
const defaultEnvironment =
|
||||||
project.environments.find(
|
project.environments.find(
|
||||||
(environment) => environment.isDefault,
|
(environment) => environment.isDefault,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -89,15 +88,15 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
) : (
|
) : (
|
||||||
<DropdownMenuItem
|
<Button
|
||||||
className="w-full cursor-pointer "
|
className="w-full cursor-pointer "
|
||||||
onSelect={(e) => {
|
size="sm"
|
||||||
e.preventDefault();
|
onClick={() => {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Setup Server
|
Setup Server <Settings className="size-4" />
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<DialogContent className="sm:max-w-4xl ">
|
<DialogContent className="sm:max-w-4xl ">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Network,
|
Network,
|
||||||
Pencil,
|
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
Settings,
|
|
||||||
Terminal,
|
Terminal,
|
||||||
Trash2,
|
Trash2,
|
||||||
User,
|
User,
|
||||||
@@ -31,9 +29,7 @@ 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 {
|
||||||
@@ -285,7 +281,32 @@ export const ShowServers = () => {
|
|||||||
|
|
||||||
{/* Compact Actions */}
|
{/* Compact Actions */}
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div className="flex items-center gap-2 pt-3 border-t mt-auto">
|
<div className="flex items-center gap-2 pt-3 border-t mt-auto flex-wrap">
|
||||||
|
<div className="flex items-center gap-2 w-full">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<SetupServer
|
||||||
|
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>
|
||||||
|
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{server.sshKeyId && (
|
{server.sshKeyId && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -311,20 +332,6 @@ export const ShowServers = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div>
|
|
||||||
<SetupServer
|
|
||||||
serverId={server.serverId}
|
|
||||||
asButton={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>Setup Server</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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/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';
|
||||||
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
@@ -939,6 +939,13 @@
|
|||||||
"when": 1766301478005,
|
"when": 1766301478005,
|
||||||
"tag": "0133_striped_the_order",
|
"tag": "0133_striped_the_order",
|
||||||
"breakpoints": true
|
"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.3",
|
"version": "v0.26.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -109,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",
|
||||||
@@ -126,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",
|
||||||
@@ -155,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",
|
||||||
@@ -196,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"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -909,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>
|
||||||
@@ -1032,6 +1034,7 @@ const EnvironmentPage = (
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogAction>
|
</DialogAction>
|
||||||
{(auth?.role === "owner" ||
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
auth?.canDeleteServices) && (
|
auth?.canDeleteServices) && (
|
||||||
<>
|
<>
|
||||||
<DialogAction
|
<DialogAction
|
||||||
@@ -1621,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,
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ const Service = (
|
|||||||
{ 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 || "",
|
||||||
@@ -192,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>
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ const Service = (
|
|||||||
{ 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 || "",
|
||||||
@@ -182,7 +183,9 @@ const Service = (
|
|||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateCompose composeId={composeId} />
|
<UpdateCompose composeId={composeId} />
|
||||||
|
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={composeId} type="compose" />
|
<DeleteService id={composeId} type="compose" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ const Mariadb = (
|
|||||||
{ 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 || "",
|
||||||
@@ -156,7 +157,9 @@ const Mariadb = (
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateMariadb mariadbId={mariadbId} />
|
<UpdateMariadb mariadbId={mariadbId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={mariadbId} type="mariadb" />
|
<DeleteService id={mariadbId} type="mariadb" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ const Mongo = (
|
|||||||
{ 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 || "",
|
||||||
@@ -155,7 +156,9 @@ const Mongo = (
|
|||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateMongo mongoId={mongoId} />
|
<UpdateMongo mongoId={mongoId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={mongoId} type="mongo" />
|
<DeleteService id={mongoId} type="mongo" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const MySql = (
|
|||||||
{ 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 || "",
|
||||||
@@ -156,7 +157,9 @@ const MySql = (
|
|||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateMysql mysqlId={mysqlId} />
|
<UpdateMysql mysqlId={mysqlId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={mysqlId} type="mysql" />
|
<DeleteService id={mysqlId} type="mysql" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const Postgresql = (
|
|||||||
{ 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 || "",
|
||||||
@@ -154,7 +155,9 @@ const Postgresql = (
|
|||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdatePostgres postgresId={postgresId} />
|
<UpdatePostgres postgresId={postgresId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={postgresId} type="postgres" />
|
<DeleteService id={postgresId} type="postgres" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const Redis = (
|
|||||||
{ 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 || "",
|
||||||
@@ -154,7 +155,9 @@ const Redis = (
|
|||||||
|
|
||||||
<div className="flex flex-row gap-2 justify-end">
|
<div className="flex flex-row gap-2 justify-end">
|
||||||
<UpdateRedis redisId={redisId} />
|
<UpdateRedis redisId={redisId} />
|
||||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
{(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canDeleteServices) && (
|
||||||
<DeleteService id={redisId} type="redis" />
|
<DeleteService id={redisId} type="redis" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
63
apps/dokploy/pages/dashboard/settings/invoices.tsx
Normal file
63
apps/dokploy/pages/dashboard/settings/invoices.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||||
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
|
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||||
|
import type { GetServerSidePropsContext } from "next";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import superjson from "superjson";
|
||||||
|
import { ShowBillingInvoices } from "@/components/dashboard/settings/billing/show-billing-invoices";
|
||||||
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
|
import { appRouter } from "@/server/api/root";
|
||||||
|
|
||||||
|
const Page = () => {
|
||||||
|
return <ShowBillingInvoices />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
|
|
||||||
|
Page.getLayout = (page: ReactElement) => {
|
||||||
|
return <DashboardLayout metaName="Invoices">{page}</DashboardLayout>;
|
||||||
|
};
|
||||||
|
export async function getServerSideProps(
|
||||||
|
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||||
|
) {
|
||||||
|
if (!IS_CLOUD) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/dashboard/projects",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { req, res } = ctx;
|
||||||
|
const { user, session } = await validateRequest(req);
|
||||||
|
if (!user || user.role !== "owner") {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: true,
|
||||||
|
destination: "/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const helpers = createServerSideHelpers({
|
||||||
|
router: appRouter,
|
||||||
|
ctx: {
|
||||||
|
req: req as any,
|
||||||
|
res: res as any,
|
||||||
|
db: null as any,
|
||||||
|
session: session as any,
|
||||||
|
user: user as any,
|
||||||
|
},
|
||||||
|
transformer: superjson,
|
||||||
|
});
|
||||||
|
|
||||||
|
await helpers.user.get.prefetch();
|
||||||
|
|
||||||
|
await helpers.settings.isCloud.prefetch();
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
trpcState: helpers.dehydrate(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
18
apps/dokploy/proprietary/README.md
Normal file
18
apps/dokploy/proprietary/README.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Proprietary Features
|
||||||
|
|
||||||
|
This directory contains all proprietary functionality of Dokploy.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This folder will house all **paid features** and premium functionality that are not part of the open source code.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
The code in this directory is under Dokploy's proprietary license. See [LICENSE_PROPRIETARY.md](../../../LICENSE_PROPRIETARY.md) for more details.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
If you want to learn more about our paid features or have any questions, please contact us at:
|
||||||
|
|
||||||
|
- Email: [sales@dokploy.com](mailto:sales@dokploy.com)
|
||||||
|
- Contact Form: [https://dokploy.com/contact](https://dokploy.com/contact)
|
||||||
@@ -285,6 +285,7 @@ export const backupRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const backup = await findBackupById(input.backupId);
|
const backup = await findBackupById(input.backupId);
|
||||||
await runWebServerBackup(backup);
|
await runWebServerBackup(backup);
|
||||||
|
await keepLatestNBackups(backup);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
listBackupFiles: protectedProcedure
|
listBackupFiles: protectedProcedure
|
||||||
|
|||||||
@@ -430,7 +430,11 @@ export const composeRouter = createTRPCRouter({
|
|||||||
removeOnFail: true,
|
removeOnFail: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return { success: true, message: "Deployment queued" };
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Deployment queued",
|
||||||
|
composeId: compose.composeId,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
redeploy: protectedProcedure
|
redeploy: protectedProcedure
|
||||||
.input(apiRedeployCompose)
|
.input(apiRedeployCompose)
|
||||||
@@ -468,7 +472,11 @@ export const composeRouter = createTRPCRouter({
|
|||||||
removeOnFail: true,
|
removeOnFail: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return { success: true, message: "Redeployment queued" };
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Redeployment queued",
|
||||||
|
composeId: compose.composeId,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
stop: protectedProcedure
|
stop: protectedProcedure
|
||||||
.input(apiFindCompose)
|
.input(apiFindCompose)
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import {
|
|||||||
findApplicationById,
|
findApplicationById,
|
||||||
findPreviewDeploymentById,
|
findPreviewDeploymentById,
|
||||||
findPreviewDeploymentsByApplicationId,
|
findPreviewDeploymentsByApplicationId,
|
||||||
|
IS_CLOUD,
|
||||||
removePreviewDeployment,
|
removePreviewDeployment,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { apiFindAllByApplication } from "@/server/db/schema";
|
import { apiFindAllByApplication } from "@/server/db/schema";
|
||||||
|
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||||
|
import { myQueue } from "@/server/queues/queueSetup";
|
||||||
|
import { deploy } from "@/server/utils/deploy";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
|
||||||
export const previewDeploymentRouter = createTRPCRouter({
|
export const previewDeploymentRouter = createTRPCRouter({
|
||||||
@@ -60,4 +64,55 @@ export const previewDeploymentRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return previewDeployment;
|
return previewDeployment;
|
||||||
}),
|
}),
|
||||||
|
redeploy: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
previewDeploymentId: z.string(),
|
||||||
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const previewDeployment = await findPreviewDeploymentById(
|
||||||
|
input.previewDeploymentId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
previewDeployment.application.environment.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to redeploy this preview deployment",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const application = await findApplicationById(
|
||||||
|
previewDeployment.applicationId,
|
||||||
|
);
|
||||||
|
const jobData: DeploymentJob = {
|
||||||
|
applicationId: previewDeployment.applicationId,
|
||||||
|
titleLog: input.title || "Rebuild Preview Deployment",
|
||||||
|
descriptionLog: input.description || "",
|
||||||
|
type: "redeploy",
|
||||||
|
applicationType: "application-preview",
|
||||||
|
previewDeploymentId: input.previewDeploymentId,
|
||||||
|
server: !!application.serverId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (IS_CLOUD && application.serverId) {
|
||||||
|
jobData.serverId = application.serverId;
|
||||||
|
deploy(jobData).catch((error) => {
|
||||||
|
console.error("Background deployment failed:", error);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await myQueue.add(
|
||||||
|
"deployments",
|
||||||
|
{ ...jobData },
|
||||||
|
{
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await reloadDockerResource("dokploy");
|
await reloadDockerResource("dokploy", undefined, packageInfo.version);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
cleanRedis: adminProcedure.mutation(async () => {
|
cleanRedis: adminProcedure.mutation(async () => {
|
||||||
@@ -399,7 +399,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
return DEFAULT_UPDATE_DATA;
|
return DEFAULT_UPDATE_DATA;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await getUpdateData();
|
return await getUpdateData(packageInfo.version);
|
||||||
}),
|
}),
|
||||||
updateServer: adminProcedure.mutation(async () => {
|
updateServer: adminProcedure.mutation(async () => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
|
|||||||
@@ -75,9 +75,9 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
line_items: items,
|
line_items: items,
|
||||||
...(stripeCustomerId && {
|
...(stripeCustomerId
|
||||||
customer: stripeCustomerId,
|
? { customer: stripeCustomerId }
|
||||||
}),
|
: { customer_email: owner.email }),
|
||||||
metadata: {
|
metadata: {
|
||||||
adminId: owner.id,
|
adminId: owner.id,
|
||||||
},
|
},
|
||||||
@@ -128,4 +128,39 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return servers.length < user.serversQuantity;
|
return servers.length < user.serversQuantity;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getInvoices: adminProcedure.query(async ({ ctx }) => {
|
||||||
|
const user = await findUserById(ctx.user.ownerId);
|
||||||
|
const stripeCustomerId = user.stripeCustomerId;
|
||||||
|
|
||||||
|
if (!stripeCustomerId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||||
|
apiVersion: "2024-09-30.acacia",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invoices = await stripe.invoices.list({
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
return invoices.data.map((invoice) => ({
|
||||||
|
id: invoice.id,
|
||||||
|
number: invoice.number,
|
||||||
|
status: invoice.status,
|
||||||
|
amountDue: invoice.amount_due,
|
||||||
|
amountPaid: invoice.amount_paid,
|
||||||
|
currency: invoice.currency,
|
||||||
|
created: invoice.created,
|
||||||
|
dueDate: invoice.due_date,
|
||||||
|
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||||
|
invoicePdf: invoice.invoice_pdf,
|
||||||
|
}));
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { dbUrl } from "@dokploy/server/db";
|
||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: "./server/db/schema/index.ts",
|
schema: "./server/db/schema/index.ts",
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL!,
|
url: dbUrl,
|
||||||
},
|
},
|
||||||
out: "drizzle",
|
out: "drizzle",
|
||||||
migrations: {
|
migrations: {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { dbUrl } from "@dokploy/server/db/constants";
|
||||||
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
@@ -6,10 +7,6 @@ declare global {
|
|||||||
var db: PostgresJsDatabase<typeof schema> | undefined;
|
var db: PostgresJsDatabase<typeof schema> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dbUrl =
|
|
||||||
process.env.DATABASE_URL ||
|
|
||||||
"postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy";
|
|
||||||
|
|
||||||
export let db: PostgresJsDatabase<typeof schema>;
|
export let db: PostgresJsDatabase<typeof schema>;
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
db = drizzle(postgres(dbUrl!), {
|
db = drizzle(postgres(dbUrl!), {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
export const migration = async () =>
|
export const migration = async () =>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
|
import { dbUrl } from "@dokploy/server/db";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
// Credits to Louistiti from Drizzle Discord: https://discord.com/channels/1043890932593987624/1130802621750448160/1143083373535973406
|
// Credits to Louistiti from Drizzle Discord: https://discord.com/channels/1043890932593987624/1130802621750448160/1143083373535973406
|
||||||
import { drizzle } from "drizzle-orm/postgres-js";
|
import { drizzle } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL!;
|
const pg = postgres(dbUrl, { max: 1 });
|
||||||
|
|
||||||
const pg = postgres(connectionString, { max: 1 });
|
|
||||||
const db = drizzle(pg);
|
const db = drizzle(pg);
|
||||||
|
|
||||||
const clearDb = async (): Promise<void> => {
|
const clearDb = async (): Promise<void> => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
deployPreviewApplication,
|
deployPreviewApplication,
|
||||||
rebuildApplication,
|
rebuildApplication,
|
||||||
rebuildCompose,
|
rebuildCompose,
|
||||||
|
rebuildPreviewApplication,
|
||||||
updateApplicationStatus,
|
updateApplicationStatus,
|
||||||
updateCompose,
|
updateCompose,
|
||||||
updatePreviewDeployment,
|
updatePreviewDeployment,
|
||||||
@@ -54,7 +55,14 @@ export const deploymentWorker = new Worker(
|
|||||||
previewStatus: "running",
|
previewStatus: "running",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (job.data.type === "deploy") {
|
if (job.data.type === "redeploy") {
|
||||||
|
await rebuildPreviewApplication({
|
||||||
|
applicationId: job.data.applicationId,
|
||||||
|
titleLog: job.data.titleLog,
|
||||||
|
descriptionLog: job.data.descriptionLog,
|
||||||
|
previewDeploymentId: job.data.previewDeploymentId,
|
||||||
|
});
|
||||||
|
} else if (job.data.type === "deploy") {
|
||||||
await deployPreviewApplication({
|
await deployPreviewApplication({
|
||||||
applicationId: job.data.applicationId,
|
applicationId: job.data.applicationId,
|
||||||
titleLog: job.data.titleLog,
|
titleLog: job.data.titleLog,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type DeployJob =
|
|||||||
titleLog: string;
|
titleLog: string;
|
||||||
descriptionLog: string;
|
descriptionLog: string;
|
||||||
server?: boolean;
|
server?: boolean;
|
||||||
type: "deploy";
|
type: "deploy" | "redeploy";
|
||||||
applicationType: "application-preview";
|
applicationType: "application-preview";
|
||||||
previewDeploymentId: string;
|
previewDeploymentId: string;
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type http from "node:http";
|
import type http from "node:http";
|
||||||
import { findServerById, validateRequest } from "@dokploy/server";
|
import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
|
||||||
import { spawn } from "node-pty";
|
import { spawn } from "node-pty";
|
||||||
import { Client } from "ssh2";
|
import { Client } from "ssh2";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import { getShell } from "./utils";
|
import { getShell, isValidContainerId } from "./utils";
|
||||||
|
|
||||||
export const setupDockerContainerLogsWebSocketServer = (
|
export const setupDockerContainerLogsWebSocketServer = (
|
||||||
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
||||||
@@ -42,6 +42,12 @@ export const setupDockerContainerLogsWebSocketServer = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security: Validate containerId to prevent command injection
|
||||||
|
if (!isValidContainerId(containerId)) {
|
||||||
|
ws.close(4000, "Invalid container ID format");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!user || !session) {
|
if (!user || !session) {
|
||||||
ws.close();
|
ws.close();
|
||||||
return;
|
return;
|
||||||
@@ -111,6 +117,11 @@ export const setupDockerContainerLogsWebSocketServer = (
|
|||||||
client.end();
|
client.end();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
ws.send("This feature is not available in the cloud version.");
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const shell = getShell();
|
const shell = getShell();
|
||||||
const baseCommand = `docker ${runType === "swarm" ? "service" : "container"} logs --timestamps ${
|
const baseCommand = `docker ${runType === "swarm" ? "service" : "container"} logs --timestamps ${
|
||||||
runType === "swarm" ? "--raw" : ""
|
runType === "swarm" ? "--raw" : ""
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type http from "node:http";
|
import type http from "node:http";
|
||||||
import { findServerById, validateRequest } from "@dokploy/server";
|
import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
|
||||||
import { spawn } from "node-pty";
|
import { spawn } from "node-pty";
|
||||||
import { Client } from "ssh2";
|
import { Client } from "ssh2";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import { getShell } from "./utils";
|
import { isValidContainerId, isValidShell } from "./utils";
|
||||||
|
|
||||||
export const setupDockerContainerTerminalWebSocketServer = (
|
export const setupDockerContainerTerminalWebSocketServer = (
|
||||||
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
||||||
@@ -35,10 +35,25 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
|||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
|
|
||||||
if (!containerId) {
|
if (!containerId) {
|
||||||
ws.close(4000, "containerId no provided");
|
ws.close(4000, "containerId not provided");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security: Validate containerId to prevent command injection
|
||||||
|
if (!isValidContainerId(containerId)) {
|
||||||
|
ws.close(4000, "Invalid container ID format");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: Validate shell to prevent command injection
|
||||||
|
if (activeWay && !isValidShell(activeWay)) {
|
||||||
|
ws.close(4000, "Invalid shell specified");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to 'sh' if no shell specified
|
||||||
|
const shell = activeWay || "sh";
|
||||||
|
|
||||||
if (!user || !session) {
|
if (!user || !session) {
|
||||||
ws.close();
|
ws.close();
|
||||||
return;
|
return;
|
||||||
@@ -54,55 +69,61 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
|||||||
let _stderr = "";
|
let _stderr = "";
|
||||||
conn
|
conn
|
||||||
.once("ready", () => {
|
.once("ready", () => {
|
||||||
conn.exec(
|
// Use array-style arguments to prevent shell injection
|
||||||
`docker exec -it -w / ${containerId} ${activeWay}`,
|
const dockerCommand = [
|
||||||
{ pty: true },
|
"docker",
|
||||||
(err, stream) => {
|
"exec",
|
||||||
if (err) {
|
"-it",
|
||||||
console.error("SSH exec error:", err);
|
"-w",
|
||||||
ws.close();
|
"/",
|
||||||
|
containerId,
|
||||||
|
shell,
|
||||||
|
].join(" ");
|
||||||
|
conn.exec(dockerCommand, { pty: true }, (err, stream) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("SSH exec error:", err);
|
||||||
|
ws.close();
|
||||||
|
conn.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream
|
||||||
|
.on("close", (code: number, _signal: string) => {
|
||||||
|
ws.send(`\nContainer closed with code: ${code}\n`);
|
||||||
conn.end();
|
conn.end();
|
||||||
return;
|
})
|
||||||
}
|
.on("data", (data: string) => {
|
||||||
|
_stdout += data.toString();
|
||||||
|
ws.send(data.toString());
|
||||||
|
})
|
||||||
|
.stderr.on("data", (data) => {
|
||||||
|
_stderr += data.toString();
|
||||||
|
ws.send(data.toString());
|
||||||
|
console.error("Error: ", data.toString());
|
||||||
|
});
|
||||||
|
|
||||||
stream
|
ws.on("message", (message) => {
|
||||||
.on("close", (code: number, _signal: string) => {
|
try {
|
||||||
ws.send(`\nContainer closed with code: ${code}\n`);
|
let command: string | Buffer[] | Buffer | ArrayBuffer;
|
||||||
conn.end();
|
if (Buffer.isBuffer(message)) {
|
||||||
})
|
command = message.toString("utf8");
|
||||||
.on("data", (data: string) => {
|
} else {
|
||||||
_stdout += data.toString();
|
command = message;
|
||||||
ws.send(data.toString());
|
|
||||||
})
|
|
||||||
.stderr.on("data", (data) => {
|
|
||||||
_stderr += data.toString();
|
|
||||||
ws.send(data.toString());
|
|
||||||
console.error("Error: ", data.toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on("message", (message) => {
|
|
||||||
try {
|
|
||||||
let command: string | Buffer[] | Buffer | ArrayBuffer;
|
|
||||||
if (Buffer.isBuffer(message)) {
|
|
||||||
command = message.toString("utf8");
|
|
||||||
} else {
|
|
||||||
command = message;
|
|
||||||
}
|
|
||||||
stream.write(command.toString());
|
|
||||||
} catch (error) {
|
|
||||||
// @ts-ignore
|
|
||||||
const errorMessage = error?.message as unknown as string;
|
|
||||||
ws.send(errorMessage);
|
|
||||||
}
|
}
|
||||||
});
|
stream.write(command.toString());
|
||||||
|
} catch (error) {
|
||||||
|
// @ts-ignore
|
||||||
|
const errorMessage = error?.message as unknown as string;
|
||||||
|
ws.send(errorMessage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
stream.end();
|
stream.end();
|
||||||
// Ensure SSH connection is closed when WebSocket closes
|
// Ensure SSH connection is closed when WebSocket closes
|
||||||
conn.end();
|
conn.end();
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.on("error", (err) => {
|
.on("error", (err) => {
|
||||||
console.error("SSH connection error:", err);
|
console.error("SSH connection error:", err);
|
||||||
@@ -119,10 +140,14 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
|||||||
privateKey: server.sshKey?.privateKey,
|
privateKey: server.sshKey?.privateKey,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const shell = getShell();
|
if (IS_CLOUD) {
|
||||||
|
ws.send("This feature is not available in the cloud version.");
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ptyProcess = spawn(
|
const ptyProcess = spawn(
|
||||||
shell,
|
"docker",
|
||||||
["-c", `docker exec -it -w / ${containerId} ${activeWay}`],
|
["exec", "-it", "-w", "/", containerId, shell],
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
execAsync,
|
execAsync,
|
||||||
getHostSystemStats,
|
getHostSystemStats,
|
||||||
getLastAdvancedStatsFile,
|
getLastAdvancedStatsFile,
|
||||||
|
IS_CLOUD,
|
||||||
recordAdvancedStats,
|
recordAdvancedStats,
|
||||||
validateRequest,
|
validateRequest,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
@@ -32,6 +33,12 @@ export const setupDockerStatsMonitoringSocketServer = (
|
|||||||
|
|
||||||
wssTerm.on("connection", async (ws, req) => {
|
wssTerm.on("connection", async (ws, req) => {
|
||||||
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
const url = new URL(req.url || "", `http://${req.headers.host}`);
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
ws.send("This feature is not available in the cloud version.");
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const appName = url.searchParams.get("appName");
|
const appName = url.searchParams.get("appName");
|
||||||
const appType = (url.searchParams.get("appType") || "application") as
|
const appType = (url.searchParams.get("appType") || "application") as
|
||||||
| "application"
|
| "application"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import type http from "node:http";
|
import type http from "node:http";
|
||||||
import { findServerById, validateRequest } from "@dokploy/server";
|
import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
|
||||||
import { Client } from "ssh2";
|
import { Client } from "ssh2";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
|
import { readValidDirectory } from "./utils";
|
||||||
|
|
||||||
export const setupDeploymentLogsWebSocketServer = (
|
export const setupDeploymentLogsWebSocketServer = (
|
||||||
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
||||||
@@ -40,6 +41,11 @@ export const setupDeploymentLogsWebSocketServer = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!readValidDirectory(logPath)) {
|
||||||
|
ws.close(4000, "Invalid log path");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!user || !session) {
|
if (!user || !session) {
|
||||||
ws.close();
|
ws.close();
|
||||||
return;
|
return;
|
||||||
@@ -108,6 +114,11 @@ export const setupDeploymentLogsWebSocketServer = (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
ws.send("This feature is not available in the cloud version.");
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
tailProcess = spawn("tail", ["-n", "+1", "-f", logPath]);
|
tailProcess = spawn("tail", ["-n", "+1", "-f", logPath]);
|
||||||
|
|
||||||
const stdout = tailProcess.stdout;
|
const stdout = tailProcess.stdout;
|
||||||
|
|||||||
@@ -97,7 +97,12 @@ export const setupTerminalWebSocketServer = (
|
|||||||
|
|
||||||
const isLocalServer = serverId === "local";
|
const isLocalServer = serverId === "local";
|
||||||
|
|
||||||
if (isLocalServer && !IS_CLOUD) {
|
if (isLocalServer) {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
ws.send("This feature is not available in the cloud version.");
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const port = Number(url.searchParams.get("port"));
|
const port = Number(url.searchParams.get("port"));
|
||||||
const username = url.searchParams.get("username");
|
const username = url.searchParams.get("username");
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,52 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { execAsync, paths } from "@dokploy/server";
|
import { execAsync, IS_CLOUD, paths } from "@dokploy/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the container ID matches Docker's expected format.
|
||||||
|
* Docker container IDs are 64-character hex strings (or 12-char short form).
|
||||||
|
* Also allows container names: alphanumeric, underscores, hyphens, and dots.
|
||||||
|
*/
|
||||||
|
export const isValidContainerId = (id: string): boolean => {
|
||||||
|
// Match full ID (64 hex chars), short ID (12 hex chars), or container name
|
||||||
|
const hexPattern = /^[a-f0-9]{12,64}$/i;
|
||||||
|
const namePattern = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||||
|
return hexPattern.test(id) || (namePattern.test(id) && id.length <= 128);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that the shell is one of the allowed shells.
|
||||||
|
*/
|
||||||
|
export const isValidShell = (shell: string): boolean => {
|
||||||
|
const allowedShells = [
|
||||||
|
"sh",
|
||||||
|
"bash",
|
||||||
|
"zsh",
|
||||||
|
"ash",
|
||||||
|
"/bin/sh",
|
||||||
|
"/bin/bash",
|
||||||
|
"/bin/zsh",
|
||||||
|
"/bin/ash",
|
||||||
|
];
|
||||||
|
return allowedShells.includes(shell);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readValidDirectory = (directory: string) => {
|
||||||
|
const { BASE_PATH } = paths();
|
||||||
|
|
||||||
|
const resolvedBase = path.resolve(BASE_PATH);
|
||||||
|
const resolvedDir = path.resolve(directory);
|
||||||
|
|
||||||
|
return (
|
||||||
|
resolvedDir === resolvedBase ||
|
||||||
|
resolvedDir.startsWith(resolvedBase + path.sep)
|
||||||
|
);
|
||||||
|
};
|
||||||
export const getShell = () => {
|
export const getShell = () => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return "NO_AVAILABLE";
|
||||||
|
}
|
||||||
switch (os.platform()) {
|
switch (os.platform()) {
|
||||||
case "win32":
|
case "win32":
|
||||||
return "powershell.exe";
|
return "powershell.exe";
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func (db *DB) GetLastNContainerMetrics(containerName string, limit int) ([]Conta
|
|||||||
WITH recent_metrics AS (
|
WITH recent_metrics AS (
|
||||||
SELECT metrics_json
|
SELECT metrics_json
|
||||||
FROM container_metrics
|
FROM container_metrics
|
||||||
WHERE container_name LIKE ? || '%'
|
WHERE container_name = ?
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
)
|
)
|
||||||
@@ -98,7 +98,7 @@ func (db *DB) GetAllMetricsContainer(containerName string) ([]ContainerMetric, e
|
|||||||
WITH recent_metrics AS (
|
WITH recent_metrics AS (
|
||||||
SELECT metrics_json
|
SELECT metrics_json
|
||||||
FROM container_metrics
|
FROM container_metrics
|
||||||
WHERE container_name LIKE ? || '%'
|
WHERE container_name = ?
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
)
|
)
|
||||||
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
|
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
|
||||||
|
|||||||
45
lefthook.yml
45
lefthook.yml
@@ -1,45 +0,0 @@
|
|||||||
# EXAMPLE USAGE:
|
|
||||||
#
|
|
||||||
# Refer for explanation to following link:
|
|
||||||
# https://github.com/evilmartians/lefthook/blob/master/docs/configuration.md
|
|
||||||
#
|
|
||||||
# pre-push:
|
|
||||||
# commands:
|
|
||||||
# packages-audit:
|
|
||||||
# tags: frontend security
|
|
||||||
# run: yarn audit
|
|
||||||
# gems-audit:
|
|
||||||
# tags: backend security
|
|
||||||
# run: bundle audit
|
|
||||||
#
|
|
||||||
# pre-commit:
|
|
||||||
# parallel: true
|
|
||||||
# commands:
|
|
||||||
# eslint:
|
|
||||||
# glob: "*.{js,ts,jsx,tsx}"
|
|
||||||
# run: yarn eslint {staged_files}
|
|
||||||
# rubocop:
|
|
||||||
# tags: backend style
|
|
||||||
# glob: "*.rb"
|
|
||||||
# exclude: '(^|/)(application|routes)\.rb$'
|
|
||||||
# run: bundle exec rubocop --force-exclusion {all_files}
|
|
||||||
# govet:
|
|
||||||
# tags: backend style
|
|
||||||
# files: git ls-files -m
|
|
||||||
# glob: "*.go"
|
|
||||||
# run: go vet {files}
|
|
||||||
# scripts:
|
|
||||||
# "hello.js":
|
|
||||||
# runner: node
|
|
||||||
# "any.go":
|
|
||||||
# runner: go run
|
|
||||||
|
|
||||||
commit-msg:
|
|
||||||
commands:
|
|
||||||
commitlint:
|
|
||||||
# run: "npx commitlint --edit $1"
|
|
||||||
|
|
||||||
pre-commit:
|
|
||||||
commands:
|
|
||||||
check:
|
|
||||||
# run: "pnpm check"
|
|
||||||
@@ -24,12 +24,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.1.1",
|
"@biomejs/biome": "2.1.1",
|
||||||
"@commitlint/cli": "^19.8.1",
|
|
||||||
"@commitlint/config-conventional": "^19.8.1",
|
|
||||||
"@types/node": "^18.19.104",
|
"@types/node": "^18.19.104",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
"esbuild": "0.20.2",
|
"esbuild": "0.20.2",
|
||||||
"lefthook": "1.8.4",
|
|
||||||
"lint-staged": "^15.5.2",
|
"lint-staged": "^15.5.2",
|
||||||
"tsx": "4.16.2"
|
"tsx": "4.16.2"
|
||||||
},
|
},
|
||||||
@@ -43,11 +40,6 @@
|
|||||||
"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"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@types/react": "18.3.5",
|
"@types/react": "18.3.5",
|
||||||
"@types/react-dom": "18.3.0"
|
"@types/react-dom": "18.3.0"
|
||||||
|
|||||||
@@ -57,7 +57,6 @@
|
|||||||
"drizzle-dbml-generator": "0.10.0",
|
"drizzle-dbml-generator": "0.10.0",
|
||||||
"drizzle-orm": "^0.39.3",
|
"drizzle-orm": "^0.39.3",
|
||||||
"drizzle-zod": "0.5.1",
|
"drizzle-zod": "0.5.1",
|
||||||
"hi-base32": "^0.5.1",
|
|
||||||
"yaml": "2.8.1",
|
"yaml": "2.8.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
@@ -67,7 +66,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",
|
||||||
@@ -80,9 +78,11 @@
|
|||||||
"ssh2": "1.15.0",
|
"ssh2": "1.15.0",
|
||||||
"toml": "3.0.0",
|
"toml": "3.0.0",
|
||||||
"ws": "8.16.0",
|
"ws": "8.16.0",
|
||||||
"zod": "^3.25.32"
|
"zod": "^3.25.32",
|
||||||
|
"semver": "7.7.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/semver": "7.7.1",
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/bcrypt": "5.0.2",
|
"@types/bcrypt": "5.0.2",
|
||||||
"@types/dockerode": "3.3.23",
|
"@types/dockerode": "3.3.23",
|
||||||
@@ -111,4 +111,4 @@
|
|||||||
"node": "^20.16.0",
|
"node": "^20.16.0",
|
||||||
"pnpm": ">=9.12.0"
|
"pnpm": ">=9.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ table application {
|
|||||||
replicas integer [not null, default: 1]
|
replicas integer [not null, default: 1]
|
||||||
applicationStatus applicationStatus [not null, default: 'idle']
|
applicationStatus applicationStatus [not null, default: 'idle']
|
||||||
buildType buildType [not null, default: 'nixpacks']
|
buildType buildType [not null, default: 'nixpacks']
|
||||||
railpackVersion text [default: '0.2.2']
|
railpackVersion text [default: '0.15.4']
|
||||||
herokuVersion text [default: '24']
|
herokuVersion text [default: '24']
|
||||||
publishDirectory text
|
publishDirectory text
|
||||||
isStaticSpa boolean
|
isStaticSpa boolean
|
||||||
|
|||||||
39
packages/server/src/db/constants.ts
Normal file
39
packages/server/src/db/constants.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
export const {
|
||||||
|
DATABASE_URL,
|
||||||
|
POSTGRES_PASSWORD_FILE,
|
||||||
|
POSTGRES_USER = "dokploy",
|
||||||
|
POSTGRES_DB = "dokploy",
|
||||||
|
POSTGRES_HOST = "dokploy-postgres",
|
||||||
|
POSTGRES_PORT = "5432",
|
||||||
|
} = process.env;
|
||||||
|
|
||||||
|
function readSecret(path: string): string {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(path, "utf8").trim();
|
||||||
|
} catch {
|
||||||
|
throw new Error(`Cannot read secret at ${path}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export let dbUrl: string;
|
||||||
|
if (DATABASE_URL) {
|
||||||
|
// Compatibilidad legacy / overrides
|
||||||
|
dbUrl = DATABASE_URL;
|
||||||
|
} else if (POSTGRES_PASSWORD_FILE) {
|
||||||
|
const password = readSecret(POSTGRES_PASSWORD_FILE);
|
||||||
|
dbUrl = `postgres://${POSTGRES_USER}:${encodeURIComponent(
|
||||||
|
password,
|
||||||
|
)}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}`;
|
||||||
|
} else {
|
||||||
|
console.warn(`
|
||||||
|
⚠️ [DEPRECATED DATABASE CONFIG]
|
||||||
|
You are using the legacy hardcoded database credentials.
|
||||||
|
This mode WILL BE REMOVED in a future release.
|
||||||
|
|
||||||
|
Please migrate to Docker Secrets using POSTGRES_PASSWORD_FILE.
|
||||||
|
Please execute this command in your server: curl -sSL https://dokploy.com/security/0.26.6.sh | bash
|
||||||
|
`);
|
||||||
|
dbUrl =
|
||||||
|
"postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy";
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
|
import { dbUrl } from "./constants";
|
||||||
import * as schema from "./schema";
|
import * as schema from "./schema";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -8,14 +9,16 @@ declare global {
|
|||||||
|
|
||||||
export let db: PostgresJsDatabase<typeof schema>;
|
export let db: PostgresJsDatabase<typeof schema>;
|
||||||
if (process.env.NODE_ENV === "production") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
db = drizzle(postgres(process.env.DATABASE_URL!), {
|
db = drizzle(postgres(dbUrl), {
|
||||||
schema,
|
schema,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (!global.db)
|
if (!global.db)
|
||||||
global.db = drizzle(postgres(process.env.DATABASE_URL!), {
|
global.db = drizzle(postgres(dbUrl), {
|
||||||
schema,
|
schema,
|
||||||
});
|
});
|
||||||
|
|
||||||
db = global.db;
|
db = global.db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { dbUrl };
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export const applications = pgTable("application", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default("idle"),
|
.default("idle"),
|
||||||
buildType: buildType("buildType").notNull().default("nixpacks"),
|
buildType: buildType("buildType").notNull().default("nixpacks"),
|
||||||
railpackVersion: text("railpackVersion").default("0.2.2"),
|
railpackVersion: text("railpackVersion").default("0.15.4"),
|
||||||
herokuVersion: text("herokuVersion").default("24"),
|
herokuVersion: text("herokuVersion").default("24"),
|
||||||
publishDirectory: text("publishDirectory"),
|
publishDirectory: text("publishDirectory"),
|
||||||
isStaticSpa: boolean("isStaticSpa"),
|
isStaticSpa: boolean("isStaticSpa"),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from "./auth/random-password";
|
export * from "./auth/random-password";
|
||||||
export * from "./constants/index";
|
export * from "./constants/index";
|
||||||
|
export * from "./db/constants";
|
||||||
export * from "./db/validations/domain";
|
export * from "./db/validations/domain";
|
||||||
export * from "./db/validations/index";
|
export * from "./db/validations/index";
|
||||||
export * from "./lib/auth";
|
export * from "./lib/auth";
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ const { handler, api } = betterAuth({
|
|||||||
return [
|
return [
|
||||||
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
|
||||||
...(settings?.host ? [`https://${settings?.host}`] : []),
|
...(settings?.host ? [`https://${settings?.host}`] : []),
|
||||||
|
...(process.env.NODE_ENV === "development"
|
||||||
|
? [
|
||||||
|
"http://localhost:3000",
|
||||||
|
"https://absolutely-handy-falcon.ngrok-free.app",
|
||||||
|
]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -452,6 +452,137 @@ export const deployPreviewApplication = async ({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const rebuildPreviewApplication = async ({
|
||||||
|
applicationId,
|
||||||
|
titleLog = "Rebuild Preview Deployment",
|
||||||
|
descriptionLog = "",
|
||||||
|
previewDeploymentId,
|
||||||
|
}: {
|
||||||
|
applicationId: string;
|
||||||
|
titleLog: string;
|
||||||
|
descriptionLog: string;
|
||||||
|
previewDeploymentId: string;
|
||||||
|
}) => {
|
||||||
|
const application = await findApplicationById(applicationId);
|
||||||
|
const previewDeployment =
|
||||||
|
await findPreviewDeploymentById(previewDeploymentId);
|
||||||
|
|
||||||
|
const deployment = await createDeploymentPreview({
|
||||||
|
title: titleLog,
|
||||||
|
description: descriptionLog,
|
||||||
|
previewDeploymentId: previewDeploymentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewDomain = getDomainHost(previewDeployment?.domain as Domain);
|
||||||
|
const issueParams = {
|
||||||
|
owner: application?.owner || "",
|
||||||
|
repository: application?.repository || "",
|
||||||
|
issue_number: previewDeployment.pullRequestNumber,
|
||||||
|
comment_id: Number.parseInt(previewDeployment.pullRequestCommentId),
|
||||||
|
githubId: application?.githubId || "",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const commentExists = await issueCommentExists({
|
||||||
|
...issueParams,
|
||||||
|
});
|
||||||
|
if (!commentExists) {
|
||||||
|
const result = await createPreviewDeploymentComment({
|
||||||
|
...issueParams,
|
||||||
|
previewDomain,
|
||||||
|
appName: previewDeployment.appName,
|
||||||
|
githubId: application?.githubId || "",
|
||||||
|
previewDeploymentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Pull request comment not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
issueParams.comment_id = Number.parseInt(result?.pullRequestCommentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildingComment = getIssueComment(
|
||||||
|
application.name,
|
||||||
|
"running",
|
||||||
|
previewDomain,
|
||||||
|
);
|
||||||
|
await updateIssueComment({
|
||||||
|
...issueParams,
|
||||||
|
body: `### Dokploy Preview Deployment\n\n${buildingComment}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set application properties for preview deployment
|
||||||
|
application.appName = previewDeployment.appName;
|
||||||
|
application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||||
|
application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||||
|
application.buildSecrets = `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
|
||||||
|
application.rollbackActive = false;
|
||||||
|
application.buildRegistry = null;
|
||||||
|
application.rollbackRegistry = null;
|
||||||
|
application.registry = null;
|
||||||
|
|
||||||
|
const serverId = application.serverId;
|
||||||
|
let command = "set -e;";
|
||||||
|
// Only rebuild, don't clone repository
|
||||||
|
command += await getBuildCommand(application);
|
||||||
|
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, commandWithLog);
|
||||||
|
} else {
|
||||||
|
await execAsync(commandWithLog);
|
||||||
|
}
|
||||||
|
await mechanizeDockerContainer(application);
|
||||||
|
|
||||||
|
const successComment = getIssueComment(
|
||||||
|
application.name,
|
||||||
|
"success",
|
||||||
|
previewDomain,
|
||||||
|
);
|
||||||
|
await updateIssueComment({
|
||||||
|
...issueParams,
|
||||||
|
body: `### Dokploy Preview Deployment\n\n${successComment}`,
|
||||||
|
});
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||||
|
await updatePreviewDeployment(previewDeploymentId, {
|
||||||
|
previewStatus: "done",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
let command = "";
|
||||||
|
|
||||||
|
// Only log details for non-ExecError errors
|
||||||
|
if (!(error instanceof ExecError)) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const encodedMessage = encodeBase64(message);
|
||||||
|
command += `echo "${encodedMessage}" | base64 -d >> "${deployment.logPath}";`;
|
||||||
|
}
|
||||||
|
|
||||||
|
command += `echo "\nError occurred ❌, check the logs for details." >> ${deployment.logPath};`;
|
||||||
|
const serverId = application.buildServerId || application.serverId;
|
||||||
|
if (serverId) {
|
||||||
|
await execAsyncRemote(serverId, command);
|
||||||
|
} else {
|
||||||
|
await execAsync(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = getIssueComment(application.name, "error", previewDomain);
|
||||||
|
await updateIssueComment({
|
||||||
|
...issueParams,
|
||||||
|
body: `### Dokploy Preview Deployment\n\n${comment}`,
|
||||||
|
});
|
||||||
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
|
await updatePreviewDeployment(previewDeploymentId, {
|
||||||
|
previewStatus: "error",
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
export const getApplicationStats = async (appName: string) => {
|
export const getApplicationStats = async (appName: string) => {
|
||||||
if (appName === "dokploy") {
|
if (appName === "dokploy") {
|
||||||
return await getAdvancedStats(appName);
|
return await getAdvancedStats(appName);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import dns from "node:dns";
|
import dns from "node:dns";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
|
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
|
||||||
import { generateRandomDomain } from "@dokploy/server/templates";
|
import { generateRandomDomain } from "@dokploy/server/templates";
|
||||||
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
|
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
|
||||||
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { type apiCreateDomain, domains } from "../db/schema";
|
import { type apiCreateDomain, domains } from "../db/schema";
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import {
|
|||||||
execAsync,
|
execAsync,
|
||||||
execAsyncRemote,
|
execAsyncRemote,
|
||||||
} from "@dokploy/server/utils/process/execAsync";
|
} from "@dokploy/server/utils/process/execAsync";
|
||||||
|
import semver from "semver";
|
||||||
import {
|
import {
|
||||||
initializeStandaloneTraefik,
|
initializeStandaloneTraefik,
|
||||||
initializeTraefikService,
|
initializeTraefikService,
|
||||||
type TraefikOptions,
|
type TraefikOptions,
|
||||||
} from "../setup/traefik-setup";
|
} from "../setup/traefik-setup";
|
||||||
|
|
||||||
export interface IUpdateData {
|
export interface IUpdateData {
|
||||||
latestVersion: string | null;
|
latestVersion: string | null;
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
@@ -55,56 +55,95 @@ export const getServiceImageDigest = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Returns latest version number and information whether server update is available by comparing current image's digest against digest for provided image tag via Docker hub API. */
|
/** Returns latest version number and information whether server update is available by comparing current image's digest against digest for provided image tag via Docker hub API. */
|
||||||
export const getUpdateData = async (): Promise<IUpdateData> => {
|
export const getUpdateData = async (
|
||||||
let currentDigest: string;
|
currentVersion: string,
|
||||||
|
): Promise<IUpdateData> => {
|
||||||
try {
|
try {
|
||||||
currentDigest = await getServiceImageDigest();
|
const baseUrl =
|
||||||
} catch (error) {
|
"https://hub.docker.com/v2/repositories/dokploy/dokploy/tags";
|
||||||
// TODO: Docker versions 29.0.0 change the way to get the service image digest, so we need to update this in the future we upgrade to that version.
|
let url: string | null = `${baseUrl}?page_size=100`;
|
||||||
return DEFAULT_UPDATE_DATA;
|
let allResults: { digest: string; name: string }[] = [];
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = "https://hub.docker.com/v2/repositories/dokploy/dokploy/tags";
|
// Fetch all tags from Docker Hub
|
||||||
let url: string | null = `${baseUrl}?page_size=100`;
|
while (url) {
|
||||||
let allResults: { digest: string; name: string }[] = [];
|
const response = await fetch(url, {
|
||||||
while (url) {
|
method: "GET",
|
||||||
const response = await fetch(url, {
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "GET",
|
});
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
next: string | null;
|
next: string | null;
|
||||||
results: { digest: string; name: string }[];
|
results: { digest: string; name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
allResults = allResults.concat(data.results);
|
allResults = allResults.concat(data.results);
|
||||||
url = data?.next;
|
url = data?.next;
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageTag = getDokployImageTag();
|
const currentImageTag = getDokployImageTag();
|
||||||
const searchedDigest = allResults.find((t) => t.name === imageTag)?.digest;
|
|
||||||
|
|
||||||
if (!searchedDigest) {
|
// Special handling for canary and feature branches
|
||||||
return DEFAULT_UPDATE_DATA;
|
// For development versions (canary/feature), don't perform update checks
|
||||||
}
|
// These are unstable versions that change frequently, and users on these
|
||||||
|
// branches are expected to manually manage updates
|
||||||
|
if (currentImageTag === "canary" || currentImageTag === "feature") {
|
||||||
|
const currentDigest = await getServiceImageDigest();
|
||||||
|
const latestDigest = allResults.find(
|
||||||
|
(t) => t.name === currentImageTag,
|
||||||
|
)?.digest;
|
||||||
|
if (!latestDigest) {
|
||||||
|
return DEFAULT_UPDATE_DATA;
|
||||||
|
}
|
||||||
|
if (currentDigest !== latestDigest) {
|
||||||
|
return {
|
||||||
|
latestVersion: currentImageTag,
|
||||||
|
updateAvailable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
latestVersion: currentImageTag,
|
||||||
|
updateAvailable: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (imageTag === "latest") {
|
// For stable versions, use semver comparison
|
||||||
const versionedTag = allResults.find(
|
// Find the "latest" tag and get its digest
|
||||||
(t) => t.digest === searchedDigest && t.name.startsWith("v"),
|
const latestTag = allResults.find((t) => t.name === "latest");
|
||||||
);
|
|
||||||
|
|
||||||
if (!versionedTag) {
|
if (!latestTag) {
|
||||||
return DEFAULT_UPDATE_DATA;
|
return DEFAULT_UPDATE_DATA;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name: latestVersion, digest } = versionedTag;
|
// Find the versioned tag (v0.x.x) that has the same digest as "latest"
|
||||||
const updateAvailable = digest !== currentDigest;
|
const latestVersionTag = allResults.find(
|
||||||
|
(t) => t.digest === latestTag.digest && t.name.startsWith("v"),
|
||||||
|
);
|
||||||
|
|
||||||
return { latestVersion, updateAvailable };
|
if (!latestVersionTag) {
|
||||||
|
return DEFAULT_UPDATE_DATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestVersion = latestVersionTag.name;
|
||||||
|
|
||||||
|
// Use semver to compare versions for stable releases
|
||||||
|
const cleanedCurrent = semver.clean(currentVersion);
|
||||||
|
const cleanedLatest = semver.clean(latestVersion);
|
||||||
|
|
||||||
|
if (!cleanedCurrent || !cleanedLatest) {
|
||||||
|
return DEFAULT_UPDATE_DATA;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the latest version is greater than the current version
|
||||||
|
const updateAvailable = semver.gt(cleanedLatest, cleanedCurrent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
latestVersion,
|
||||||
|
updateAvailable,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching update data:", error);
|
||||||
|
return DEFAULT_UPDATE_DATA;
|
||||||
}
|
}
|
||||||
const updateAvailable = searchedDigest !== currentDigest;
|
|
||||||
return { latestVersion: imageTag, updateAvailable };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TreeDataItem {
|
interface TreeDataItem {
|
||||||
@@ -254,11 +293,22 @@ fi`;
|
|||||||
export const reloadDockerResource = async (
|
export const reloadDockerResource = async (
|
||||||
resourceName: string,
|
resourceName: string,
|
||||||
serverId?: string,
|
serverId?: string,
|
||||||
|
version?: string,
|
||||||
) => {
|
) => {
|
||||||
const resourceType = await getDockerResourceType(resourceName, serverId);
|
const resourceType = await getDockerResourceType(resourceName, serverId);
|
||||||
let command = "";
|
let command = "";
|
||||||
if (resourceType === "service") {
|
if (resourceType === "service") {
|
||||||
command = `docker service update --force ${resourceName}`;
|
if (resourceName === "dokploy") {
|
||||||
|
const currentImageTag = getDokployImageTag();
|
||||||
|
let imageTag = version;
|
||||||
|
if (currentImageTag === "canary" || currentImageTag === "feature") {
|
||||||
|
imageTag = currentImageTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
command = `docker service update --force --image dokploy/dokploy:${imageTag} ${resourceName}`;
|
||||||
|
} else {
|
||||||
|
command = `docker service update --force ${resourceName}`;
|
||||||
|
}
|
||||||
} else if (resourceType === "standalone") {
|
} else if (resourceType === "standalone") {
|
||||||
command = `docker restart ${resourceName}`;
|
command = `docker restart ${resourceName}`;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { paths } from "@dokploy/server/constants";
|
import { IS_CLOUD, paths } from "@dokploy/server/constants";
|
||||||
|
import { getDokployUrl } from "@dokploy/server/services/admin";
|
||||||
import {
|
import {
|
||||||
createServerDeployment,
|
createServerDeployment,
|
||||||
updateDeploymentStatus,
|
updateDeploymentStatus,
|
||||||
} from "@dokploy/server/services/deployment";
|
} from "@dokploy/server/services/deployment";
|
||||||
import { findServerById } from "@dokploy/server/services/server";
|
import {
|
||||||
|
findServerById,
|
||||||
|
updateServerById,
|
||||||
|
} from "@dokploy/server/services/server";
|
||||||
import {
|
import {
|
||||||
getDefaultMiddlewares,
|
getDefaultMiddlewares,
|
||||||
getDefaultServerTraefikConfig,
|
getDefaultServerTraefikConfig,
|
||||||
@@ -16,6 +20,15 @@ import {
|
|||||||
import slug from "slugify";
|
import slug from "slugify";
|
||||||
import { Client } from "ssh2";
|
import { Client } from "ssh2";
|
||||||
import { recreateDirectory } from "../utils/filesystem/directory";
|
import { recreateDirectory } from "../utils/filesystem/directory";
|
||||||
|
import { setupMonitoring } from "./monitoring-setup";
|
||||||
|
|
||||||
|
const generateToken = () => {
|
||||||
|
const array = new Uint8Array(64);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const slugify = (text: string | undefined) => {
|
export const slugify = (text: string | undefined) => {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
@@ -59,6 +72,29 @@ export const serverSetup = async (
|
|||||||
);
|
);
|
||||||
await installRequirements(serverId, onData);
|
await installRequirements(serverId, onData);
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
onData?.("\nConfiguring Monitoring: 🔄\n");
|
||||||
|
|
||||||
|
const baseUrl = await getDokployUrl();
|
||||||
|
const token = generateToken();
|
||||||
|
const urlCallback = `${baseUrl}/api/trpc/notification.receiveNotification`;
|
||||||
|
|
||||||
|
// Update server with monitoring configuration
|
||||||
|
await updateServerById(serverId, {
|
||||||
|
metricsConfig: {
|
||||||
|
server: {
|
||||||
|
...server.metricsConfig.server,
|
||||||
|
token: token,
|
||||||
|
urlCallback: urlCallback,
|
||||||
|
},
|
||||||
|
containers: server.metricsConfig.containers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupMonitoring(serverId);
|
||||||
|
onData?.("\nMonitoring Configured: ✅\n");
|
||||||
|
}
|
||||||
|
|
||||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||||
|
|
||||||
onData?.("\nSetup Server: ✅\n");
|
onData?.("\nSetup Server: ✅\n");
|
||||||
@@ -629,7 +665,7 @@ const installNixpacks = () => `
|
|||||||
if command_exists nixpacks; then
|
if command_exists nixpacks; then
|
||||||
echo "Nixpacks already installed ✅"
|
echo "Nixpacks already installed ✅"
|
||||||
else
|
else
|
||||||
export NIXPACKS_VERSION=1.39.0
|
export NIXPACKS_VERSION=1.41.0
|
||||||
bash -c "$(curl -fsSL https://nixpacks.com/install.sh)"
|
bash -c "$(curl -fsSL https://nixpacks.com/install.sh)"
|
||||||
echo "Nixpacks version $NIXPACKS_VERSION installed ✅"
|
echo "Nixpacks version $NIXPACKS_VERSION installed ✅"
|
||||||
fi
|
fi
|
||||||
@@ -639,7 +675,7 @@ const installRailpack = () => `
|
|||||||
if command_exists railpack; then
|
if command_exists railpack; then
|
||||||
echo "Railpack already installed ✅"
|
echo "Railpack already installed ✅"
|
||||||
else
|
else
|
||||||
export RAILPACK_VERSION=0.2.2
|
export RAILPACK_VERSION=0.15.4
|
||||||
bash -c "$(curl -fsSL https://railpack.com/install.sh)"
|
bash -c "$(curl -fsSL https://railpack.com/install.sh)"
|
||||||
echo "Railpack version $RAILPACK_VERSION installed ✅"
|
echo "Railpack version $RAILPACK_VERSION installed ✅"
|
||||||
fi
|
fi
|
||||||
@@ -653,8 +689,8 @@ const installBuildpacks = () => `
|
|||||||
if command_exists pack; then
|
if command_exists pack; then
|
||||||
echo "Buildpacks already installed ✅"
|
echo "Buildpacks already installed ✅"
|
||||||
else
|
else
|
||||||
BUILDPACKS_VERSION=0.35.0
|
BUILDPACKS_VERSION=0.39.1
|
||||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v$BUILDPACKS_VERSION-linux$SUFFIX.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v$BUILDPACKS_VERSION-linux$SUFFIX.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||||
echo "Buildpacks version $BUILDPACKS_VERSION installed ✅"
|
echo "Buildpacks version $BUILDPACKS_VERSION installed ✅"
|
||||||
fi
|
fi
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -71,8 +71,9 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
|
|||||||
return createOpenAICompatible({
|
return createOpenAICompatible({
|
||||||
name: "gemini",
|
name: "gemini",
|
||||||
baseURL: config.apiUrl,
|
baseURL: config.apiUrl,
|
||||||
queryParams: { key: config.apiKey },
|
headers: {
|
||||||
headers: {},
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
case "custom":
|
case "custom":
|
||||||
return createOpenAICompatible({
|
return createOpenAICompatible({
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ const getExportEnvCommand = (compose: ComposeNested) => {
|
|||||||
const envVars = getEnviromentVariablesObject(
|
const envVars = getEnviromentVariablesObject(
|
||||||
compose.env,
|
compose.env,
|
||||||
compose.environment.project.env,
|
compose.environment.project.env,
|
||||||
|
compose.environment.env,
|
||||||
);
|
);
|
||||||
const exports = Object.entries(envVars)
|
const exports = Object.entries(envVars)
|
||||||
.map(([key, value]) => `${key}=${quote([value])}`)
|
.map(([key, value]) => `${key}=${quote([value])}`)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user