mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-16 20:55:21 +02:00
Compare commits
165 Commits
feat/add-a
...
v0.26.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ea8b5283 | ||
|
|
060a053fdb | ||
|
|
304069d3c8 | ||
|
|
5967f48c6b | ||
|
|
f3bb56910a | ||
|
|
24c1c2a377 | ||
|
|
6fdb2e4a21 | ||
|
|
15e90e9ca9 | ||
|
|
d1553e1bda | ||
|
|
880a377e54 | ||
|
|
74e0bd5fe3 | ||
|
|
74aecf6828 | ||
|
|
7362cc49d2 | ||
|
|
84fa805acc | ||
|
|
bcbf433607 | ||
|
|
bc6647071f | ||
|
|
dd10d0b1a4 | ||
|
|
9714695d5a | ||
|
|
37e817ff26 | ||
|
|
733f4c4a23 | ||
|
|
86548a1f24 | ||
|
|
dbd354d928 | ||
|
|
9a9e3dc295 | ||
|
|
cbd70fe5d0 | ||
|
|
c8ec86c639 | ||
|
|
b902c160a2 | ||
|
|
8f2a0f8029 | ||
|
|
f334e89108 | ||
|
|
a8fc2adab6 | ||
|
|
b8d8d9e5b2 | ||
|
|
6c2457907f | ||
|
|
36f082f12a | ||
|
|
f3f52c21ab | ||
|
|
9c565656b1 | ||
|
|
983c8d5e9e | ||
|
|
9a7b7c0c23 | ||
|
|
a76147d820 | ||
|
|
7e48b2cf29 | ||
|
|
a0d8eb9380 | ||
|
|
e5fcc10db2 | ||
|
|
a33c6bcce4 | ||
|
|
5aa5b5538c | ||
|
|
49e52ac674 | ||
|
|
2a8387bcc2 | ||
|
|
138b193577 | ||
|
|
f0400495b0 | ||
|
|
240e5cb12f | ||
|
|
2760c16ade | ||
|
|
79655b5673 | ||
|
|
384fdd01d6 | ||
|
|
c93ec1f06c | ||
|
|
7b3f0273cb | ||
|
|
66ed6e07c0 | ||
|
|
c1d452bcf7 | ||
|
|
f39b511316 | ||
|
|
a2df52ea7c | ||
|
|
3e5a189177 | ||
|
|
2b9231dcd1 | ||
|
|
5d26df9d9f | ||
|
|
b965dedd7d | ||
|
|
2b779f9fc6 | ||
|
|
15b0ca7ab2 | ||
|
|
fd6f61fd2a | ||
|
|
8f95546535 | ||
|
|
8b370d4f7b | ||
|
|
1ed941b17c | ||
|
|
18d980c3ff | ||
|
|
5ddcdd843c | ||
|
|
fdf88b1ff3 | ||
|
|
13b64e45ec | ||
|
|
4383e46686 | ||
|
|
60d69d2915 | ||
|
|
a2b16d4be8 | ||
|
|
831a1815cf | ||
|
|
6b9bcbc539 | ||
|
|
6ca6ff3530 | ||
|
|
7583d5f860 | ||
|
|
7921f754fd | ||
|
|
0c0944d221 | ||
|
|
d490111a58 | ||
|
|
167daccee0 | ||
|
|
11af6a5eb9 | ||
|
|
85424badcf | ||
|
|
ccfd7f5189 | ||
|
|
6d94da1dee | ||
|
|
10c0de9d5f | ||
|
|
2b0ae65f71 | ||
|
|
2acaaede37 | ||
|
|
f303962319 | ||
|
|
edc8efe816 | ||
|
|
4e0cb2a9c7 | ||
|
|
4001f1d067 | ||
|
|
d894b2a3bf | ||
|
|
14d359dd14 | ||
|
|
1e11f603de | ||
|
|
d12f029e2b | ||
|
|
0c62bc0f29 | ||
|
|
b19d3e94eb | ||
|
|
5005f9198b | ||
|
|
fe5efd7651 | ||
|
|
8db7a421dc | ||
|
|
068deecb61 | ||
|
|
9aa03efd13 | ||
|
|
016aa0248a | ||
|
|
eb9d140c5d | ||
|
|
9e8c3f1525 | ||
|
|
611b0b3113 | ||
|
|
2eb73b988b | ||
|
|
d2ce587494 | ||
|
|
13ad8cb846 | ||
|
|
0897417d7c | ||
|
|
eb14a68bdd | ||
|
|
01c0b461b5 | ||
|
|
9498fbeff3 | ||
|
|
d2aa60ddf7 | ||
|
|
58b75205af | ||
|
|
9e03625586 | ||
|
|
260efdc2bb | ||
|
|
1b5bfe051d | ||
|
|
e4384075f2 | ||
|
|
b355d44605 | ||
|
|
f39aa23803 | ||
|
|
3abc4cdc3b | ||
|
|
ec56062f17 | ||
|
|
10c4f882a5 | ||
|
|
f1dfa9c6a2 | ||
|
|
6010643d9e | ||
|
|
1ccb205495 | ||
|
|
b2be5bc09f | ||
|
|
babd30a110 | ||
|
|
e77f276785 | ||
|
|
78c9a047b0 | ||
|
|
84e0f5856b | ||
|
|
2bfa4643fc | ||
|
|
8c7bc82712 | ||
|
|
44645a6fbe | ||
|
|
771d0dd8ab | ||
|
|
67725759e6 | ||
|
|
2065372d4f | ||
|
|
67d5e1a350 | ||
|
|
93fa19213e | ||
|
|
1988a14b24 | ||
|
|
3bdf029155 | ||
|
|
e1896c2498 | ||
|
|
a8064afd60 | ||
|
|
3849a206e8 | ||
|
|
69d5c6f0cb | ||
|
|
bb0a53d976 | ||
|
|
0a8753d0a9 | ||
|
|
23b14cf0cf | ||
|
|
53f67c6eb2 | ||
|
|
7c53a3ef75 | ||
|
|
ed701df6ac | ||
|
|
dfc15cd621 | ||
|
|
1ac3d1c1b0 | ||
|
|
f6b756e711 | ||
|
|
9f84dd4e0d | ||
|
|
2e32b0a4af | ||
|
|
0f69bbbd20 | ||
|
|
9e79314ef4 | ||
|
|
540b4039ac | ||
|
|
9e89edf167 | ||
|
|
e31d5a723b | ||
|
|
d465fb4da1 | ||
|
|
698104e7b7 |
6
.github/workflows/pull-request.yml
vendored
6
.github/workflows/pull-request.yml
vendored
@@ -24,14 +24,14 @@ jobs:
|
||||
- name: Install Nixpacks
|
||||
if: matrix.job == 'test'
|
||||
run: |
|
||||
export NIXPACKS_VERSION=1.39.0
|
||||
export NIXPACKS_VERSION=1.41.0
|
||||
curl -sSL https://nixpacks.com/install.sh | bash
|
||||
echo "Nixpacks installed $NIXPACKS_VERSION"
|
||||
|
||||
|
||||
- name: Install Railpack
|
||||
if: matrix.job == 'test'
|
||||
run: |
|
||||
export RAILPACK_VERSION=0.15.0
|
||||
export RAILPACK_VERSION=0.15.4
|
||||
curl -sSL https://railpack.com/install.sh | bash
|
||||
echo "Railpack installed $RAILPACK_VERSION"
|
||||
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -43,4 +43,7 @@ yarn-error.log*
|
||||
*.pem
|
||||
|
||||
|
||||
.db
|
||||
.db
|
||||
|
||||
# Development environment
|
||||
.devcontainer
|
||||
@@ -148,7 +148,7 @@ curl -sSL https://railpack.com/install.sh | sh
|
||||
|
||||
```bash
|
||||
# Install Buildpacks
|
||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v0.39.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||
```
|
||||
|
||||
## Pull Request
|
||||
|
||||
@@ -51,18 +51,18 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh --ver
|
||||
# Install Nixpacks and tsx
|
||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||
|
||||
ARG NIXPACKS_VERSION=1.39.0
|
||||
ARG NIXPACKS_VERSION=1.41.0
|
||||
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
||||
&& chmod +x install.sh \
|
||||
&& ./install.sh \
|
||||
&& pnpm install -g tsx
|
||||
|
||||
# Install Railpack
|
||||
ARG RAILPACK_VERSION=0.2.2
|
||||
ARG RAILPACK_VERSION=0.15.4
|
||||
RUN curl -sSL https://railpack.com/install.sh | bash
|
||||
|
||||
# Install buildpacks
|
||||
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
|
||||
COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
|
||||
|
||||
EXPOSE 3000
|
||||
CMD [ "pnpm", "start" ]
|
||||
|
||||
@@ -60,4 +60,4 @@ RUN curl https://rclone.org/install.sh | bash
|
||||
RUN pnpm install -g tsx
|
||||
|
||||
EXPOSE 3000
|
||||
CMD [ "pnpm", "start" ]
|
||||
CMD [ "pnpm", "start" ]
|
||||
|
||||
@@ -35,4 +35,4 @@ COPY --from=build /prod/schedules/dist ./dist
|
||||
COPY --from=build /prod/schedules/package.json ./package.json
|
||||
COPY --from=build /prod/schedules/node_modules ./node_modules
|
||||
|
||||
CMD HOSTNAME=0.0.0.0 && pnpm start
|
||||
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/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");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -15,12 +20,4 @@ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
## Additional Terms for Specific Features
|
||||
|
||||
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
|
||||
|
||||
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
|
||||
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
|
||||
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
|
||||
|
||||
For further inquiries or permissions, please contact us directly.
|
||||
|
||||
11
LICENSE_PROPRIETARY.md
Normal file
11
LICENSE_PROPRIETARY.md
Normal file
@@ -0,0 +1,11 @@
|
||||
The Dokploy Source Available license (DSAL) version 1.0
|
||||
|
||||
Copyright (c) 2026-present Dokploy Technology, Inc.
|
||||
|
||||
With regard to the Dokploy Software:This software and associated documentation files (the "Software") may only beused in production, if you (and any entity that you represent) have agreed to, and are in compliance with, a valid commercial agreement from Dokploy.Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software. You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid Dokploy Source Available License. Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes, without requiring a subscription. You agree that Dokploy and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications. You are not granted any other rights beyond what is expressly stated herein. Subject to theforegoing, it is forbidden to copy, merge, publish, distribute, sublicense,and/or sell the Software.
|
||||
|
||||
This Dokploy Source Available license applies only to the part of this Software that is in a /proprietary folder. The full text of this License shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.
|
||||
|
||||
For all third party components incorporated into the Dokploy Software, thosecomponents are licensed under the original license provided by the owner of the applicable component.
|
||||
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)
|
||||
|
||||
<!-- Hero Sponsors 🎖 -->
|
||||
## Sponsors
|
||||
|
||||
<!-- Add Hero Sponsors here -->
|
||||
|
||||
### Hero Sponsors 🎖
|
||||
|
||||
<div>
|
||||
<a href="https://www.hostinger.com/vps-hosting?ref=dokploy"><img src=".github/sponsors/hostinger.jpg" alt="Hostinger" width="300"/></a>
|
||||
<a href="https://www.lxaer.com/?ref=dokploy"><img src=".github/sponsors/lxaer.png" alt="LX Aer" width="100"/></a>
|
||||
<a href="https://www.lambdatest.com/?utm_source=dokploy&utm_medium=sponsor" target="_blank">
|
||||
<img src="https://www.lambdatest.com/blue-logo.png" width="450" height="100" />
|
||||
</a>
|
||||
<a href="https://awesome.tools/" target="_blank">
|
||||
<img src=".github/sponsors/awesome.png" width="200" height="150" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Premium Supporters 🥇 -->
|
||||
|
||||
<!-- Add Premium Supporters here -->
|
||||
|
||||
### Premium Supporters 🥇
|
||||
|
||||
<div>
|
||||
<a href="https://supafort.com/?ref=dokploy"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="300"/></a>
|
||||
<a href="https://agentdock.ai/?ref=dokploy"><img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/></a>
|
||||
</div>
|
||||
|
||||
<!-- Elite Contributors 🥈 -->
|
||||
|
||||
<!-- Add Elite Contributors here -->
|
||||
|
||||
### Elite Contributors 🥈
|
||||
|
||||
<div>
|
||||
<a href="https://americancloud.com/?ref=dokploy"><img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="300"/></a>
|
||||
<a href="https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy"><img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/></a>
|
||||
</div>
|
||||
|
||||
### Supporting Members 🥉
|
||||
|
||||
<div>
|
||||
|
||||
<a href="https://cloudblast.io/?ref=dokploy"><img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" width="250px" alt="Cloudblast.io"/></a>
|
||||
|
||||
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
|
||||
</div>
|
||||
| Sponsor | Logo | Supporter Level |
|
||||
|---------|:----:|----------------|
|
||||
| [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 |
|
||||
| [LinkDR](https://linkdr.com/?ref=dokploy) | <img src="https://dokploy.com/linkdr-logo.svg" alt="LinkDR" width="100"/> | 🎖 Hero Sponsor |
|
||||
| [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 |
|
||||
| [Awesome Tools](https://awesome.tools/) | <img src=".github/sponsors/awesome.png" alt="Awesome Tools" width="100"/> | 🎖 Hero Sponsor |
|
||||
| [Supafort](https://supafort.com/?ref=dokploy) | <img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="200"/> | 🥇 Premium Supporter |
|
||||
| [Agentdock](https://agentdock.ai/?ref=dokploy) | <img src=".github/sponsors/agentdock.png" alt="agentdock.ai" width="100"/> | 🥇 Premium Supporter |
|
||||
| [AmericanCloud](https://americancloud.com/?ref=dokploy) | <img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" width="200"/> | 🥈 Elite Contributor |
|
||||
| [Tolgee](https://tolgee.io/?utm_source=github_dokploy&utm_medium=banner&utm_campaign=dokploy) | <img src="https://dokploy.com/tolgee-logo.png" alt="Tolgee" width="100"/> | 🥈 Elite Contributor |
|
||||
| [Cloudblast](https://cloudblast.io/?ref=dokploy) | <img src="https://cloudblast.io/img/logo-icon.193cf13e.svg" alt="Cloudblast.io" width="150"/> | 🥉 Supporting Member |
|
||||
| [Synexa](https://synexa.ai/?ref=dokploy) | <img src=".github/sponsors/synexa.png" alt="Synexa" width="100"/> | 🥉 Supporting Member |
|
||||
|
||||
### Community Backers 🤝
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/zod-validator": "0.3.0",
|
||||
"@nerimity/mimiqueue": "1.2.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"hono": "^4.7.10",
|
||||
"pino": "9.4.0",
|
||||
|
||||
@@ -25,7 +25,7 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
|
||||
titleLog: z.string().optional(),
|
||||
descriptionLog: z.string().optional(),
|
||||
server: z.boolean().optional(),
|
||||
type: z.enum(["deploy"]),
|
||||
type: z.enum(["deploy", "redeploy"]),
|
||||
applicationType: z.literal("application-preview"),
|
||||
serverId: z.string().min(1),
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
deployPreviewApplication,
|
||||
rebuildApplication,
|
||||
rebuildCompose,
|
||||
rebuildPreviewApplication,
|
||||
updateApplicationStatus,
|
||||
updateCompose,
|
||||
updatePreviewDeployment,
|
||||
@@ -54,7 +55,14 @@ export const deploy = async (job: DeployJob) => {
|
||||
previewStatus: "running",
|
||||
});
|
||||
if (job.server) {
|
||||
if (job.type === "deploy") {
|
||||
if (job.type === "redeploy") {
|
||||
await rebuildPreviewApplication({
|
||||
applicationId: job.applicationId,
|
||||
titleLog: job.titleLog || "Rebuild Preview Deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
previewDeploymentId: job.previewDeploymentId,
|
||||
});
|
||||
} else if (job.type === "deploy") {
|
||||
await deployPreviewApplication({
|
||||
applicationId: job.applicationId,
|
||||
titleLog: job.titleLog || "Preview Deployment",
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@dokploy-postgres:5432/dokploy"
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
@@ -206,4 +206,38 @@ describe("getRegistryTag", () => {
|
||||
expect(result).toBe("docker.io/myuser/repo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("special characters in username", () => {
|
||||
it("should handle Harbor robot account username with $ (e.g. robot$library+dokploy)", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "robot$library+dokploy",
|
||||
});
|
||||
const result = getRegistryTag(registry, "nginx");
|
||||
expect(result).toBe("docker.io/robot$library+dokploy/nginx");
|
||||
});
|
||||
|
||||
it("should handle username with $ and other special characters", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "robot$test+app",
|
||||
});
|
||||
const result = getRegistryTag(registry, "myapp:latest");
|
||||
expect(result).toBe("docker.io/robot$test+app/myapp:latest");
|
||||
});
|
||||
|
||||
it("should handle username with multiple $ symbols", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "user$name$test",
|
||||
});
|
||||
const result = getRegistryTag(registry, "app");
|
||||
expect(result).toBe("docker.io/user$name$test/app");
|
||||
});
|
||||
|
||||
it("should handle username with + and - symbols", () => {
|
||||
const registry = createMockRegistry({
|
||||
username: "robot+test-user",
|
||||
});
|
||||
const result = getRegistryTag(registry, "nginx:latest");
|
||||
expect(result).toBe("docker.io/robot+test-user/nginx:latest");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Domain } from "@dokploy/server";
|
||||
import { createDomainLabels } from "@dokploy/server";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
/**
|
||||
* Regression tests for Traefik Host rule label format.
|
||||
|
||||
@@ -25,7 +25,7 @@ if (typeof window === "undefined") {
|
||||
}
|
||||
|
||||
const baseApp: ApplicationNested = {
|
||||
railpackVersion: "0.2.2",
|
||||
railpackVersion: "0.15.4",
|
||||
applicationId: "",
|
||||
previewLabels: [],
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,21 +5,27 @@ vi.mock("node:fs", () => ({
|
||||
default: fs,
|
||||
}));
|
||||
|
||||
import type { FileConfig, User } from "@dokploy/server";
|
||||
import type { FileConfig } from "@dokploy/server";
|
||||
import {
|
||||
createDefaultServerTraefikConfig,
|
||||
loadOrCreateConfig,
|
||||
updateServerTraefik,
|
||||
} from "@dokploy/server";
|
||||
import type { webServerSettings } from "@dokploy/server/db/schema";
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
const baseAdmin: User = {
|
||||
type WebServerSettings = typeof webServerSettings.$inferSelect;
|
||||
|
||||
const baseSettings: WebServerSettings = {
|
||||
id: "",
|
||||
https: false,
|
||||
enablePaidFeatures: false,
|
||||
allowImpersonation: false,
|
||||
role: "user",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
certificateType: "none",
|
||||
host: null,
|
||||
serverIp: null,
|
||||
letsEncryptEmail: null,
|
||||
sshPrivateKey: null,
|
||||
enableDockerCleanup: false,
|
||||
logCleanupCron: null,
|
||||
metricsConfig: {
|
||||
containers: {
|
||||
refreshRate: 20,
|
||||
@@ -45,29 +51,8 @@ const baseAdmin: User = {
|
||||
cleanupCacheApplications: false,
|
||||
cleanupCacheOnCompose: false,
|
||||
cleanupCacheOnPreviews: false,
|
||||
createdAt: new Date(),
|
||||
serverIp: null,
|
||||
certificateType: "none",
|
||||
host: null,
|
||||
letsEncryptEmail: null,
|
||||
sshPrivateKey: null,
|
||||
enableDockerCleanup: false,
|
||||
logCleanupCron: null,
|
||||
serversQuantity: 0,
|
||||
stripeCustomerId: "",
|
||||
stripeSubscriptionId: "",
|
||||
banExpires: new Date(),
|
||||
banned: true,
|
||||
banReason: "",
|
||||
email: "",
|
||||
expirationDate: "",
|
||||
id: "",
|
||||
isRegistered: false,
|
||||
createdAt2: new Date().toISOString(),
|
||||
emailVerified: false,
|
||||
image: "",
|
||||
createdAt: null,
|
||||
updatedAt: new Date(),
|
||||
twoFactorEnabled: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -85,7 +70,7 @@ test("Should read the configuration file", () => {
|
||||
test("Should apply redirect-to-https", () => {
|
||||
updateServerTraefik(
|
||||
{
|
||||
...baseAdmin,
|
||||
...baseSettings,
|
||||
https: true,
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
@@ -100,7 +85,7 @@ test("Should apply redirect-to-https", () => {
|
||||
});
|
||||
|
||||
test("Should change only host when no certificate", () => {
|
||||
updateServerTraefik(baseAdmin, "example.com");
|
||||
updateServerTraefik(baseSettings, "example.com");
|
||||
|
||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||
|
||||
@@ -110,7 +95,7 @@ test("Should change only host when no certificate", () => {
|
||||
test("Should not touch config without host", () => {
|
||||
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
|
||||
|
||||
updateServerTraefik(baseAdmin, null);
|
||||
updateServerTraefik(baseSettings, null);
|
||||
|
||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||
|
||||
@@ -119,11 +104,14 @@ test("Should not touch config without host", () => {
|
||||
|
||||
test("Should remove websecure if https rollback to http", () => {
|
||||
updateServerTraefik(
|
||||
{ ...baseAdmin, certificateType: "letsencrypt" },
|
||||
{ ...baseSettings, certificateType: "letsencrypt" },
|
||||
"example.com",
|
||||
);
|
||||
|
||||
updateServerTraefik({ ...baseAdmin, certificateType: "none" }, "example.com");
|
||||
updateServerTraefik(
|
||||
{ ...baseSettings, certificateType: "none" },
|
||||
"example.com",
|
||||
);
|
||||
|
||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRouterConfig } from "@dokploy/server";
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
const baseApp: ApplicationNested = {
|
||||
railpackVersion: "0.2.2",
|
||||
railpackVersion: "0.15.4",
|
||||
rollbackActive: false,
|
||||
applicationId: "",
|
||||
previewLabels: [],
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
createConverter,
|
||||
NumberInputWithSteps,
|
||||
} from "@/components/ui/number-input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -30,6 +33,23 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const CPU_STEP = 0.25;
|
||||
const MEMORY_STEP_MB = 256;
|
||||
|
||||
const formatNumber = (value: number, decimals = 2): string =>
|
||||
Number.isInteger(value) ? String(value) : value.toFixed(decimals);
|
||||
|
||||
const cpuConverter = createConverter(1_000_000_000, (cpu) =>
|
||||
cpu <= 0 ? "" : `${formatNumber(cpu)} CPU`,
|
||||
);
|
||||
|
||||
const memoryConverter = createConverter(1024 * 1024, (mb) => {
|
||||
if (mb <= 0) return "";
|
||||
return mb >= 1024
|
||||
? `${formatNumber(mb / 1024)} GB`
|
||||
: `${formatNumber(mb)} MB`;
|
||||
});
|
||||
|
||||
const addResourcesSchema = z.object({
|
||||
memoryReservation: z.string().optional(),
|
||||
cpuLimit: z.string().optional(),
|
||||
@@ -51,6 +71,7 @@ interface Props {
|
||||
}
|
||||
|
||||
type AddResources = z.infer<typeof addResourcesSchema>;
|
||||
|
||||
export const ShowResources = ({ id, type }: Props) => {
|
||||
const queryMap = {
|
||||
postgres: () =>
|
||||
@@ -163,16 +184,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Memory hard limit in bytes. Example: 1GB =
|
||||
1073741824 bytes
|
||||
1073741824 bytes. Use +/- buttons to adjust by
|
||||
256 MB.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
<NumberInputWithSteps
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="1073741824 (1GB in bytes)"
|
||||
{...field}
|
||||
step={MEMORY_STEP_MB}
|
||||
converter={memoryConverter}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -198,16 +223,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Memory soft limit in bytes. Example: 256MB =
|
||||
268435456 bytes
|
||||
268435456 bytes. Use +/- buttons to adjust by 256
|
||||
MB.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
<NumberInputWithSteps
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="268435456 (256MB in bytes)"
|
||||
{...field}
|
||||
step={MEMORY_STEP_MB}
|
||||
converter={memoryConverter}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -234,17 +263,20 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<TooltipContent>
|
||||
<p>
|
||||
CPU quota in units of 10^-9 CPUs. Example: 2
|
||||
CPUs = 2000000000
|
||||
CPUs = 2000000000. Use +/- buttons to adjust by
|
||||
0.25 CPU.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input
|
||||
<NumberInputWithSteps
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="2000000000 (2 CPUs)"
|
||||
{...field}
|
||||
value={field.value?.toString() || ""}
|
||||
step={CPU_STEP}
|
||||
converter={cpuConverter}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -271,14 +303,21 @@ export const ShowResources = ({ id, type }: Props) => {
|
||||
<TooltipContent>
|
||||
<p>
|
||||
CPU shares (relative weight). Example: 1 CPU =
|
||||
1000000000
|
||||
1000000000. Use +/- buttons to adjust by 0.25
|
||||
CPU.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Input placeholder="1000000000 (1 CPU)" {...field} />
|
||||
<NumberInputWithSteps
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder="1000000000 (1 CPU)"
|
||||
step={CPU_STEP}
|
||||
converter={cpuConverter}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Cog } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -20,8 +20,39 @@ import {
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
// Railpack versions from https://github.com/railwayapp/railpack/releases
|
||||
export const RAILPACK_VERSIONS = [
|
||||
"0.15.4",
|
||||
"0.15.3",
|
||||
"0.15.2",
|
||||
"0.15.1",
|
||||
"0.15.0",
|
||||
"0.14.0",
|
||||
"0.13.0",
|
||||
"0.12.0",
|
||||
"0.11.0",
|
||||
"0.10.0",
|
||||
"0.9.2",
|
||||
"0.9.1",
|
||||
"0.9.0",
|
||||
"0.8.0",
|
||||
"0.7.0",
|
||||
"0.6.0",
|
||||
"0.5.0",
|
||||
"0.4.0",
|
||||
"0.3.0",
|
||||
"0.2.2",
|
||||
] as const;
|
||||
|
||||
export enum BuildType {
|
||||
dockerfile = "dockerfile",
|
||||
heroku_buildpacks = "heroku_buildpacks",
|
||||
@@ -65,7 +96,7 @@ const mySchema = z.discriminatedUnion("buildType", [
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal(BuildType.railpack),
|
||||
railpackVersion: z.string().nullable().default("0.2.2"),
|
||||
railpackVersion: z.string().nullable().default("0.15.4"),
|
||||
}),
|
||||
z.object({
|
||||
buildType: z.literal(BuildType.static),
|
||||
@@ -152,6 +183,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
});
|
||||
|
||||
const buildType = form.watch("buildType");
|
||||
const railpackVersion = form.watch("railpackVersion");
|
||||
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -163,9 +196,22 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
};
|
||||
|
||||
form.reset(resetData(typedData));
|
||||
|
||||
// Check if railpack version is manual (not in the predefined list)
|
||||
if (
|
||||
data.railpackVersion &&
|
||||
!RAILPACK_VERSIONS.includes(data.railpackVersion as any)
|
||||
) {
|
||||
setIsManualRailpackVersion(true);
|
||||
}
|
||||
}
|
||||
}, [data, form]);
|
||||
|
||||
// Hide builder section when Docker provider is selected
|
||||
if (data?.sourceType === "docker") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSubmit = async (data: AddTemplate) => {
|
||||
await mutateAsync({
|
||||
applicationId,
|
||||
@@ -186,7 +232,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
data.buildType === BuildType.static ? data.isStaticSpa : null,
|
||||
railpackVersion:
|
||||
data.buildType === BuildType.railpack
|
||||
? data.railpackVersion || "0.2.2"
|
||||
? data.railpackVersion || "0.15.4"
|
||||
: null,
|
||||
})
|
||||
.then(async () => {
|
||||
@@ -403,23 +449,88 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
|
||||
/>
|
||||
)}
|
||||
{buildType === BuildType.railpack && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="railpackVersion"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Railpack Version</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Railpack Version"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="railpackVersion"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Railpack Version</FormLabel>
|
||||
<FormControl>
|
||||
{isManualRailpackVersion ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="Enter custom version (e.g., 0.15.4)"
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsManualRailpackVersion(false);
|
||||
field.onChange("0.15.4");
|
||||
}}
|
||||
>
|
||||
Use predefined versions
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (value === "manual") {
|
||||
setIsManualRailpackVersion(true);
|
||||
field.onChange("");
|
||||
} else {
|
||||
field.onChange(value);
|
||||
}
|
||||
}}
|
||||
value={field.value ?? "0.15.4"}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Railpack version" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="manual">
|
||||
<span className="font-medium">
|
||||
✏️ Manual (Custom Version)
|
||||
</span>
|
||||
</SelectItem>
|
||||
{RAILPACK_VERSIONS.map((version) => (
|
||||
<SelectItem key={version} value={version}>
|
||||
v{version}
|
||||
{version === "0.15.4" && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-2 px-1 text-xs"
|
||||
>
|
||||
Latest
|
||||
</Badge>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Select a Railpack version or choose manual to enter a
|
||||
custom version.{" "}
|
||||
<a
|
||||
href="https://github.com/railwayapp/railpack/releases"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
View releases
|
||||
</a>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex w-full justify-end">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
|
||||
@@ -256,9 +256,9 @@ export const ShowDeployments = ({
|
||||
return (
|
||||
<div
|
||||
key={deployment.deploymentId}
|
||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||
className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-1 flex-col min-w-0">
|
||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||
{index + 1}. {deployment.status}
|
||||
<StatusTooltip
|
||||
@@ -313,8 +313,8 @@ export const ShowDeployments = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 max-w-[300px] w-full justify-start">
|
||||
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
||||
<div className="flex w-full flex-col items-start gap-2 sm:w-auto sm:max-w-[300px] sm:items-end sm:justify-start">
|
||||
<div className="text-sm capitalize text-muted-foreground flex flex-wrap items-center gap-2">
|
||||
<DateTooltip date={deployment.createdAt} />
|
||||
{deployment.startedAt && deployment.finishedAt && (
|
||||
<Badge
|
||||
@@ -333,7 +333,7 @@ export const ShowDeployments = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-end">
|
||||
{deployment.pid && deployment.status === "running" && (
|
||||
<DialogAction
|
||||
title="Kill Process"
|
||||
@@ -355,6 +355,7 @@ export const ShowDeployments = ({
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isLoading={isKillingProcess}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Kill Process
|
||||
</Button>
|
||||
@@ -364,6 +365,7 @@ export const ShowDeployments = ({
|
||||
onClick={() => {
|
||||
setActiveLog(deployment);
|
||||
}}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
@@ -405,6 +407,7 @@ export const ShowDeployments = ({
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isLoading={isRollingBack}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||
Rollback
|
||||
|
||||
@@ -232,9 +232,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
<FormItem className="md:col-span-2 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
{field.value.owner && field.value.repo && (
|
||||
{field.value.gitlabPathNamespace && (
|
||||
<Link
|
||||
href={`${gitlabUrl}/${field.value.owner}/${field.value.repo}`}
|
||||
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import {
|
||||
ExternalLink,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
Hammer,
|
||||
Loader2,
|
||||
PenSquare,
|
||||
RocketIcon,
|
||||
@@ -22,6 +24,12 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
|
||||
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
|
||||
@@ -38,6 +46,9 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
const { mutateAsync: deletePreviewDeployment, isLoading } =
|
||||
api.previewDeployment.delete.useMutation();
|
||||
|
||||
const { mutateAsync: redeployPreviewDeployment } =
|
||||
api.previewDeployment.redeploy.useMutation();
|
||||
|
||||
const {
|
||||
data: previewDeployments,
|
||||
refetch: refetchPreviewDeployments,
|
||||
@@ -46,6 +57,8 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
{ applicationId },
|
||||
{
|
||||
enabled: !!applicationId,
|
||||
refetchInterval: (data) =>
|
||||
data?.some((d) => d.previewStatus === "running") ? 2000 : false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -193,6 +206,58 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
</Button>
|
||||
</ShowDeploymentsModal>
|
||||
|
||||
<DialogAction
|
||||
title="Rebuild Preview Deployment"
|
||||
description="Are you sure you want to rebuild this preview deployment?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await redeployPreviewDeployment({
|
||||
previewDeploymentId:
|
||||
deployment.previewDeploymentId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Preview deployment rebuild started",
|
||||
);
|
||||
refetchPreviewDeployments();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error rebuilding preview deployment",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
isLoading={status === "running"}
|
||||
className="gap-2"
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<Hammer className="size-4" />
|
||||
Rebuild
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent
|
||||
sideOffset={5}
|
||||
className="z-[60]"
|
||||
>
|
||||
<p>
|
||||
Rebuild the preview deployment without
|
||||
downloading new code
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
|
||||
<AddPreviewDomain
|
||||
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
||||
domainId={deployment.domain?.domainId}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -97,6 +97,16 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
const repository = form.watch("repository");
|
||||
const gitlabId = form.watch("gitlabId");
|
||||
|
||||
const gitlabUrl = useMemo(() => {
|
||||
const url = gitlabProviders?.find(
|
||||
(provider) => provider.gitlabId === gitlabId,
|
||||
)?.gitlabUrl;
|
||||
|
||||
const gitlabUrl = url?.replace(/\/$/, "");
|
||||
|
||||
return gitlabUrl || "https://gitlab.com";
|
||||
}, [gitlabId, gitlabProviders]);
|
||||
|
||||
const {
|
||||
data: repositories,
|
||||
isLoading: isLoadingRepositories,
|
||||
@@ -224,9 +234,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
<FormItem className="md:col-span-2 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Repository</FormLabel>
|
||||
{field.value.owner && field.value.repo && (
|
||||
{field.value.gitlabPathNamespace && (
|
||||
<Link
|
||||
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
|
||||
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
|
||||
|
||||
@@ -108,7 +108,8 @@ export const getLogType = (message: string): LogStyle => {
|
||||
/(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) ||
|
||||
/(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) ||
|
||||
/\b(?:deprecated|obsolete)\b/i.test(lowerMessage) ||
|
||||
/\b(?:unstable|experimental)\b/i.test(lowerMessage)
|
||||
/\b(?:unstable|experimental)\b/i.test(lowerMessage) ||
|
||||
/⚠|⚠️/i.test(lowerMessage)
|
||||
) {
|
||||
return LOG_STYLES.warning;
|
||||
}
|
||||
|
||||
@@ -559,6 +559,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
type="password"
|
||||
placeholder="******************"
|
||||
autoComplete="one-time-code"
|
||||
enablePasswordGenerator={true}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -578,6 +579,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="******************"
|
||||
enablePasswordGenerator={true}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -288,9 +288,12 @@ export const ShowProjects = () => {
|
||||
)
|
||||
.some(Boolean);
|
||||
|
||||
const productionEnvironment = project?.environments.find(
|
||||
(env) => env.isDefault,
|
||||
);
|
||||
// Find default environment from accessible environments, or fall back to first accessible environment
|
||||
const accessibleEnvironment =
|
||||
project?.environments.find((env) => env.isDefault) ||
|
||||
project?.environments?.[0];
|
||||
|
||||
const hasNoEnvironments = !accessibleEnvironment;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -298,7 +301,16 @@ export const ShowProjects = () => {
|
||||
className="w-full lg:max-w-md"
|
||||
>
|
||||
<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">
|
||||
{haveServicesWithDomains ? (
|
||||
@@ -419,7 +431,7 @@ export const ShowProjects = () => {
|
||||
) : null}
|
||||
<CardHeader>
|
||||
<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">
|
||||
<BookIcon className="size-4 text-muted-foreground" />
|
||||
<span className="text-base font-medium leading-none">
|
||||
@@ -427,9 +439,19 @@ export const ShowProjects = () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
<span className="text-sm font-medium text-muted-foreground break-all">
|
||||
{project.description}
|
||||
</span>
|
||||
|
||||
{hasNoEnvironments && (
|
||||
<div className="flex flex-row gap-2 items-center rounded-lg bg-yellow-50 p-2 mt-2 dark:bg-yellow-950">
|
||||
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-400 shrink-0" />
|
||||
<span className="text-xs text-yellow-600 dark:text-yellow-400">
|
||||
You have access to this project but no
|
||||
environments are available
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex self-start space-x-1">
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -89,7 +89,7 @@ export const SearchCommand = () => {
|
||||
<CommandGroup heading={"Projects"}>
|
||||
<CommandList>
|
||||
{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 =
|
||||
project.environments.find(
|
||||
(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,
|
||||
CheckIcon,
|
||||
CreditCard,
|
||||
FileText,
|
||||
Loader2,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -37,7 +39,22 @@ export const calculatePrice = (count: number, isAnnual = false) => {
|
||||
if (count <= 1) return 4.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 = () => {
|
||||
const router = useRouter();
|
||||
const { data: servers } = api.server.count.useQuery();
|
||||
const { data: admin } = api.user.get.useQuery();
|
||||
const { data, isLoading } = api.stripe.getProducts.useQuery();
|
||||
@@ -76,17 +93,41 @@ export const ShowBilling = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<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</CardDescription>
|
||||
<CardDescription>
|
||||
Manage your subscription and invoices
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<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="flex flex-col gap-4 w-full mt-6">
|
||||
<Tabs
|
||||
defaultValue="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 { Loader2, User } from "lucide-react";
|
||||
import { Loader2, Palette, User } from "lucide-react";
|
||||
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 { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getAvatarType, isSolidColorAvatar } from "@/lib/avatar-utils";
|
||||
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { Configure2FA } from "./configure-2fa";
|
||||
@@ -40,7 +41,7 @@ const profileSchema = z.object({
|
||||
password: z.string().nullable(),
|
||||
currentPassword: z.string().nullable(),
|
||||
image: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
allowImpersonation: z.boolean().optional().default(false),
|
||||
});
|
||||
@@ -74,6 +75,7 @@ export const ProfileForm = () => {
|
||||
} = api.user.update.useMutation();
|
||||
const { t } = useTranslation("settings");
|
||||
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
|
||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const availableAvatars = useMemo(() => {
|
||||
if (gravatarHash === null) return randomImages;
|
||||
@@ -89,7 +91,7 @@ export const ProfileForm = () => {
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: "",
|
||||
allowImpersonation: data?.user?.allowImpersonation || false,
|
||||
name: data?.user?.firstName || "",
|
||||
firstName: data?.user?.firstName || "",
|
||||
lastName: data?.user?.lastName || "",
|
||||
},
|
||||
resolver: zodResolver(profileSchema),
|
||||
@@ -104,7 +106,7 @@ export const ProfileForm = () => {
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: form.getValues("currentPassword") || "",
|
||||
allowImpersonation: data?.user?.allowImpersonation,
|
||||
name: data?.user?.firstName || "",
|
||||
firstName: data?.user?.firstName || "",
|
||||
lastName: data?.user?.lastName || "",
|
||||
},
|
||||
{
|
||||
@@ -129,7 +131,7 @@ export const ProfileForm = () => {
|
||||
image: values.image,
|
||||
currentPassword: values.currentPassword || undefined,
|
||||
allowImpersonation: values.allowImpersonation,
|
||||
name: values.name || undefined,
|
||||
firstName: values.firstName || undefined,
|
||||
lastName: values.lastName || undefined,
|
||||
});
|
||||
await refetch();
|
||||
@@ -139,7 +141,7 @@ export const ProfileForm = () => {
|
||||
password: "",
|
||||
image: values.image,
|
||||
currentPassword: "",
|
||||
name: values.name || "",
|
||||
firstName: values.firstName || "",
|
||||
lastName: values.lastName || "",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -182,7 +184,7 @@ export const ProfileForm = () => {
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
@@ -274,16 +276,8 @@ export const ProfileForm = () => {
|
||||
onValueChange={(e) => {
|
||||
field.onChange(e);
|
||||
}}
|
||||
defaultValue={
|
||||
field.value?.startsWith("data:")
|
||||
? "upload"
|
||||
: field.value
|
||||
}
|
||||
value={
|
||||
field.value?.startsWith("data:")
|
||||
? "upload"
|
||||
: field.value
|
||||
}
|
||||
defaultValue={getAvatarType(field.value)}
|
||||
value={getAvatarType(field.value)}
|
||||
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
|
||||
>
|
||||
<FormItem key="no-avatar">
|
||||
@@ -370,6 +364,40 @@ export const ProfileForm = () => {
|
||||
/>
|
||||
</FormLabel>
|
||||
</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) => (
|
||||
<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">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Activity } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { ShowStorageActions } from "./show-storage-actions";
|
||||
import { ShowTraefikActions } from "./show-traefik-actions";
|
||||
|
||||
@@ -7,9 +7,12 @@ interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
const { data, refetch } = api.user.get.useQuery(undefined, {
|
||||
enabled: !serverId,
|
||||
});
|
||||
const { data, refetch } = api.settings.getWebServerSettings.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: !serverId,
|
||||
},
|
||||
);
|
||||
|
||||
const { data: server, refetch: refetchServer } = api.server.one.useQuery(
|
||||
{
|
||||
@@ -22,7 +25,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
|
||||
const enabled = serverId
|
||||
? server?.enableDockerCleanup
|
||||
: data?.user.enableDockerCleanup;
|
||||
: data?.enableDockerCleanup;
|
||||
|
||||
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
|
||||
|
||||
@@ -30,7 +33,10 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
try {
|
||||
await mutateAsync({
|
||||
enableDockerCleanup: checked,
|
||||
serverId: serverId,
|
||||
...(serverId && { serverId }),
|
||||
} as {
|
||||
enableDockerCleanup: boolean;
|
||||
serverId?: string;
|
||||
});
|
||||
if (serverId) {
|
||||
await refetchServer();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon, Pencil } from "lucide-react";
|
||||
import { Pencil, PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -80,7 +80,7 @@ const Schema = z.object({
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
|
||||
export const SetupMonitoring = ({ serverId }: Props) => {
|
||||
const { data } = serverId
|
||||
const { data: serverData } = serverId
|
||||
? api.server.one.useQuery(
|
||||
{
|
||||
serverId: serverId || "",
|
||||
@@ -89,7 +89,14 @@ export const SetupMonitoring = ({ serverId }: Props) => {
|
||||
enabled: !!serverId,
|
||||
},
|
||||
)
|
||||
: api.user.getServerMetrics.useQuery();
|
||||
: { data: null };
|
||||
|
||||
const { data: webServerSettings } =
|
||||
api.settings.getWebServerSettings.useQuery(undefined, {
|
||||
enabled: !serverId,
|
||||
});
|
||||
|
||||
const data = serverId ? serverData : webServerSettings;
|
||||
|
||||
const url = useUrl();
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
@@ -89,15 +88,15 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
<Button
|
||||
className="w-full cursor-pointer "
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
Setup Server
|
||||
</DropdownMenuItem>
|
||||
Setup Server <Settings className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
<DialogContent className="sm:max-w-4xl ">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
Clock,
|
||||
Key,
|
||||
KeyIcon,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
ServerIcon,
|
||||
Clock,
|
||||
User,
|
||||
Key,
|
||||
Network,
|
||||
ServerIcon,
|
||||
Terminal,
|
||||
Settings,
|
||||
Pencil,
|
||||
Trash2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -31,9 +29,7 @@ import {
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -285,7 +281,32 @@ export const ShowServers = () => {
|
||||
|
||||
{/* Compact Actions */}
|
||||
{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>
|
||||
{server.sshKeyId && (
|
||||
<Tooltip>
|
||||
@@ -311,20 +332,6 @@ export const ShowServers = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<SetupServer
|
||||
serverId={server.serverId}
|
||||
asButton={true}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Setup Server</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
|
||||
@@ -67,7 +67,7 @@ type AddServerDomain = z.infer<typeof addServerDomain>;
|
||||
|
||||
export const WebDomain = () => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { data, refetch } = api.user.get.useQuery();
|
||||
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||
const { mutateAsync, isLoading } =
|
||||
api.settings.assignDomainServer.useMutation();
|
||||
|
||||
@@ -82,15 +82,15 @@ export const WebDomain = () => {
|
||||
});
|
||||
const https = form.watch("https");
|
||||
const domain = form.watch("domain") || "";
|
||||
const host = data?.user?.host || "";
|
||||
const host = data?.host || "";
|
||||
const hasChanged = domain !== host;
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
domain: data?.user?.host || "",
|
||||
certificateType: data?.user?.certificateType,
|
||||
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
|
||||
https: data?.user?.https || false,
|
||||
domain: data?.host || "",
|
||||
certificateType: data?.certificateType || "none",
|
||||
letsEncryptEmail: data?.letsEncryptEmail || "",
|
||||
https: data?.https || false,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
@@ -16,7 +16,8 @@ import { UpdateServer } from "./web-server/update-server";
|
||||
|
||||
export const WebServer = () => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { data } = api.user.get.useQuery();
|
||||
const { data: webServerSettings } =
|
||||
api.settings.getWebServerSettings.useQuery();
|
||||
|
||||
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||
|
||||
@@ -53,7 +54,7 @@ export const WebServer = () => {
|
||||
|
||||
<div className="flex items-center flex-wrap justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Server IP: {data?.user.serverIp}
|
||||
Server IP: {webServerSettings?.serverIp}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Version: {dokployVersion}
|
||||
|
||||
@@ -46,15 +46,15 @@ interface Props {
|
||||
export const UpdateServerIp = ({ children }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data } = api.user.get.useQuery();
|
||||
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
|
||||
const { data: ip } = api.server.publicIp.useQuery();
|
||||
|
||||
const { mutateAsync, isLoading, error, isError } =
|
||||
api.user.update.useMutation();
|
||||
api.settings.updateServerIp.useMutation();
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
serverIp: data?.user.serverIp || "",
|
||||
serverIp: data?.serverIp || "",
|
||||
},
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
@@ -62,13 +62,11 @@ export const UpdateServerIp = ({ children }: Props) => {
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
serverIp: data.user.serverIp || "",
|
||||
serverIp: data.serverIp || "",
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const setCurrentIp = () => {
|
||||
if (!ip) return;
|
||||
form.setValue("serverIp", ip);
|
||||
@@ -80,7 +78,7 @@ export const UpdateServerIp = ({ children }: Props) => {
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Server IP Updated");
|
||||
await utils.user.get.invalidate();
|
||||
await refetch();
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
Forward,
|
||||
GalleryVerticalEnd,
|
||||
GitBranch,
|
||||
HeartIcon,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
type LucideIcon,
|
||||
@@ -410,18 +409,6 @@ const MENU: Menu = {
|
||||
url: "https://discord.gg/2tBnJ3jDJc",
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Fragment } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -10,7 +10,7 @@ export const ToggleVisibilityInput = ({ ...props }: InputProps) => {
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<Input ref={inputRef} type={"password"} {...props} />
|
||||
<Input ref={inputRef} {...props} type="password" />
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import * as React from "react";
|
||||
|
||||
import { isSolidColorAvatar } from "@/lib/avatar-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
@@ -20,14 +20,33 @@ Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> & {
|
||||
src?: string | null;
|
||||
}
|
||||
>(({ className, src, ...props }, ref) => {
|
||||
if (isSolidColorAvatar(src)) {
|
||||
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;
|
||||
|
||||
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 { generateRandomPassword } from "@/lib/password-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
errorMessage?: string;
|
||||
enablePasswordGenerator?: boolean;
|
||||
passwordGeneratorLength?: number;
|
||||
}
|
||||
|
||||
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 inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const isPassword = type === "password";
|
||||
const shouldShowGenerator =
|
||||
isPassword &&
|
||||
enablePasswordGenerator !== false &&
|
||||
!props.disabled &&
|
||||
!props.readOnly;
|
||||
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 (
|
||||
<>
|
||||
<div className="relative w-full">
|
||||
@@ -21,25 +78,39 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
className={cn(
|
||||
// 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",
|
||||
isPassword && "pr-10", // Add padding for the eye icon
|
||||
isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"),
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
ref={setRefs}
|
||||
{...props}
|
||||
/>
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground focus:outline-none"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOffIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground">
|
||||
{shouldShowGenerator && (
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-foreground focus:outline-none"
|
||||
onClick={handleGeneratePassword}
|
||||
aria-label="Generate password"
|
||||
title="Generate password"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<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>
|
||||
{errorMessage && (
|
||||
|
||||
84
apps/dokploy/components/ui/number-input.tsx
Normal file
84
apps/dokploy/components/ui/number-input.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { MinusIcon, PlusIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export interface UnitConverter {
|
||||
toValue: (raw: string | undefined) => number;
|
||||
fromValue: (value: number) => string;
|
||||
formatDisplay: (value: number) => string;
|
||||
}
|
||||
|
||||
export const createConverter = (
|
||||
multiplier: number,
|
||||
formatDisplay: (value: number) => string,
|
||||
): UnitConverter => ({
|
||||
toValue: (raw) => {
|
||||
if (!raw) return 0;
|
||||
const value = Number.parseInt(raw, 10);
|
||||
return Number.isNaN(value) ? 0 : value / multiplier;
|
||||
},
|
||||
fromValue: (value) =>
|
||||
value <= 0 ? "" : String(Math.round(value * multiplier)),
|
||||
formatDisplay,
|
||||
});
|
||||
|
||||
interface NumberInputWithStepsProps {
|
||||
value: string | undefined;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
step: number;
|
||||
converter: UnitConverter;
|
||||
}
|
||||
|
||||
export const NumberInputWithSteps = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
step,
|
||||
converter,
|
||||
}: NumberInputWithStepsProps) => {
|
||||
const numericValue = converter.toValue(value);
|
||||
const displayValue = converter.formatDisplay(numericValue);
|
||||
|
||||
const handleIncrement = () =>
|
||||
onChange(converter.fromValue(numericValue + step));
|
||||
const handleDecrement = () =>
|
||||
onChange(converter.fromValue(Math.max(0, numericValue - step)));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={handleDecrement}
|
||||
disabled={numericValue <= 0}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="text-center"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={handleIncrement}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{displayValue && (
|
||||
<span className="text-xs text-muted-foreground text-center">
|
||||
{displayValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS "ai" (
|
||||
"aiId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"apiUrl" text NOT NULL,
|
||||
"apiKey" text NOT NULL,
|
||||
"model" text NOT NULL,
|
||||
"isEnabled" boolean DEFAULT true NOT NULL,
|
||||
"adminId" text NOT NULL,
|
||||
"createdAt" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "ai" ADD CONSTRAINT "ai_adminId_admin_adminId_fk" FOREIGN KEY ("adminId") REFERENCES "public"."admin"("adminId") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
114
apps/dokploy/drizzle/0133_striped_the_order.sql
Normal file
114
apps/dokploy/drizzle/0133_striped_the_order.sql
Normal file
@@ -0,0 +1,114 @@
|
||||
CREATE TABLE "webServerSettings" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"serverIp" text,
|
||||
"certificateType" "certificateType" DEFAULT 'none' NOT NULL,
|
||||
"https" boolean DEFAULT false NOT NULL,
|
||||
"host" text,
|
||||
"letsEncryptEmail" text,
|
||||
"sshPrivateKey" text,
|
||||
"enableDockerCleanup" boolean DEFAULT true NOT NULL,
|
||||
"logCleanupCron" text DEFAULT '0 0 * * *',
|
||||
"metricsConfig" jsonb DEFAULT '{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb NOT NULL,
|
||||
"cleanupCacheApplications" boolean DEFAULT false NOT NULL,
|
||||
"cleanupCacheOnPreviews" boolean DEFAULT false NOT NULL,
|
||||
"cleanupCacheOnCompose" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp DEFAULT now(),
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
-- Migrate data from user table to webServerSettings
|
||||
-- Get the owner user's data and insert into webServerSettings
|
||||
INSERT INTO "webServerSettings" (
|
||||
"id",
|
||||
"serverIp",
|
||||
"certificateType",
|
||||
"https",
|
||||
"host",
|
||||
"letsEncryptEmail",
|
||||
"sshPrivateKey",
|
||||
"enableDockerCleanup",
|
||||
"logCleanupCron",
|
||||
"metricsConfig",
|
||||
"cleanupCacheApplications",
|
||||
"cleanupCacheOnPreviews",
|
||||
"cleanupCacheOnCompose",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid()::text as "id",
|
||||
u."serverIp",
|
||||
COALESCE(u."certificateType", 'none') as "certificateType",
|
||||
COALESCE(u."https", false) as "https",
|
||||
u."host",
|
||||
u."letsEncryptEmail",
|
||||
u."sshPrivateKey",
|
||||
COALESCE(u."enableDockerCleanup", true) as "enableDockerCleanup",
|
||||
COALESCE(u."logCleanupCron", '0 0 * * *') as "logCleanupCron",
|
||||
COALESCE(
|
||||
u."metricsConfig",
|
||||
'{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb
|
||||
) as "metricsConfig",
|
||||
COALESCE(u."cleanupCacheApplications", false) as "cleanupCacheApplications",
|
||||
COALESCE(u."cleanupCacheOnPreviews", false) as "cleanupCacheOnPreviews",
|
||||
COALESCE(u."cleanupCacheOnCompose", false) as "cleanupCacheOnCompose",
|
||||
NOW() as "created_at",
|
||||
NOW() as "updated_at"
|
||||
FROM "user" u
|
||||
INNER JOIN "member" m ON u."id" = m."user_id"
|
||||
WHERE m."role" = 'owner'
|
||||
ORDER BY m."created_at" ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- If no owner found, create a default entry
|
||||
INSERT INTO "webServerSettings" (
|
||||
"id",
|
||||
"serverIp",
|
||||
"certificateType",
|
||||
"https",
|
||||
"host",
|
||||
"letsEncryptEmail",
|
||||
"sshPrivateKey",
|
||||
"enableDockerCleanup",
|
||||
"logCleanupCron",
|
||||
"metricsConfig",
|
||||
"cleanupCacheApplications",
|
||||
"cleanupCacheOnPreviews",
|
||||
"cleanupCacheOnCompose",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid()::text as "id",
|
||||
NULL as "serverIp",
|
||||
'none' as "certificateType",
|
||||
false as "https",
|
||||
NULL as "host",
|
||||
NULL as "letsEncryptEmail",
|
||||
NULL as "sshPrivateKey",
|
||||
true as "enableDockerCleanup",
|
||||
'0 0 * * *' as "logCleanupCron",
|
||||
'{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb as "metricsConfig",
|
||||
false as "cleanupCacheApplications",
|
||||
false as "cleanupCacheOnPreviews",
|
||||
false as "cleanupCacheOnCompose",
|
||||
NOW() as "created_at",
|
||||
NOW() as "updated_at"
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "webServerSettings"
|
||||
);
|
||||
|
||||
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "serverIp";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "certificateType";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "https";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "host";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "letsEncryptEmail";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "sshPrivateKey";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "enableDockerCleanup";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "logCleanupCron";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "metricsConfig";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "cleanupCacheApplications";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "cleanupCacheOnPreviews";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "cleanupCacheOnCompose";
|
||||
1
apps/dokploy/drizzle/0134_strong_hercules.sql
Normal file
1
apps/dokploy/drizzle/0134_strong_hercules.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "application" ALTER COLUMN "railpackVersion" SET DEFAULT '0.15.4';
|
||||
6968
apps/dokploy/drizzle/meta/0133_snapshot.json
Normal file
6968
apps/dokploy/drizzle/meta/0133_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6968
apps/dokploy/drizzle/meta/0134_snapshot.json
Normal file
6968
apps/dokploy/drizzle/meta/0134_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -932,6 +932,20 @@
|
||||
"when": 1765346573500,
|
||||
"tag": "0132_clean_layla_miller",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 133,
|
||||
"version": "7",
|
||||
"when": 1766301478005,
|
||||
"tag": "0133_striped_the_order",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 134,
|
||||
"version": "7",
|
||||
"when": 1767871040249,
|
||||
"tag": "0134_strong_hercules",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
30
apps/dokploy/lib/avatar-utils.ts
Normal file
30
apps/dokploy/lib/avatar-utils.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Checks if the given avatar value represents a solid color in hexadecimal format.
|
||||
*
|
||||
* @param value Avatar value to check.
|
||||
*
|
||||
* @return True if the avatar is a solid color, false otherwise.
|
||||
*/
|
||||
export function isSolidColorAvatar(value?: string | null) {
|
||||
return (
|
||||
(value?.startsWith("#") && /^#[0-9A-Fa-f]{6}$/.test(value)) ||
|
||||
value?.startsWith("color:") ||
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the avatar type for form selection (RadioGroup value).
|
||||
*
|
||||
* @param value Avatar value.
|
||||
*
|
||||
* @return "upload" for base64 images, "color" for solid colors, or the original value for other types.
|
||||
*/
|
||||
export function getAvatarType(value?: string | null) {
|
||||
if (!value) return "";
|
||||
|
||||
if (value.startsWith("data:")) return "upload";
|
||||
if (isSolidColorAvatar(value)) return "color";
|
||||
|
||||
return value;
|
||||
}
|
||||
38
apps/dokploy/lib/password-utils.ts
Normal file
38
apps/dokploy/lib/password-utils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
const DEFAULT_PASSWORD_LENGTH = 20;
|
||||
const DEFAULT_PASSWORD_CHARSET =
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
|
||||
export const generateRandomPassword = (
|
||||
length: number = DEFAULT_PASSWORD_LENGTH,
|
||||
charset: string = DEFAULT_PASSWORD_CHARSET,
|
||||
) => {
|
||||
const safeLength =
|
||||
Number.isFinite(length) && length > 0
|
||||
? Math.floor(length)
|
||||
: DEFAULT_PASSWORD_LENGTH;
|
||||
|
||||
if (safeLength <= 0 || charset.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const cryptoApi =
|
||||
typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
|
||||
|
||||
if (!cryptoApi?.getRandomValues) {
|
||||
let fallback = "";
|
||||
for (let i = 0; i < safeLength; i += 1) {
|
||||
fallback += charset[Math.floor(Math.random() * charset.length)];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const values = new Uint32Array(safeLength);
|
||||
cryptoApi.getRandomValues(values);
|
||||
|
||||
let result = "";
|
||||
for (const value of values) {
|
||||
result += charset[value % charset.length];
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -1,10 +1,9 @@
|
||||
import { dbUrl } from "@dokploy/server/db";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||
import postgres from "postgres";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
|
||||
const sql = postgres(connectionString, { max: 1 });
|
||||
const sql = postgres(dbUrl, { max: 1 });
|
||||
const db = drizzle(sql);
|
||||
|
||||
await migrate(db, { migrationsFolder: "drizzle" })
|
||||
|
||||
@@ -19,6 +19,32 @@ const nextConfig = {
|
||||
locales: ["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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.26.3",
|
||||
"version": "v0.26.6",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -33,7 +33,7 @@
|
||||
"docker:build:canary": "./docker/build.sh canary",
|
||||
"docker:push:canary": "./docker/push.sh canary",
|
||||
"version": "echo $(node -p \"require('./package.json').version\")",
|
||||
"test": "vitest --config __test__/vitest.config.ts volume-backups",
|
||||
"test": "vitest --config __test__/vitest.config.ts",
|
||||
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -109,7 +109,6 @@
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"fancy-ansi": "^0.1.3",
|
||||
"hi-base32": "^0.5.1",
|
||||
"i18next": "^23.16.8",
|
||||
"input-otp": "^1.4.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
@@ -126,7 +125,6 @@
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.14",
|
||||
"octokit": "3.1.2",
|
||||
"otpauth": "^9.4.0",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
"postgres": "3.4.4",
|
||||
@@ -140,7 +138,6 @@
|
||||
"react-i18next": "^15.5.2",
|
||||
"react-markdown": "^9.1.0",
|
||||
"recharts": "^2.15.3",
|
||||
"rotating-file-stream": "3.2.3",
|
||||
"slugify": "^1.6.6",
|
||||
"sonner": "^1.7.4",
|
||||
"ssh2": "1.15.0",
|
||||
@@ -156,9 +153,11 @@
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"yaml": "2.8.1",
|
||||
"zod": "^3.25.32",
|
||||
"zod-form-data": "^2.0.7"
|
||||
"zod-form-data": "^2.0.7",
|
||||
"semver": "7.7.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
@@ -197,10 +196,5 @@
|
||||
"*": [
|
||||
"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}>
|
||||
<Button variant="outline">Project Environment</Button>
|
||||
</ProjectEnvironment>
|
||||
{(auth?.role === "owner" || auth?.canCreateServices) && (
|
||||
{(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canCreateServices) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button>
|
||||
@@ -1032,6 +1034,7 @@ const EnvironmentPage = (
|
||||
</Button>
|
||||
</DialogAction>
|
||||
{(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canDeleteServices) && (
|
||||
<>
|
||||
<DialogAction
|
||||
@@ -1621,9 +1624,39 @@ export async function getServerSideProps(
|
||||
projectId: params.projectId,
|
||||
});
|
||||
|
||||
await helpers.environment.one.fetch({
|
||||
environmentId: params.environmentId,
|
||||
});
|
||||
// Try to fetch the requested environment
|
||||
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({
|
||||
projectId: params.projectId,
|
||||
|
||||
@@ -108,6 +108,7 @@ const Service = (
|
||||
{ name: "Projects", href: "/dashboard/projects" },
|
||||
{
|
||||
name: data?.environment?.project?.name || "",
|
||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||
},
|
||||
{
|
||||
name: data?.environment?.name || "",
|
||||
@@ -192,7 +193,9 @@ const Service = (
|
||||
|
||||
<div className="flex flex-row gap-2 justify-end">
|
||||
<UpdateApplication applicationId={applicationId} />
|
||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
||||
{(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canDeleteServices) && (
|
||||
<DeleteService id={applicationId} type="application" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -97,6 +97,7 @@ const Service = (
|
||||
{ name: "Projects", href: "/dashboard/projects" },
|
||||
{
|
||||
name: data?.environment?.project?.name || "",
|
||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||
},
|
||||
{
|
||||
name: data?.environment?.name || "",
|
||||
@@ -182,7 +183,9 @@ const Service = (
|
||||
<div className="flex flex-row gap-2 justify-end">
|
||||
<UpdateCompose composeId={composeId} />
|
||||
|
||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
||||
{(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canDeleteServices) && (
|
||||
<DeleteService id={composeId} type="compose" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -79,6 +79,7 @@ const Mariadb = (
|
||||
{ name: "Projects", href: "/dashboard/projects" },
|
||||
{
|
||||
name: data?.environment?.project?.name || "",
|
||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||
},
|
||||
{
|
||||
name: data?.environment?.name || "",
|
||||
@@ -156,7 +157,9 @@ const Mariadb = (
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 justify-end">
|
||||
<UpdateMariadb mariadbId={mariadbId} />
|
||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
||||
{(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canDeleteServices) && (
|
||||
<DeleteService id={mariadbId} type="mariadb" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -78,6 +78,7 @@ const Mongo = (
|
||||
{ name: "Projects", href: "/dashboard/projects" },
|
||||
{
|
||||
name: data?.environment?.project?.name || "",
|
||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||
},
|
||||
{
|
||||
name: data?.environment?.name || "",
|
||||
@@ -155,7 +156,9 @@ const Mongo = (
|
||||
|
||||
<div className="flex flex-row gap-2 justify-end">
|
||||
<UpdateMongo mongoId={mongoId} />
|
||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
||||
{(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canDeleteServices) && (
|
||||
<DeleteService id={mongoId} type="mongo" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -77,6 +77,7 @@ const MySql = (
|
||||
{ name: "Projects", href: "/dashboard/projects" },
|
||||
{
|
||||
name: data?.environment?.project?.name || "",
|
||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||
},
|
||||
{
|
||||
name: data?.environment?.name || "",
|
||||
@@ -156,7 +157,9 @@ const MySql = (
|
||||
|
||||
<div className="flex flex-row gap-2 justify-end">
|
||||
<UpdateMysql mysqlId={mysqlId} />
|
||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
||||
{(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canDeleteServices) && (
|
||||
<DeleteService id={mysqlId} type="mysql" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -77,6 +77,7 @@ const Postgresql = (
|
||||
{ name: "Projects", href: "/dashboard/projects" },
|
||||
{
|
||||
name: data?.environment?.project?.name || "",
|
||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||
},
|
||||
{
|
||||
name: data?.environment?.name || "",
|
||||
@@ -154,7 +155,9 @@ const Postgresql = (
|
||||
|
||||
<div className="flex flex-row gap-2 justify-end">
|
||||
<UpdatePostgres postgresId={postgresId} />
|
||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
||||
{(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canDeleteServices) && (
|
||||
<DeleteService id={postgresId} type="postgres" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -77,6 +77,7 @@ const Redis = (
|
||||
{ name: "Projects", href: "/dashboard/projects" },
|
||||
{
|
||||
name: data?.environment?.project?.name || "",
|
||||
href: `/dashboard/project/${projectId}/environment/${environmentId}`,
|
||||
},
|
||||
{
|
||||
name: data?.environment?.name || "",
|
||||
@@ -154,7 +155,9 @@ const Redis = (
|
||||
|
||||
<div className="flex flex-row gap-2 justify-end">
|
||||
<UpdateRedis redisId={redisId} />
|
||||
{(auth?.role === "owner" || auth?.canDeleteServices) && (
|
||||
{(auth?.role === "owner" ||
|
||||
auth?.role === "admin" ||
|
||||
auth?.canDeleteServices) && (
|
||||
<DeleteService id={redisId} type="redis" />
|
||||
)}
|
||||
</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)
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
findUserById,
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
setupWebMonitoring,
|
||||
updateUser,
|
||||
updateWebServerSettings,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { apiUpdateWebServerMonitoring } from "@/server/db/schema";
|
||||
@@ -11,7 +11,7 @@ import { adminProcedure, createTRPCRouter } from "../trpc";
|
||||
export const adminRouter = createTRPCRouter({
|
||||
setupMonitoring: adminProcedure
|
||||
.input(apiUpdateWebServerMonitoring)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
if (IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
@@ -19,15 +19,8 @@ export const adminRouter = createTRPCRouter({
|
||||
message: "Feature disabled on cloud",
|
||||
});
|
||||
}
|
||||
const user = await findUserById(ctx.user.ownerId);
|
||||
if (user.id !== ctx.user.ownerId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to setup the monitoring",
|
||||
});
|
||||
}
|
||||
|
||||
await updateUser(user.id, {
|
||||
await updateWebServerSettings({
|
||||
metricsConfig: {
|
||||
server: {
|
||||
type: "Dokploy",
|
||||
@@ -52,8 +45,9 @@ export const adminRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
const currentServer = await setupWebMonitoring(user.id);
|
||||
return currentServer;
|
||||
await setupWebMonitoring();
|
||||
const settings = await getWebServerSettings();
|
||||
return settings;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,40 @@ export const aiRouter = createTRPCRouter({
|
||||
{ headers: {} },
|
||||
);
|
||||
break;
|
||||
case "perplexity":
|
||||
// Perplexity doesn't have a /models endpoint, return hardcoded list
|
||||
return [
|
||||
{
|
||||
id: "sonar-deep-research",
|
||||
object: "model",
|
||||
created: Date.now(),
|
||||
owned_by: "perplexity",
|
||||
},
|
||||
{
|
||||
id: "sonar-reasoning-pro",
|
||||
object: "model",
|
||||
created: Date.now(),
|
||||
owned_by: "perplexity",
|
||||
},
|
||||
{
|
||||
id: "sonar-reasoning",
|
||||
object: "model",
|
||||
created: Date.now(),
|
||||
owned_by: "perplexity",
|
||||
},
|
||||
{
|
||||
id: "sonar-pro",
|
||||
object: "model",
|
||||
created: Date.now(),
|
||||
owned_by: "perplexity",
|
||||
},
|
||||
{
|
||||
id: "sonar",
|
||||
object: "model",
|
||||
created: Date.now(),
|
||||
owned_by: "perplexity",
|
||||
},
|
||||
] as Model[];
|
||||
default:
|
||||
if (!input.apiKey)
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -285,6 +285,7 @@ export const backupRouter = createTRPCRouter({
|
||||
.mutation(async ({ input }) => {
|
||||
const backup = await findBackupById(input.backupId);
|
||||
await runWebServerBackup(backup);
|
||||
await keepLatestNBackups(backup);
|
||||
return true;
|
||||
}),
|
||||
listBackupFiles: protectedProcedure
|
||||
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
findGitProviderById,
|
||||
findProjectById,
|
||||
findServerById,
|
||||
findUserById,
|
||||
getComposeContainer,
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
loadServices,
|
||||
randomizeComposeFile,
|
||||
@@ -430,7 +430,11 @@ export const composeRouter = createTRPCRouter({
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
return { success: true, message: "Deployment queued" };
|
||||
return {
|
||||
success: true,
|
||||
message: "Deployment queued",
|
||||
composeId: compose.composeId,
|
||||
};
|
||||
}),
|
||||
redeploy: protectedProcedure
|
||||
.input(apiRedeployCompose)
|
||||
@@ -468,7 +472,11 @@ export const composeRouter = createTRPCRouter({
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
return { success: true, message: "Redeployment queued" };
|
||||
return {
|
||||
success: true,
|
||||
message: "Redeployment queued",
|
||||
composeId: compose.composeId,
|
||||
};
|
||||
}),
|
||||
stop: protectedProcedure
|
||||
.input(apiFindCompose)
|
||||
@@ -569,8 +577,7 @@ export const composeRouter = createTRPCRouter({
|
||||
|
||||
const template = await fetchTemplateFiles(input.id, input.baseUrl);
|
||||
|
||||
const admin = await findUserById(ctx.user.ownerId);
|
||||
let serverIp = admin.serverIp || "127.0.0.1";
|
||||
let serverIp = "127.0.0.1";
|
||||
|
||||
const project = await findProjectById(environment.projectId);
|
||||
|
||||
@@ -579,6 +586,9 @@ export const composeRouter = createTRPCRouter({
|
||||
serverIp = server.ipAddress;
|
||||
} else if (process.env.NODE_ENV === "development") {
|
||||
serverIp = "127.0.0.1";
|
||||
} else {
|
||||
const settings = await getWebServerSettings();
|
||||
serverIp = settings?.serverIp || "127.0.0.1";
|
||||
}
|
||||
|
||||
const projectName = slugify(`${project.name} ${input.id}`);
|
||||
@@ -803,14 +813,16 @@ export const composeRouter = createTRPCRouter({
|
||||
const decodedData = Buffer.from(input.base64, "base64").toString(
|
||||
"utf-8",
|
||||
);
|
||||
const admin = await findUserById(ctx.user.ownerId);
|
||||
let serverIp = admin.serverIp || "127.0.0.1";
|
||||
let serverIp = "127.0.0.1";
|
||||
|
||||
if (compose.serverId) {
|
||||
const server = await findServerById(compose.serverId);
|
||||
serverIp = server.ipAddress;
|
||||
} else if (process.env.NODE_ENV === "development") {
|
||||
serverIp = "127.0.0.1";
|
||||
} else {
|
||||
const settings = await getWebServerSettings();
|
||||
serverIp = settings?.serverIp || "127.0.0.1";
|
||||
}
|
||||
const templateData = JSON.parse(decodedData);
|
||||
const config = parse(templateData.config) as CompleteTemplate;
|
||||
@@ -880,14 +892,16 @@ export const composeRouter = createTRPCRouter({
|
||||
await removeDomainById(domain.domainId);
|
||||
}
|
||||
|
||||
const admin = await findUserById(ctx.user.ownerId);
|
||||
let serverIp = admin.serverIp || "127.0.0.1";
|
||||
let serverIp = "127.0.0.1";
|
||||
|
||||
if (compose.serverId) {
|
||||
const server = await findServerById(compose.serverId);
|
||||
serverIp = server.ipAddress;
|
||||
} else if (process.env.NODE_ENV === "development") {
|
||||
serverIp = "127.0.0.1";
|
||||
} else {
|
||||
const settings = await getWebServerSettings();
|
||||
serverIp = settings?.serverIp || "127.0.0.1";
|
||||
}
|
||||
|
||||
const templateData = JSON.parse(decodedData);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
findPreviewDeploymentById,
|
||||
findServerById,
|
||||
generateTraefikMeDomain,
|
||||
getWebServerSettings,
|
||||
manageDomain,
|
||||
removeDomain,
|
||||
removeDomainById,
|
||||
@@ -107,16 +108,13 @@ export const domainRouter = createTRPCRouter({
|
||||
}),
|
||||
canGenerateTraefikMeDomains: protectedProcedure
|
||||
.input(z.object({ serverId: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
const organization = await findOrganizationById(
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
|
||||
.query(async ({ input }) => {
|
||||
if (input.serverId) {
|
||||
const server = await findServerById(input.serverId);
|
||||
return server.ipAddress;
|
||||
}
|
||||
return organization?.owner.serverIp;
|
||||
const settings = await getWebServerSettings();
|
||||
return settings?.serverIp || "";
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
createSlackNotification,
|
||||
createTelegramNotification,
|
||||
findNotificationById,
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
removeNotificationById,
|
||||
sendCustomNotification,
|
||||
@@ -66,7 +67,6 @@ import {
|
||||
apiUpdateTelegram,
|
||||
notifications,
|
||||
server,
|
||||
user,
|
||||
} from "@/server/db/schema";
|
||||
|
||||
export const notificationRouter = createTRPCRouter({
|
||||
@@ -364,21 +364,20 @@ export const notificationRouter = createTRPCRouter({
|
||||
let organizationId = "";
|
||||
let ServerName = "";
|
||||
if (input.ServerType === "Dokploy") {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(
|
||||
sql`${user.metricsConfig}::jsonb -> 'server' ->> 'token' = ${input.Token}`,
|
||||
);
|
||||
|
||||
if (!result?.[0]?.id) {
|
||||
const settings = await getWebServerSettings();
|
||||
if (
|
||||
!settings?.metricsConfig?.server?.token ||
|
||||
settings.metricsConfig.server.token !== input.Token
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Token not found",
|
||||
});
|
||||
}
|
||||
|
||||
organizationId = result?.[0]?.id;
|
||||
// For Dokploy server type, we don't have a specific organizationId
|
||||
// This might need to be adjusted based on your business logic
|
||||
organizationId = "";
|
||||
ServerName = "Dokploy";
|
||||
} else {
|
||||
const result = await db
|
||||
|
||||
@@ -2,11 +2,15 @@ import {
|
||||
findApplicationById,
|
||||
findPreviewDeploymentById,
|
||||
findPreviewDeploymentsByApplicationId,
|
||||
IS_CLOUD,
|
||||
removePreviewDeployment,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
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";
|
||||
|
||||
export const previewDeploymentRouter = createTRPCRouter({
|
||||
@@ -60,4 +64,55 @@ export const previewDeploymentRouter = createTRPCRouter({
|
||||
}
|
||||
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;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -12,11 +12,11 @@ import {
|
||||
DEFAULT_UPDATE_DATA,
|
||||
execAsync,
|
||||
findServerById,
|
||||
findUserById,
|
||||
getDokployImage,
|
||||
getDokployImageTag,
|
||||
getLogCleanupStatus,
|
||||
getUpdateData,
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
parseRawConfig,
|
||||
paths,
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
updateLetsEncryptEmail,
|
||||
updateServerById,
|
||||
updateServerTraefik,
|
||||
updateUser,
|
||||
updateWebServerSettings,
|
||||
writeConfig,
|
||||
writeMainConfig,
|
||||
writeTraefikConfigInPath,
|
||||
@@ -77,11 +77,18 @@ import {
|
||||
} from "../trpc";
|
||||
|
||||
export const settingsRouter = createTRPCRouter({
|
||||
getWebServerSettings: protectedProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
return null;
|
||||
}
|
||||
const settings = await getWebServerSettings();
|
||||
return settings;
|
||||
}),
|
||||
reloadServer: adminProcedure.mutation(async () => {
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
}
|
||||
await reloadDockerResource("dokploy");
|
||||
await reloadDockerResource("dokploy", undefined, packageInfo.version);
|
||||
return true;
|
||||
}),
|
||||
cleanRedis: adminProcedure.mutation(async () => {
|
||||
@@ -209,11 +216,11 @@ export const settingsRouter = createTRPCRouter({
|
||||
}),
|
||||
saveSSHPrivateKey: adminProcedure
|
||||
.input(apiSaveSSHKey)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
.mutation(async ({ input }) => {
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
}
|
||||
await updateUser(ctx.user.ownerId, {
|
||||
await updateWebServerSettings({
|
||||
sshPrivateKey: input.sshPrivateKey,
|
||||
});
|
||||
|
||||
@@ -221,36 +228,36 @@ export const settingsRouter = createTRPCRouter({
|
||||
}),
|
||||
assignDomainServer: adminProcedure
|
||||
.input(apiAssignDomain)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
.mutation(async ({ input }) => {
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
}
|
||||
const user = await updateUser(ctx.user.ownerId, {
|
||||
const settings = await updateWebServerSettings({
|
||||
host: input.host,
|
||||
letsEncryptEmail: input.letsEncryptEmail,
|
||||
certificateType: input.certificateType,
|
||||
https: input.https,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
if (!settings) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
message: "Web server settings not found",
|
||||
});
|
||||
}
|
||||
|
||||
updateServerTraefik(user, input.host);
|
||||
updateServerTraefik(settings, input.host);
|
||||
if (input.letsEncryptEmail) {
|
||||
updateLetsEncryptEmail(input.letsEncryptEmail);
|
||||
}
|
||||
|
||||
return user;
|
||||
return settings;
|
||||
}),
|
||||
cleanSSHPrivateKey: adminProcedure.mutation(async ({ ctx }) => {
|
||||
cleanSSHPrivateKey: adminProcedure.mutation(async () => {
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
}
|
||||
await updateUser(ctx.user.ownerId, {
|
||||
await updateWebServerSettings({
|
||||
sshPrivateKey: null,
|
||||
});
|
||||
return true;
|
||||
@@ -310,11 +317,11 @@ export const settingsRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
} else if (!IS_CLOUD) {
|
||||
const userUpdated = await updateUser(ctx.user.ownerId, {
|
||||
const settingsUpdated = await updateWebServerSettings({
|
||||
enableDockerCleanup: input.enableDockerCleanup,
|
||||
});
|
||||
|
||||
if (userUpdated?.enableDockerCleanup) {
|
||||
if (settingsUpdated?.enableDockerCleanup) {
|
||||
scheduleJob("docker-cleanup", "0 0 * * *", async () => {
|
||||
console.log(
|
||||
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
||||
@@ -392,7 +399,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
return DEFAULT_UPDATE_DATA;
|
||||
}
|
||||
|
||||
return await getUpdateData();
|
||||
return await getUpdateData(packageInfo.version);
|
||||
}),
|
||||
updateServer: adminProcedure.mutation(async () => {
|
||||
if (IS_CLOUD) {
|
||||
@@ -488,13 +495,28 @@ export const settingsRouter = createTRPCRouter({
|
||||
|
||||
return readConfigInPath(input.path, input.serverId);
|
||||
}),
|
||||
getIp: protectedProcedure.query(async ({ ctx }) => {
|
||||
getIp: protectedProcedure.query(async () => {
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
return "";
|
||||
}
|
||||
const user = await findUserById(ctx.user.ownerId);
|
||||
return user.serverIp;
|
||||
const settings = await getWebServerSettings();
|
||||
return settings?.serverIp || "";
|
||||
}),
|
||||
updateServerIp: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
serverIp: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
if (IS_CLOUD) {
|
||||
return true;
|
||||
}
|
||||
const settings = await updateWebServerSettings({
|
||||
serverIp: input.serverIp,
|
||||
});
|
||||
return settings;
|
||||
}),
|
||||
|
||||
getOpenApiDocument: protectedProcedure.query(
|
||||
async ({ ctx }): Promise<unknown> => {
|
||||
|
||||
@@ -75,9 +75,9 @@ export const stripeRouter = createTRPCRouter({
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
line_items: items,
|
||||
...(stripeCustomerId && {
|
||||
customer: stripeCustomerId,
|
||||
}),
|
||||
...(stripeCustomerId
|
||||
? { customer: stripeCustomerId }
|
||||
: { customer_email: owner.email }),
|
||||
metadata: {
|
||||
adminId: owner.id,
|
||||
},
|
||||
@@ -128,4 +128,39 @@ export const stripeRouter = createTRPCRouter({
|
||||
|
||||
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 [];
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
findUserById,
|
||||
getDokployUrl,
|
||||
getUserByToken,
|
||||
getWebServerSettings,
|
||||
IS_CLOUD,
|
||||
removeUserById,
|
||||
sendEmailNotification,
|
||||
@@ -214,10 +215,11 @@ export const userRouter = createTRPCRouter({
|
||||
}),
|
||||
getMetricsToken: protectedProcedure.query(async ({ ctx }) => {
|
||||
const user = await findUserById(ctx.user.ownerId);
|
||||
const settings = await getWebServerSettings();
|
||||
return {
|
||||
serverIp: user.serverIp,
|
||||
serverIp: settings?.serverIp,
|
||||
enabledFeatures: user.enablePaidFeatures,
|
||||
metricsConfig: user?.metricsConfig,
|
||||
metricsConfig: settings?.metricsConfig,
|
||||
};
|
||||
}),
|
||||
remove: protectedProcedure
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { dbUrl } from "@dokploy/server/db";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./server/db/schema/index.ts",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
url: dbUrl,
|
||||
},
|
||||
out: "drizzle",
|
||||
migrations: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { dbUrl } from "@dokploy/server/db/constants";
|
||||
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
@@ -6,10 +7,6 @@ declare global {
|
||||
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>;
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
db = drizzle(postgres(dbUrl!), {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { dbUrl } from "@dokploy/server/db";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||
import postgres from "postgres";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
|
||||
const sql = postgres(connectionString, { max: 1 });
|
||||
const sql = postgres(dbUrl, { max: 1 });
|
||||
const db = drizzle(sql);
|
||||
|
||||
export const migration = async () =>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { dbUrl } from "@dokploy/server/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
// Credits to Louistiti from Drizzle Discord: https://discord.com/channels/1043890932593987624/1130802621750448160/1143083373535973406
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
|
||||
const pg = postgres(connectionString, { max: 1 });
|
||||
const pg = postgres(dbUrl, { max: 1 });
|
||||
const db = drizzle(pg);
|
||||
|
||||
const clearDb = async (): Promise<void> => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
deployPreviewApplication,
|
||||
rebuildApplication,
|
||||
rebuildCompose,
|
||||
rebuildPreviewApplication,
|
||||
updateApplicationStatus,
|
||||
updateCompose,
|
||||
updatePreviewDeployment,
|
||||
@@ -54,7 +55,14 @@ export const deploymentWorker = new Worker(
|
||||
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({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
|
||||
@@ -22,7 +22,7 @@ type DeployJob =
|
||||
titleLog: string;
|
||||
descriptionLog: string;
|
||||
server?: boolean;
|
||||
type: "deploy";
|
||||
type: "deploy" | "redeploy";
|
||||
applicationType: "application-preview";
|
||||
previewDeploymentId: string;
|
||||
serverId?: string;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { Client } from "ssh2";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { getShell } from "./utils";
|
||||
import { getShell, isValidContainerId } from "./utils";
|
||||
|
||||
export const setupDockerContainerLogsWebSocketServer = (
|
||||
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
||||
@@ -42,6 +42,12 @@ export const setupDockerContainerLogsWebSocketServer = (
|
||||
return;
|
||||
}
|
||||
|
||||
// Security: Validate containerId to prevent command injection
|
||||
if (!isValidContainerId(containerId)) {
|
||||
ws.close(4000, "Invalid container ID format");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user || !session) {
|
||||
ws.close();
|
||||
return;
|
||||
@@ -111,6 +117,11 @@ export const setupDockerContainerLogsWebSocketServer = (
|
||||
client.end();
|
||||
});
|
||||
} else {
|
||||
if (IS_CLOUD) {
|
||||
ws.send("This feature is not available in the cloud version.");
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
const shell = getShell();
|
||||
const baseCommand = `docker ${runType === "swarm" ? "service" : "container"} logs --timestamps ${
|
||||
runType === "swarm" ? "--raw" : ""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user