mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Compare commits
207 Commits
v0.21.1
...
feat/intro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73d9373c70 | ||
|
|
bbc2cbc78c | ||
|
|
e2e6513ee1 | ||
|
|
d5c77fded3 | ||
|
|
d853b1d326 | ||
|
|
d57e347fdc | ||
|
|
25f3980492 | ||
|
|
c8e2f4bfdc | ||
|
|
4afc6ac250 | ||
|
|
52a660add3 | ||
|
|
3ad5982f39 | ||
|
|
8ba4ac22cc | ||
|
|
bcebcfdfdf | ||
|
|
77b1ec4733 | ||
|
|
cfae5f7e6c | ||
|
|
0f67e9e222 | ||
|
|
c4045795ee | ||
|
|
24f3be3c00 | ||
|
|
5055994bd3 | ||
|
|
ddcb22dff9 | ||
|
|
77d7dc1f22 | ||
|
|
19bf4f27b6 | ||
|
|
6b9765a26c | ||
|
|
8d91053c3a | ||
|
|
7c2eb63625 | ||
|
|
2ea2605ab1 | ||
|
|
7ae3ff22ee | ||
|
|
fa6baa0c1a | ||
|
|
5b43df92c1 | ||
|
|
f3032bc94f | ||
|
|
eef874ecd4 | ||
|
|
d6daa5677a | ||
|
|
dc03ba73b3 | ||
|
|
5c2159f7b2 | ||
|
|
ffcdbcf046 | ||
|
|
bb5c6bebff | ||
|
|
144d48057c | ||
|
|
55dc08b6ba | ||
|
|
56f525803b | ||
|
|
91bcd1238f | ||
|
|
120646c77b | ||
|
|
c0b35efaca | ||
|
|
22dee88e51 | ||
|
|
1645f7e932 | ||
|
|
b4aeb6577e | ||
|
|
fdd330ca19 | ||
|
|
33de620893 | ||
|
|
6518407c0c | ||
|
|
6f47999a2e | ||
|
|
fe69d5d405 | ||
|
|
a6880fd38c | ||
|
|
5d25de13dd | ||
|
|
5611dcccfd | ||
|
|
e2a1882fe3 | ||
|
|
ceb16ae9f7 | ||
|
|
1911b5b674 | ||
|
|
6b818bbb51 | ||
|
|
79796185d6 | ||
|
|
461d7c530a | ||
|
|
ade4b8dd1b | ||
|
|
f49a67f8df | ||
|
|
c3986d7a08 | ||
|
|
0bf4e5560c | ||
|
|
79d55d8d34 | ||
|
|
d4c6e5b048 | ||
|
|
cd4eed3507 | ||
|
|
a650bd16fb | ||
|
|
4e5b5f219e | ||
|
|
dfda934726 | ||
|
|
e6d0b7b4ee | ||
|
|
d0dbc1837f | ||
|
|
2b5af1897f | ||
|
|
11b9cee73d | ||
|
|
bc17991580 | ||
|
|
459c94929a | ||
|
|
8d28a50a17 | ||
|
|
08bbeceeba | ||
|
|
b7bf09bf21 | ||
|
|
546c6ade82 | ||
|
|
db2e3691a5 | ||
|
|
a6dca144a8 | ||
|
|
43a17e7e75 | ||
|
|
da60c4f3a8 | ||
|
|
e14f2780af | ||
|
|
33ab87f3db | ||
|
|
571d73a5b6 | ||
|
|
a630909612 | ||
|
|
8eaa006f0f | ||
|
|
8e8bc3e71e | ||
|
|
f4cd617107 | ||
|
|
48cfe66a6b | ||
|
|
bdc10cacef | ||
|
|
8fbad8a26e | ||
|
|
0f36bcb04e | ||
|
|
f4054453b4 | ||
|
|
dbd36fc024 | ||
|
|
850d06a32c | ||
|
|
dfd3dc180d | ||
|
|
3d42bfc81b | ||
|
|
764f8ec993 | ||
|
|
d2eaa4b40b | ||
|
|
7d7f2b4b1f | ||
|
|
8c36e48fe7 | ||
|
|
8e97c63faa | ||
|
|
74ec8f4594 | ||
|
|
76c0bff13a | ||
|
|
9b5cd0f5fe | ||
|
|
efee798880 | ||
|
|
1c470b8ba7 | ||
|
|
692864ced1 | ||
|
|
9ca61476d2 | ||
|
|
773a610be1 | ||
|
|
37f9e073f0 | ||
|
|
d335a9515d | ||
|
|
7a5a3de43d | ||
|
|
ef7918a33a | ||
|
|
ee6ad07c0a | ||
|
|
48fe26b204 | ||
|
|
3ede89fe8a | ||
|
|
fa698d173e | ||
|
|
1279fac137 | ||
|
|
0e1f0b42ee | ||
|
|
05f43ad06b | ||
|
|
af4511040f | ||
|
|
8f0697b0e9 | ||
|
|
4b15177260 | ||
|
|
61a20f13e2 | ||
|
|
f5cffca37c | ||
|
|
148b1ff2db | ||
|
|
1beceb7ee7 | ||
|
|
bea0316bbd | ||
|
|
b2a8572d10 | ||
|
|
2352939e87 | ||
|
|
e5ee06b67e | ||
|
|
48ec0a74ad | ||
|
|
bca6af77fd | ||
|
|
b3bd9ba1ce | ||
|
|
5a9c763c4f | ||
|
|
4b51744d0d | ||
|
|
e5a3e56e13 | ||
|
|
42fa4008ab | ||
|
|
1605aedd6e | ||
|
|
14bc26e065 | ||
|
|
6c8eb3b711 | ||
|
|
cb20950dd9 | ||
|
|
350bed217c | ||
|
|
7ac7481343 | ||
|
|
d9c34c4524 | ||
|
|
e83efa3379 | ||
|
|
5863e45c13 | ||
|
|
2c09b63bf9 | ||
|
|
eff2657e70 | ||
|
|
36172491a4 | ||
|
|
d43b098a7a | ||
|
|
8479f20205 | ||
|
|
4de599745e | ||
|
|
60e40f4ad0 | ||
|
|
6cb4159d54 | ||
|
|
1bbbdfba60 | ||
|
|
031d0ce315 | ||
|
|
131a1acbbe | ||
|
|
9a839de022 | ||
|
|
b9de05015f | ||
|
|
e176def5b6 | ||
|
|
94c947e288 | ||
|
|
fcb8a2bded | ||
|
|
116e33ce37 | ||
|
|
0bdaa81263 | ||
|
|
baf36b6fb6 | ||
|
|
d632e83799 | ||
|
|
6f52edd845 | ||
|
|
e9b92d2641 | ||
|
|
96e9799afb | ||
|
|
3e07be38df | ||
|
|
67e85cabcb | ||
|
|
64a77decfd | ||
|
|
439d2fe116 | ||
|
|
cc5a3e6873 | ||
|
|
09d0435971 | ||
|
|
5810c94f4b | ||
|
|
1acd330462 | ||
|
|
4074942dbf | ||
|
|
5fd8fcfa1e | ||
|
|
39e6a98179 | ||
|
|
5180c785b4 | ||
|
|
c8e6df4c29 | ||
|
|
9e30525569 | ||
|
|
b7874f053f | ||
|
|
fb0272a64d | ||
|
|
2f4a3c964c | ||
|
|
feb6970b09 | ||
|
|
78682fa359 | ||
|
|
b1d1763988 | ||
|
|
473d729416 | ||
|
|
523720606d | ||
|
|
4b6db35f16 | ||
|
|
9d047164ee | ||
|
|
b5d199057d | ||
|
|
bfb6baf572 | ||
|
|
a61436b8f0 | ||
|
|
371c6317aa | ||
|
|
1f81794904 | ||
|
|
d5d3831d54 | ||
|
|
856399550a | ||
|
|
86b8b0987b | ||
|
|
0dac1fefe6 | ||
|
|
633ba899e0 |
@@ -52,7 +52,7 @@ feat: add new feature
|
|||||||
|
|
||||||
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
|
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
|
||||||
|
|
||||||
We use Node v20.9.0
|
We use Node v20.9.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.9.0 && nvm use` in the root directory.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/dokploy/dokploy.git
|
git clone https://github.com/dokploy/dokploy.git
|
||||||
@@ -87,6 +87,8 @@ pnpm run dokploy:dev
|
|||||||
|
|
||||||
Go to http://localhost:3000 to see the development server
|
Go to http://localhost:3000 to see the development server
|
||||||
|
|
||||||
|
Note: this project uses Biome. If your editor is configured to use another formatter such as Prettier, it's recommended to either change it to use Biome or turn it off.
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -145,11 +147,9 @@ curl -sSL https://railpack.com/install.sh | sh
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install Buildpacks
|
# Install Buildpacks
|
||||||
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.32.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Pull Request
|
## Pull Request
|
||||||
|
|
||||||
- The `main` branch is the source of truth and should always reflect the latest stable release.
|
- The `main` branch is the source of truth and should always reflect the latest stable release.
|
||||||
@@ -167,7 +167,6 @@ Thank you for your contribution!
|
|||||||
|
|
||||||
To add a new template, go to `https://github.com/Dokploy/templates` repository and read the README.md file.
|
To add a new template, go to `https://github.com/Dokploy/templates` repository and read the README.md file.
|
||||||
|
|
||||||
|
|
||||||
### Recommendations
|
### Recommendations
|
||||||
|
|
||||||
- Use the same name of the folder as the id of the template.
|
- Use the same name of the folder as the id of the template.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm
|
|||||||
# Install Nixpacks and tsx
|
# Install Nixpacks and tsx
|
||||||
# | VERBOSE=1 VERSION=1.21.0 bash
|
# | VERBOSE=1 VERSION=1.21.0 bash
|
||||||
|
|
||||||
ARG NIXPACKS_VERSION=1.29.1
|
ARG NIXPACKS_VERSION=1.35.0
|
||||||
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
|
||||||
&& chmod +x install.sh \
|
&& chmod +x install.sh \
|
||||||
&& ./install.sh \
|
&& ./install.sh \
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ const baseApp: ApplicationNested = {
|
|||||||
giteaRepository: "",
|
giteaRepository: "",
|
||||||
cleanCache: false,
|
cleanCache: false,
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
enableSubmodules: false,
|
||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
|
triggerType: "push",
|
||||||
appName: "",
|
appName: "",
|
||||||
autoDeploy: true,
|
autoDeploy: true,
|
||||||
serverId: "",
|
serverId: "",
|
||||||
|
|||||||
@@ -51,6 +51,35 @@ describe("processTemplate", () => {
|
|||||||
expect(result.domains).toHaveLength(0);
|
expect(result.domains).toHaveLength(0);
|
||||||
expect(result.mounts).toHaveLength(0);
|
expect(result.mounts).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should allow creation of real jwt secret", () => {
|
||||||
|
const template: CompleteTemplate = {
|
||||||
|
metadata: {} as any,
|
||||||
|
variables: {
|
||||||
|
jwt_secret: "cQsdycq1hDLopQonF6jUTqgQc5WEZTwWLL02J6XJ",
|
||||||
|
anon_payload: JSON.stringify({
|
||||||
|
role: "tester",
|
||||||
|
iss: "dockploy",
|
||||||
|
iat: "${timestamps:2025-01-01T00:00:00Z}",
|
||||||
|
exp: "${timestamps:2030-01-01T00:00:00Z}",
|
||||||
|
}),
|
||||||
|
anon_key: "${jwt:jwt_secret:anon_payload}",
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
domains: [],
|
||||||
|
env: {
|
||||||
|
ANON_KEY: "${anon_key}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const result = processTemplate(template, mockSchema);
|
||||||
|
expect(result.envs).toHaveLength(1);
|
||||||
|
expect(result.envs).toContain(
|
||||||
|
"ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIxNzM1Njg5NjAwIiwiZXhwIjoiMTg5MzQ1NjAwMCIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJkb2NrcGxveSJ9.BG5JoxL2_NaTFbPgyZdm3kRWenf_O3su_HIRKGCJ_kY",
|
||||||
|
);
|
||||||
|
expect(result.mounts).toHaveLength(0);
|
||||||
|
expect(result.domains).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("domains processing", () => {
|
describe("domains processing", () => {
|
||||||
|
|||||||
232
apps/dokploy/__test__/templates/helpers.template.test.ts
Normal file
232
apps/dokploy/__test__/templates/helpers.template.test.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import type { Schema } from "@dokploy/server/templates";
|
||||||
|
import { processValue } from "@dokploy/server/templates/processors";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("helpers functions", () => {
|
||||||
|
// Mock schema for testing
|
||||||
|
const mockSchema: Schema = {
|
||||||
|
projectName: "test",
|
||||||
|
serverIp: "127.0.0.1",
|
||||||
|
};
|
||||||
|
// some helpers to test jwt
|
||||||
|
type JWTParts = [string, string, string];
|
||||||
|
const jwtMatchExp = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/;
|
||||||
|
const jwtBase64Decode = (str: string) => {
|
||||||
|
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
||||||
|
const decoded = Buffer.from(base64 + padding, "base64").toString("utf-8");
|
||||||
|
return JSON.parse(decoded);
|
||||||
|
};
|
||||||
|
const jwtCheckHeader = (jwtHeader: string) => {
|
||||||
|
const decodedHeader = jwtBase64Decode(jwtHeader);
|
||||||
|
expect(decodedHeader).toHaveProperty("alg");
|
||||||
|
expect(decodedHeader).toHaveProperty("typ");
|
||||||
|
expect(decodedHeader.alg).toEqual("HS256");
|
||||||
|
expect(decodedHeader.typ).toEqual("JWT");
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("${domain}", () => {
|
||||||
|
it("should generate a random domain", () => {
|
||||||
|
const domain = processValue("${domain}", {}, mockSchema);
|
||||||
|
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
domain.endsWith(
|
||||||
|
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
|
||||||
|
),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${base64}", () => {
|
||||||
|
it("should generate a base64 string", () => {
|
||||||
|
const base64 = processValue("${base64}", {}, mockSchema);
|
||||||
|
expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/);
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
[4, 8],
|
||||||
|
[8, 12],
|
||||||
|
[16, 24],
|
||||||
|
[32, 44],
|
||||||
|
[64, 88],
|
||||||
|
[128, 172],
|
||||||
|
])(
|
||||||
|
"should generate a base64 string from parameter %d bytes length",
|
||||||
|
(length, finalLength) => {
|
||||||
|
const base64 = processValue(`\${base64:${length}}`, {}, mockSchema);
|
||||||
|
expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/);
|
||||||
|
expect(base64.length).toBe(finalLength);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${password}", () => {
|
||||||
|
it("should generate a password string", () => {
|
||||||
|
const password = processValue("${password}", {}, mockSchema);
|
||||||
|
expect(password).toMatch(/^[A-Za-z0-9]+$/);
|
||||||
|
});
|
||||||
|
it.each([6, 8, 12, 16, 32])(
|
||||||
|
"should generate a password string respecting parameter %d length",
|
||||||
|
(length) => {
|
||||||
|
const password = processValue(`\${password:${length}}`, {}, mockSchema);
|
||||||
|
expect(password).toMatch(/^[A-Za-z0-9]+$/);
|
||||||
|
expect(password.length).toBe(length);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${hash}", () => {
|
||||||
|
it("should generate a hash string", () => {
|
||||||
|
const hash = processValue("${hash}", {}, mockSchema);
|
||||||
|
expect(hash).toMatch(/^[A-Za-z0-9]+$/);
|
||||||
|
});
|
||||||
|
it.each([6, 8, 12, 16, 32])(
|
||||||
|
"should generate a hash string respecting parameter %d length",
|
||||||
|
(length) => {
|
||||||
|
const hash = processValue(`\${hash:${length}}`, {}, mockSchema);
|
||||||
|
expect(hash).toMatch(/^[A-Za-z0-9]+$/);
|
||||||
|
expect(hash.length).toBe(length);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${uuid}", () => {
|
||||||
|
it("should generate a UUID string", () => {
|
||||||
|
const uuid = processValue("${uuid}", {}, mockSchema);
|
||||||
|
expect(uuid).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${timestamp}", () => {
|
||||||
|
it("should generate a timestamp string in milliseconds", () => {
|
||||||
|
const timestamp = processValue("${timestamp}", {}, mockSchema);
|
||||||
|
const nowLength = Math.floor(Date.now()).toString().length;
|
||||||
|
expect(timestamp).toMatch(/^\d+$/);
|
||||||
|
expect(timestamp.length).toBe(nowLength);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("${timestampms}", () => {
|
||||||
|
it("should generate a timestamp string in milliseconds", () => {
|
||||||
|
const timestamp = processValue("${timestampms}", {}, mockSchema);
|
||||||
|
const nowLength = Date.now().toString().length;
|
||||||
|
expect(timestamp).toMatch(/^\d+$/);
|
||||||
|
expect(timestamp.length).toBe(nowLength);
|
||||||
|
});
|
||||||
|
it("should generate a timestamp string in milliseconds from parameter", () => {
|
||||||
|
const timestamp = processValue(
|
||||||
|
"${timestampms:2025-01-01}",
|
||||||
|
{},
|
||||||
|
mockSchema,
|
||||||
|
);
|
||||||
|
expect(timestamp).toEqual("1735689600000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("${timestamps}", () => {
|
||||||
|
it("should generate a timestamp string in seconds", () => {
|
||||||
|
const timestamps = processValue("${timestamps}", {}, mockSchema);
|
||||||
|
const nowLength = Math.floor(Date.now() / 1000).toString().length;
|
||||||
|
expect(timestamps).toMatch(/^\d+$/);
|
||||||
|
expect(timestamps.length).toBe(nowLength);
|
||||||
|
});
|
||||||
|
it("should generate a timestamp string in seconds from parameter", () => {
|
||||||
|
const timestamps = processValue(
|
||||||
|
"${timestamps:2025-01-01}",
|
||||||
|
{},
|
||||||
|
mockSchema,
|
||||||
|
);
|
||||||
|
expect(timestamps).toEqual("1735689600");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${randomPort}", () => {
|
||||||
|
it("should generate a random port string", () => {
|
||||||
|
const randomPort = processValue("${randomPort}", {}, mockSchema);
|
||||||
|
expect(randomPort).toMatch(/^\d+$/);
|
||||||
|
expect(Number(randomPort)).toBeLessThan(65536);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${username}", () => {
|
||||||
|
it("should generate a username string", () => {
|
||||||
|
const username = processValue("${username}", {}, mockSchema);
|
||||||
|
expect(username).toMatch(/^[a-zA-Z0-9._-]{3,}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${email}", () => {
|
||||||
|
it("should generate an email string", () => {
|
||||||
|
const email = processValue("${email}", {}, mockSchema);
|
||||||
|
expect(email).toMatch(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("${jwt}", () => {
|
||||||
|
it("should generate a JWT string", () => {
|
||||||
|
const jwt = processValue("${jwt}", {}, mockSchema);
|
||||||
|
expect(jwt).toMatch(jwtMatchExp);
|
||||||
|
const parts = jwt.split(".") as JWTParts;
|
||||||
|
const decodedPayload = jwtBase64Decode(parts[1]);
|
||||||
|
jwtCheckHeader(parts[0]);
|
||||||
|
expect(decodedPayload).toHaveProperty("iat");
|
||||||
|
expect(decodedPayload).toHaveProperty("iss");
|
||||||
|
expect(decodedPayload).toHaveProperty("exp");
|
||||||
|
expect(decodedPayload.iss).toEqual("dokploy");
|
||||||
|
});
|
||||||
|
it.each([6, 8, 12, 16, 32])(
|
||||||
|
"should generate a random hex string from parameter %d byte length",
|
||||||
|
(length) => {
|
||||||
|
const jwt = processValue(`\${jwt:${length}}`, {}, mockSchema);
|
||||||
|
expect(jwt).toMatch(/^[A-Za-z0-9-_.]+$/);
|
||||||
|
expect(jwt.length).toBeGreaterThanOrEqual(length); // bytes translated to hex can take up to 2x the length
|
||||||
|
expect(jwt.length).toBeLessThanOrEqual(length * 2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
describe("${jwt:secret}", () => {
|
||||||
|
it("should generate a JWT string respecting parameter secret from variable", () => {
|
||||||
|
const jwt = processValue(
|
||||||
|
"${jwt:secret}",
|
||||||
|
{ secret: "mysecret" },
|
||||||
|
mockSchema,
|
||||||
|
);
|
||||||
|
expect(jwt).toMatch(jwtMatchExp);
|
||||||
|
const parts = jwt.split(".") as JWTParts;
|
||||||
|
const decodedPayload = jwtBase64Decode(parts[1]);
|
||||||
|
jwtCheckHeader(parts[0]);
|
||||||
|
expect(decodedPayload).toHaveProperty("iat");
|
||||||
|
expect(decodedPayload).toHaveProperty("iss");
|
||||||
|
expect(decodedPayload).toHaveProperty("exp");
|
||||||
|
expect(decodedPayload.iss).toEqual("dokploy");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("${jwt:secret:payload}", () => {
|
||||||
|
it("should generate a JWT string respecting parameters secret and payload from variables", () => {
|
||||||
|
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
|
||||||
|
const expiry = iat + 3600;
|
||||||
|
const jwt = processValue(
|
||||||
|
"${jwt:secret:payload}",
|
||||||
|
{
|
||||||
|
secret: "mysecret",
|
||||||
|
payload: `{"iss": "test-issuer", "iat": ${iat}, "exp": ${expiry}, "customprop": "customvalue"}`,
|
||||||
|
},
|
||||||
|
mockSchema,
|
||||||
|
);
|
||||||
|
expect(jwt).toMatch(jwtMatchExp);
|
||||||
|
const parts = jwt.split(".") as JWTParts;
|
||||||
|
jwtCheckHeader(parts[0]);
|
||||||
|
const decodedPayload = jwtBase64Decode(parts[1]);
|
||||||
|
expect(decodedPayload).toHaveProperty("iat");
|
||||||
|
expect(decodedPayload.iat).toEqual(iat);
|
||||||
|
expect(decodedPayload).toHaveProperty("iss");
|
||||||
|
expect(decodedPayload.iss).toEqual("test-issuer");
|
||||||
|
expect(decodedPayload).toHaveProperty("exp");
|
||||||
|
expect(decodedPayload.exp).toEqual(expiry);
|
||||||
|
expect(decodedPayload).toHaveProperty("customprop");
|
||||||
|
expect(decodedPayload.customprop).toEqual("customvalue");
|
||||||
|
expect(jwt).toEqual(
|
||||||
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { beforeEach, expect, test, vi } from "vitest";
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
const baseAdmin: User = {
|
const baseAdmin: User = {
|
||||||
|
https: false,
|
||||||
enablePaidFeatures: false,
|
enablePaidFeatures: false,
|
||||||
metricsConfig: {
|
metricsConfig: {
|
||||||
containers: {
|
containers: {
|
||||||
@@ -73,7 +74,6 @@ beforeEach(() => {
|
|||||||
|
|
||||||
test("Should read the configuration file", () => {
|
test("Should read the configuration file", () => {
|
||||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||||
|
|
||||||
expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe(
|
expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe(
|
||||||
"dokploy-service-app",
|
"dokploy-service-app",
|
||||||
);
|
);
|
||||||
@@ -83,6 +83,7 @@ test("Should apply redirect-to-https", () => {
|
|||||||
updateServerTraefik(
|
updateServerTraefik(
|
||||||
{
|
{
|
||||||
...baseAdmin,
|
...baseAdmin,
|
||||||
|
https: true,
|
||||||
certificateType: "letsencrypt",
|
certificateType: "letsencrypt",
|
||||||
},
|
},
|
||||||
"example.com",
|
"example.com",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const baseApp: ApplicationNested = {
|
|||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
appName: "",
|
appName: "",
|
||||||
autoDeploy: true,
|
autoDeploy: true,
|
||||||
|
enableSubmodules: false,
|
||||||
serverId: "",
|
serverId: "",
|
||||||
branch: null,
|
branch: null,
|
||||||
dockerBuildStage: "",
|
dockerBuildStage: "",
|
||||||
@@ -24,6 +25,7 @@ const baseApp: ApplicationNested = {
|
|||||||
buildArgs: null,
|
buildArgs: null,
|
||||||
isPreviewDeploymentsActive: false,
|
isPreviewDeploymentsActive: false,
|
||||||
previewBuildArgs: null,
|
previewBuildArgs: null,
|
||||||
|
triggerType: "push",
|
||||||
previewCertificateType: "none",
|
previewCertificateType: "none",
|
||||||
previewEnv: null,
|
previewEnv: null,
|
||||||
previewHttps: false,
|
previewHttps: false,
|
||||||
|
|||||||
61
apps/dokploy/__test__/utils/backups.test.ts
Normal file
61
apps/dokploy/__test__/utils/backups.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
|
||||||
|
|
||||||
|
describe("normalizeS3Path", () => {
|
||||||
|
test("should handle empty and whitespace-only prefix", () => {
|
||||||
|
expect(normalizeS3Path("")).toBe("");
|
||||||
|
expect(normalizeS3Path("/")).toBe("");
|
||||||
|
expect(normalizeS3Path(" ")).toBe("");
|
||||||
|
expect(normalizeS3Path("\t")).toBe("");
|
||||||
|
expect(normalizeS3Path("\n")).toBe("");
|
||||||
|
expect(normalizeS3Path(" \n \t ")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should trim whitespace from prefix", () => {
|
||||||
|
expect(normalizeS3Path(" prefix")).toBe("prefix/");
|
||||||
|
expect(normalizeS3Path("prefix ")).toBe("prefix/");
|
||||||
|
expect(normalizeS3Path(" prefix ")).toBe("prefix/");
|
||||||
|
expect(normalizeS3Path("\tprefix\t")).toBe("prefix/");
|
||||||
|
expect(normalizeS3Path(" prefix/nested ")).toBe("prefix/nested/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should remove leading slashes", () => {
|
||||||
|
expect(normalizeS3Path("/prefix")).toBe("prefix/");
|
||||||
|
expect(normalizeS3Path("///prefix")).toBe("prefix/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should remove trailing slashes", () => {
|
||||||
|
expect(normalizeS3Path("prefix/")).toBe("prefix/");
|
||||||
|
expect(normalizeS3Path("prefix///")).toBe("prefix/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should remove both leading and trailing slashes", () => {
|
||||||
|
expect(normalizeS3Path("/prefix/")).toBe("prefix/");
|
||||||
|
expect(normalizeS3Path("///prefix///")).toBe("prefix/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle nested paths", () => {
|
||||||
|
expect(normalizeS3Path("prefix/nested")).toBe("prefix/nested/");
|
||||||
|
expect(normalizeS3Path("/prefix/nested/")).toBe("prefix/nested/");
|
||||||
|
expect(normalizeS3Path("///prefix/nested///")).toBe("prefix/nested/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should preserve middle slashes", () => {
|
||||||
|
expect(normalizeS3Path("prefix/nested/deep")).toBe("prefix/nested/deep/");
|
||||||
|
expect(normalizeS3Path("/prefix/nested/deep/")).toBe("prefix/nested/deep/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle special characters", () => {
|
||||||
|
expect(normalizeS3Path("prefix-with-dashes")).toBe("prefix-with-dashes/");
|
||||||
|
expect(normalizeS3Path("prefix_with_underscores")).toBe(
|
||||||
|
"prefix_with_underscores/",
|
||||||
|
);
|
||||||
|
expect(normalizeS3Path("prefix.with.dots")).toBe("prefix.with.dots/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle the cases from the bug report", () => {
|
||||||
|
expect(normalizeS3Path("instance-backups/")).toBe("instance-backups/");
|
||||||
|
expect(normalizeS3Path("/instance-backups/")).toBe("instance-backups/");
|
||||||
|
expect(normalizeS3Path("instance-backups")).toBe("instance-backups/");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -89,8 +89,6 @@ export const AddDomain = ({
|
|||||||
serverId: application?.serverId || "",
|
serverId: application?.serverId || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("canGenerateTraefikMeDomains", canGenerateTraefikMeDomains);
|
|
||||||
|
|
||||||
const form = useForm<Domain>({
|
const form = useForm<Domain>({
|
||||||
resolver: zodResolver(domain),
|
resolver: zodResolver(domain),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -276,6 +274,11 @@ export const AddDomain = ({
|
|||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Container Port</FormLabel>
|
<FormLabel>Container Port</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
The port where your application is running inside the
|
||||||
|
container (e.g., 3000 for Node.js, 80 for Nginx, 8080
|
||||||
|
for Java)
|
||||||
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<NumberInput placeholder={"3000"} {...field} />
|
<NumberInput placeholder={"3000"} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -8,16 +8,49 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
ExternalLink,
|
||||||
|
GlobeIcon,
|
||||||
|
InfoIcon,
|
||||||
|
Loader2,
|
||||||
|
PenBoxIcon,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AddDomain } from "./add-domain";
|
import { AddDomain } from "./add-domain";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import type { ValidationStates } from "../../compose/domains/show-domains";
|
||||||
|
import { DnsHelperModal } from "../../compose/domains/dns-helper-modal";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
applicationId: string;
|
applicationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShowDomains = ({ applicationId }: Props) => {
|
export const ShowDomains = ({ applicationId }: Props) => {
|
||||||
|
const { data: application } = api.application.one.useQuery(
|
||||||
|
{
|
||||||
|
applicationId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!applicationId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const { data: ip } = api.settings.getIp.useQuery();
|
||||||
|
|
||||||
const { data, refetch } = api.domain.byApplicationId.useQuery(
|
const { data, refetch } = api.domain.byApplicationId.useQuery(
|
||||||
{
|
{
|
||||||
applicationId,
|
applicationId,
|
||||||
@@ -26,10 +59,47 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
enabled: !!applicationId,
|
enabled: !!applicationId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
const { mutateAsync: validateDomain } =
|
||||||
|
api.domain.validateDomain.useMutation();
|
||||||
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
||||||
api.domain.delete.useMutation();
|
api.domain.delete.useMutation();
|
||||||
|
|
||||||
|
const handleValidateDomain = async (host: string) => {
|
||||||
|
setValidationStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[host]: { isLoading: true },
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await validateDomain({
|
||||||
|
domain: host,
|
||||||
|
serverIp:
|
||||||
|
application?.server?.ipAddress?.toString() || ip?.toString() || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
setValidationStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[host]: {
|
||||||
|
isLoading: false,
|
||||||
|
isValid: result.isValid,
|
||||||
|
error: result.error,
|
||||||
|
resolvedIp: result.resolvedIp,
|
||||||
|
message: result.error && result.isValid ? result.error : undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
setValidationStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[host]: {
|
||||||
|
isLoading: false,
|
||||||
|
isValid: false,
|
||||||
|
error: error.message || "Failed to validate domain",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5 ">
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
@@ -68,73 +138,208 @@ export const ShowDomains = ({ applicationId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full">
|
||||||
{data?.map((item) => {
|
{data?.map((item) => {
|
||||||
|
const validationState = validationStates[item.host];
|
||||||
return (
|
return (
|
||||||
<div
|
<Card
|
||||||
key={item.domainId}
|
key={item.domainId}
|
||||||
className="flex w-full items-center justify-between gap-4 border p-4 md:px-6 rounded-lg flex-wrap"
|
className="relative overflow-hidden w-full border bg-card transition-all hover:shadow-md bg-transparent"
|
||||||
>
|
>
|
||||||
<Link
|
<CardContent className="p-6">
|
||||||
className="md:basis-1/2 flex gap-2 items-center hover:underline transition-all w-full"
|
<div className="flex flex-col gap-4">
|
||||||
target="_blank"
|
{/* Service & Domain Info */}
|
||||||
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
<div className="flex items-start justify-between">
|
||||||
>
|
<div className="flex flex-col gap-2">
|
||||||
<span className="truncate max-w-full text-sm">
|
<Link
|
||||||
{item.host}
|
className="flex items-center gap-2 text-base font-medium hover:underline"
|
||||||
</span>
|
target="_blank"
|
||||||
<ExternalLink className="size-4 min-w-4" />
|
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
||||||
</Link>
|
>
|
||||||
|
{item.host}
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!item.host.includes("traefik.me") && (
|
||||||
|
<DnsHelperModal
|
||||||
|
domain={{
|
||||||
|
host: item.host,
|
||||||
|
https: item.https,
|
||||||
|
path: item.path || undefined,
|
||||||
|
}}
|
||||||
|
serverIp={
|
||||||
|
application?.server?.ipAddress?.toString() ||
|
||||||
|
ip?.toString()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<AddDomain
|
||||||
|
applicationId={applicationId}
|
||||||
|
domainId={item.domainId}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10"
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
</AddDomain>
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Domain"
|
||||||
|
description="Are you sure you want to delete this domain?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteDomain({
|
||||||
|
domainId: item.domainId,
|
||||||
|
})
|
||||||
|
.then((_data) => {
|
||||||
|
refetch();
|
||||||
|
toast.success(
|
||||||
|
"Domain deleted successfully",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting domain");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-8">
|
{/* Domain Details */}
|
||||||
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
|
<div className="flex flex-wrap gap-3">
|
||||||
<span>{item.path}</span>
|
<TooltipProvider>
|
||||||
<span>{item.port}</span>
|
<Tooltip>
|
||||||
<span>{item.https ? "HTTPS" : "HTTP"}</span>
|
<TooltipTrigger asChild>
|
||||||
</div>
|
<Badge variant="secondary">
|
||||||
|
<InfoIcon className="size-3 mr-1" />
|
||||||
|
Path: {item.path || "/"}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>URL path for this service</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<TooltipProvider>
|
||||||
<AddDomain
|
<Tooltip>
|
||||||
applicationId={applicationId}
|
<TooltipTrigger asChild>
|
||||||
domainId={item.domainId}
|
<Badge variant="secondary">
|
||||||
>
|
<InfoIcon className="size-3 mr-1" />
|
||||||
<Button
|
Port: {item.port}
|
||||||
variant="ghost"
|
</Badge>
|
||||||
size="icon"
|
</TooltipTrigger>
|
||||||
className="group hover:bg-blue-500/10 "
|
<TooltipContent>
|
||||||
>
|
<p>Container port exposed</p>
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
</TooltipContent>
|
||||||
</Button>
|
</Tooltip>
|
||||||
</AddDomain>
|
</TooltipProvider>
|
||||||
<DialogAction
|
|
||||||
title="Delete Domain"
|
<TooltipProvider>
|
||||||
description="Are you sure you want to delete this domain?"
|
<Tooltip>
|
||||||
type="destructive"
|
<TooltipTrigger asChild>
|
||||||
onClick={async () => {
|
<Badge
|
||||||
await deleteDomain({
|
variant={item.https ? "outline" : "secondary"}
|
||||||
domainId: item.domainId,
|
>
|
||||||
})
|
{item.https ? "HTTPS" : "HTTP"}
|
||||||
.then(() => {
|
</Badge>
|
||||||
refetch();
|
</TooltipTrigger>
|
||||||
toast.success("Domain deleted successfully");
|
<TooltipContent>
|
||||||
})
|
<p>
|
||||||
.catch(() => {
|
{item.https
|
||||||
toast.error("Error deleting domain");
|
? "Secure HTTPS connection"
|
||||||
});
|
: "Standard HTTP connection"}
|
||||||
}}
|
</p>
|
||||||
>
|
</TooltipContent>
|
||||||
<Button
|
</Tooltip>
|
||||||
variant="ghost"
|
</TooltipProvider>
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-red-500/10"
|
{item.certificateType && (
|
||||||
isLoading={isRemoving}
|
<TooltipProvider>
|
||||||
>
|
<Tooltip>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<TooltipTrigger asChild>
|
||||||
</Button>
|
<Badge variant="outline">
|
||||||
</DialogAction>
|
Cert: {item.certificateType}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>SSL Certificate Provider</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
validationState?.isValid
|
||||||
|
? "bg-green-500/10 text-green-500 cursor-pointer"
|
||||||
|
: validationState?.error
|
||||||
|
? "bg-red-500/10 text-red-500 cursor-pointer"
|
||||||
|
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
handleValidateDomain(item.host)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{validationState?.isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-3 mr-1 animate-spin" />
|
||||||
|
Checking DNS...
|
||||||
|
</>
|
||||||
|
) : validationState?.isValid ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="size-3 mr-1" />
|
||||||
|
{validationState.message
|
||||||
|
? "Behind Cloudflare"
|
||||||
|
: "DNS Valid"}
|
||||||
|
</>
|
||||||
|
) : validationState?.error ? (
|
||||||
|
<>
|
||||||
|
<XCircle className="size-3 mr-1" />
|
||||||
|
{validationState.error}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="size-3 mr-1" />
|
||||||
|
Validate DNS
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
{validationState?.error ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-medium text-red-500">
|
||||||
|
Error:
|
||||||
|
</p>
|
||||||
|
<p>{validationState.error}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Click to validate DNS configuration"
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -58,6 +59,7 @@ const BitbucketProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
enableSubmodules: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
|
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
|
||||||
@@ -84,6 +86,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
bitbucketId: "",
|
bitbucketId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(BitbucketProviderSchema),
|
resolver: zodResolver(BitbucketProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -130,6 +133,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
buildPath: data.bitbucketBuildPath || "/",
|
buildPath: data.bitbucketBuildPath || "/",
|
||||||
bitbucketId: data.bitbucketId || "",
|
bitbucketId: data.bitbucketId || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules || false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
@@ -143,6 +147,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
bitbucketId: data.bitbucketId,
|
bitbucketId: data.bitbucketId,
|
||||||
applicationId,
|
applicationId,
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules || false,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provided Saved");
|
toast.success("Service Provided Saved");
|
||||||
@@ -467,6 +472,21 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableSubmodules"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||||
@@ -44,6 +45,7 @@ const GitProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch required"),
|
branch: z.string().min(1, "Branch required"),
|
||||||
sshKey: z.string().optional(),
|
sshKey: z.string().optional(),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GitProvider = z.infer<typeof GitProviderSchema>;
|
type GitProvider = z.infer<typeof GitProviderSchema>;
|
||||||
@@ -67,6 +69,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
repositoryURL: "",
|
repositoryURL: "",
|
||||||
sshKey: undefined,
|
sshKey: undefined,
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GitProviderSchema),
|
resolver: zodResolver(GitProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -79,6 +82,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
buildPath: data.customGitBuildPath || "/",
|
buildPath: data.customGitBuildPath || "/",
|
||||||
repositoryURL: data.customGitUrl || "",
|
repositoryURL: data.customGitUrl || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
@@ -91,6 +95,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
|
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
|
||||||
applicationId,
|
applicationId,
|
||||||
watchPaths: values.watchPaths || [],
|
watchPaths: values.watchPaths || [],
|
||||||
|
enableSubmodules: values.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Git Provider Saved");
|
toast.success("Git Provider Saved");
|
||||||
@@ -294,6 +299,22 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableSubmodules"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row justify-end">
|
<div className="flex flex-row justify-end">
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -74,6 +75,7 @@ const GiteaProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||||
watchPaths: z.array(z.string()).default([]),
|
watchPaths: z.array(z.string()).default([]),
|
||||||
|
enableSubmodules: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
|
type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
|
||||||
@@ -99,6 +101,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
giteaId: "",
|
giteaId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GiteaProviderSchema),
|
resolver: zodResolver(GiteaProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -152,6 +155,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
buildPath: data.giteaBuildPath || "/",
|
buildPath: data.giteaBuildPath || "/",
|
||||||
giteaId: data.giteaId || "",
|
giteaId: data.giteaId || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules || false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
@@ -165,6 +169,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
giteaId: data.giteaId,
|
giteaId: data.giteaId,
|
||||||
applicationId,
|
applicationId,
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
|
enableSubmodules: data.enableSubmodules || false,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provider Saved");
|
toast.success("Service Provider Saved");
|
||||||
@@ -498,6 +503,21 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableSubmodules"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -57,6 +58,8 @@ const GithubProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
githubId: z.string().min(1, "Github Provider is required"),
|
githubId: z.string().min(1, "Github Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GithubProvider = z.infer<typeof GithubProviderSchema>;
|
type GithubProvider = z.infer<typeof GithubProviderSchema>;
|
||||||
@@ -81,12 +84,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
},
|
},
|
||||||
githubId: "",
|
githubId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
|
triggerType: "push",
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GithubProviderSchema),
|
resolver: zodResolver(GithubProviderSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const repository = form.watch("repository");
|
const repository = form.watch("repository");
|
||||||
const githubId = form.watch("githubId");
|
const githubId = form.watch("githubId");
|
||||||
|
const triggerType = form.watch("triggerType");
|
||||||
|
|
||||||
const { data: repositories, isLoading: isLoadingRepositories } =
|
const { data: repositories, isLoading: isLoadingRepositories } =
|
||||||
api.github.getGithubRepositories.useQuery(
|
api.github.getGithubRepositories.useQuery(
|
||||||
@@ -124,6 +130,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
buildPath: data.buildPath || "/",
|
buildPath: data.buildPath || "/",
|
||||||
githubId: data.githubId || "",
|
githubId: data.githubId || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
triggerType: data.triggerType || "push",
|
||||||
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
@@ -137,6 +145,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
buildPath: data.buildPath,
|
buildPath: data.buildPath,
|
||||||
githubId: data.githubId,
|
githubId: data.githubId,
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
triggerType: data.triggerType,
|
||||||
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provided Saved");
|
toast.success("Service Provided Saved");
|
||||||
@@ -381,11 +391,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="watchPaths"
|
name="triggerType"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="md:col-span-2">
|
<FormItem className="md:col-span-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 ">
|
||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Trigger Type</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -393,68 +403,127 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
Add paths to watch for changes. When files in these
|
Choose when to trigger deployments: on push to the
|
||||||
paths change, a new deployment will be triggered.
|
selected branch or when a new tag is created.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<Select
|
||||||
{field.value?.map((path, index) => (
|
onValueChange={field.onChange}
|
||||||
<Badge
|
defaultValue={field.value}
|
||||||
key={index}
|
value={field.value}
|
||||||
variant="secondary"
|
>
|
||||||
className="flex items-center gap-1"
|
<FormControl>
|
||||||
>
|
<SelectTrigger>
|
||||||
{path}
|
<SelectValue placeholder="Select a trigger type" />
|
||||||
<X
|
</SelectTrigger>
|
||||||
className="size-3 cursor-pointer hover:text-destructive"
|
</FormControl>
|
||||||
onClick={() => {
|
<SelectContent>
|
||||||
const newPaths = [...(field.value || [])];
|
<SelectItem value="push">On Push</SelectItem>
|
||||||
newPaths.splice(index, 1);
|
<SelectItem value="tag">On Tag</SelectItem>
|
||||||
field.onChange(newPaths);
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{triggerType === "push" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="watchPaths"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Add paths to watch for changes. When files in
|
||||||
|
these paths change, a new deployment will be
|
||||||
|
triggered.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{field.value?.map((path, index) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
variant="secondary"
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{path}
|
||||||
|
<X
|
||||||
|
className="size-3 cursor-pointer hover:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
const newPaths = [...(field.value || [])];
|
||||||
|
newPaths.splice(index, 1);
|
||||||
|
field.onChange(newPaths);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const path = input.value.trim();
|
||||||
|
if (path) {
|
||||||
|
field.onChange([...(field.value || []), path]);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Badge>
|
</FormControl>
|
||||||
))}
|
<Button
|
||||||
</div>
|
type="button"
|
||||||
<div className="flex gap-2">
|
variant="outline"
|
||||||
<FormControl>
|
size="icon"
|
||||||
<Input
|
onClick={() => {
|
||||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
const input = document.querySelector(
|
||||||
onKeyDown={(e) => {
|
'input[placeholder*="Enter a path"]',
|
||||||
if (e.key === "Enter") {
|
) as HTMLInputElement;
|
||||||
e.preventDefault();
|
const path = input.value.trim();
|
||||||
const input = e.currentTarget;
|
if (path) {
|
||||||
const path = input.value.trim();
|
field.onChange([...(field.value || []), path]);
|
||||||
if (path) {
|
input.value = "";
|
||||||
field.onChange([...(field.value || []), path]);
|
|
||||||
input.value = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</FormControl>
|
<Plus className="size-4" />
|
||||||
<Button
|
</Button>
|
||||||
type="button"
|
</div>
|
||||||
variant="outline"
|
<FormMessage />
|
||||||
size="icon"
|
</FormItem>
|
||||||
onClick={() => {
|
)}
|
||||||
const input = document.querySelector(
|
/>
|
||||||
'input[placeholder*="Enter a path"]',
|
)}
|
||||||
) as HTMLInputElement;
|
|
||||||
const path = input.value.trim();
|
<FormField
|
||||||
if (path) {
|
control={form.control}
|
||||||
field.onChange([...(field.value || []), path]);
|
name="enableSubmodules"
|
||||||
input.value = "";
|
render={({ field }) => (
|
||||||
}
|
<FormItem className="flex items-center space-x-2">
|
||||||
}}
|
<FormControl>
|
||||||
>
|
<Switch
|
||||||
<Plus className="size-4" />
|
checked={field.value}
|
||||||
</Button>
|
onCheckedChange={field.onChange}
|
||||||
</div>
|
/>
|
||||||
<FormMessage />
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -60,6 +61,7 @@ const GitlabProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
|
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
|
||||||
@@ -86,6 +88,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
},
|
},
|
||||||
gitlabId: "",
|
gitlabId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GitlabProviderSchema),
|
resolver: zodResolver(GitlabProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -135,6 +138,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
buildPath: data.gitlabBuildPath || "/",
|
buildPath: data.gitlabBuildPath || "/",
|
||||||
gitlabId: data.gitlabId || "",
|
gitlabId: data.gitlabId || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
@@ -150,6 +154,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
gitlabProjectId: data.repository.id,
|
gitlabProjectId: data.repository.id,
|
||||||
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provided Saved");
|
toast.success("Service Provided Saved");
|
||||||
@@ -483,6 +488,21 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableSubmodules"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
|||||||
setSab(e as TabState);
|
setSab(e as TabState);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
<div className="flex flex-row items-center justify-between w-full gap-4">
|
||||||
<TabsList className="md:grid md:w-fit md:grid-cols-7 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
|
<TabsList className="md:grid md:w-fit md:grid-cols-7 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="github"
|
value="github"
|
||||||
|
|||||||
@@ -298,7 +298,11 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
refetch();
|
refetch();
|
||||||
toast.success("Preview deployments enabled");
|
toast.success(
|
||||||
|
checked
|
||||||
|
? "Preview deployments enabled"
|
||||||
|
: "Preview deployments disabled",
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
|
|||||||
@@ -401,6 +401,11 @@ export const AddDomainCompose = ({
|
|||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Container Port</FormLabel>
|
<FormLabel>Container Port</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
The port where your application is running inside the
|
||||||
|
container (e.g., 3000 for Node.js, 80 for Nginx, 8080
|
||||||
|
for Java)
|
||||||
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<NumberInput placeholder={"3000"} {...field} />
|
<NumberInput placeholder={"3000"} {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Copy, HelpCircle, Server } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
domain: {
|
||||||
|
host: string;
|
||||||
|
https: boolean;
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
serverIp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DnsHelperModal = ({ domain, serverIp }: Props) => {
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
toast.success("Copied to clipboard!");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger>
|
||||||
|
<Button variant="ghost" size="icon" className="group">
|
||||||
|
<HelpCircle className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Server className="size-5" />
|
||||||
|
DNS Configuration Guide
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Follow these steps to configure your DNS records for {domain.host}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<AlertBlock type="info">
|
||||||
|
To make your domain accessible, you need to configure your DNS
|
||||||
|
records with your domain provider (e.g., Cloudflare, GoDaddy,
|
||||||
|
NameCheap).
|
||||||
|
</AlertBlock>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h3 className="font-medium mb-2">1. Add A Record</h3>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Create an A record that points your domain to the server's IP
|
||||||
|
address:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-2 bg-muted p-3 rounded-md">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Type: A</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Name: @ or {domain.host.split(".")[0]}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm">
|
||||||
|
Value: {serverIp || "Your server IP"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => copyToClipboard(serverIp || "")}
|
||||||
|
disabled={!serverIp}
|
||||||
|
>
|
||||||
|
<Copy className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-4">
|
||||||
|
<h3 className="font-medium mb-2">2. Verify Configuration</h3>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
After configuring your DNS records:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-sm">
|
||||||
|
<li>Wait for DNS propagation (usually 15-30 minutes)</li>
|
||||||
|
<li>
|
||||||
|
Test your domain by visiting:{" "}
|
||||||
|
{domain.https ? "https://" : "http://"}
|
||||||
|
{domain.host}
|
||||||
|
{domain.path || "/"}
|
||||||
|
</li>
|
||||||
|
<li>Use a DNS lookup tool to verify your records</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,17 +7,55 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
|
import {
|
||||||
|
ExternalLink,
|
||||||
|
GlobeIcon,
|
||||||
|
PenBoxIcon,
|
||||||
|
Trash2,
|
||||||
|
InfoIcon,
|
||||||
|
Server,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { AddDomainCompose } from "./add-domain";
|
import { AddDomainCompose } from "./add-domain";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { DnsHelperModal } from "./dns-helper-modal";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
composeId: string;
|
composeId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ValidationState = {
|
||||||
|
isLoading: boolean;
|
||||||
|
isValid?: boolean;
|
||||||
|
error?: string;
|
||||||
|
resolvedIp?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ValidationStates = {
|
||||||
|
[key: string]: ValidationState;
|
||||||
|
};
|
||||||
|
|
||||||
export const ShowDomainsCompose = ({ composeId }: Props) => {
|
export const ShowDomainsCompose = ({ composeId }: Props) => {
|
||||||
|
const [validationStates, setValidationStates] = useState<ValidationStates>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: ip } = api.settings.getIp.useQuery();
|
||||||
|
|
||||||
const { data, refetch } = api.domain.byComposeId.useQuery(
|
const { data, refetch } = api.domain.byComposeId.useQuery(
|
||||||
{
|
{
|
||||||
composeId,
|
composeId,
|
||||||
@@ -27,11 +65,58 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { data: compose } = api.compose.one.useQuery(
|
||||||
|
{
|
||||||
|
composeId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!composeId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: validateDomain } =
|
||||||
|
api.domain.validateDomain.useMutation();
|
||||||
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
|
||||||
api.domain.delete.useMutation();
|
api.domain.delete.useMutation();
|
||||||
|
|
||||||
|
const handleValidateDomain = async (host: string) => {
|
||||||
|
setValidationStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[host]: { isLoading: true },
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await validateDomain({
|
||||||
|
domain: host,
|
||||||
|
serverIp:
|
||||||
|
compose?.server?.ipAddress?.toString() || ip?.toString() || "",
|
||||||
|
});
|
||||||
|
|
||||||
|
setValidationStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[host]: {
|
||||||
|
isLoading: false,
|
||||||
|
isValid: result.isValid,
|
||||||
|
error: result.error,
|
||||||
|
resolvedIp: result.resolvedIp,
|
||||||
|
message: result.error && result.isValid ? result.error : undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
const error = err as Error;
|
||||||
|
setValidationStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[host]: {
|
||||||
|
isLoading: false,
|
||||||
|
isValid: false,
|
||||||
|
error: error.message || "Failed to validate domain",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-5 ">
|
<div className="flex w-full flex-col gap-5">
|
||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -45,100 +130,248 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
|
|||||||
{data && data?.length > 0 && (
|
{data && data?.length > 0 && (
|
||||||
<AddDomainCompose composeId={composeId}>
|
<AddDomainCompose composeId={composeId}>
|
||||||
<Button>
|
<Button>
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
<GlobeIcon className="size-4 mr-2" /> Add Domain
|
||||||
</Button>
|
</Button>
|
||||||
</AddDomainCompose>
|
</AddDomainCompose>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex w-full flex-row gap-4">
|
<CardContent>
|
||||||
{data?.length === 0 ? (
|
{data?.length === 0 ? (
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-3">
|
<div className="flex w-full flex-col items-center justify-center gap-3 py-8">
|
||||||
<GlobeIcon className="size-8 text-muted-foreground" />
|
<GlobeIcon className="size-8 text-muted-foreground" />
|
||||||
<span className="text-base text-muted-foreground">
|
<span className="text-base text-muted-foreground text-center">
|
||||||
To access to the application it is required to set at least 1
|
To access to the application it is required to set at least 1
|
||||||
domain
|
domain
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-row gap-4 flex-wrap">
|
<div className="flex flex-row gap-4 flex-wrap">
|
||||||
<AddDomainCompose composeId={composeId}>
|
<AddDomainCompose composeId={composeId}>
|
||||||
<Button>
|
<Button>
|
||||||
<GlobeIcon className="size-4" /> Add Domain
|
<GlobeIcon className="size-4 mr-2" /> Add Domain
|
||||||
</Button>
|
</Button>
|
||||||
</AddDomainCompose>
|
</AddDomainCompose>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex w-full flex-col gap-4">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||||
{data?.map((item) => {
|
{data?.map((item) => {
|
||||||
|
const validationState = validationStates[item.host];
|
||||||
return (
|
return (
|
||||||
<div
|
<Card
|
||||||
key={item.domainId}
|
key={item.domainId}
|
||||||
className="flex w-full items-center justify-between gap-4 border p-4 md:px-6 rounded-lg flex-wrap"
|
className="relative overflow-hidden border bg-card transition-all hover:shadow-md bg-transparent"
|
||||||
>
|
>
|
||||||
<div className="md:basis-1/2 flex gap-6 w-full items-center">
|
<CardContent className="p-6">
|
||||||
<span className="opacity-50 text-center font-medium text-sm whitespace-nowrap">
|
<div className="flex flex-col gap-4">
|
||||||
{item.serviceName}
|
{/* Service & Domain Info */}
|
||||||
</span>
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Badge variant="outline" className="w-fit">
|
||||||
|
<Server className="size-3 mr-1" />
|
||||||
|
{item.serviceName}
|
||||||
|
</Badge>
|
||||||
|
<Link
|
||||||
|
className="flex items-center gap-2 text-base font-medium hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
||||||
|
>
|
||||||
|
{item.host}
|
||||||
|
<ExternalLink className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!item.host.includes("traefik.me") && (
|
||||||
|
<DnsHelperModal
|
||||||
|
domain={{
|
||||||
|
host: item.host,
|
||||||
|
https: item.https,
|
||||||
|
path: item.path || undefined,
|
||||||
|
}}
|
||||||
|
serverIp={
|
||||||
|
compose?.server?.ipAddress?.toString() ||
|
||||||
|
ip?.toString()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<AddDomainCompose
|
||||||
|
composeId={composeId}
|
||||||
|
domainId={item.domainId}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10"
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
</AddDomainCompose>
|
||||||
|
<DialogAction
|
||||||
|
title="Delete Domain"
|
||||||
|
description="Are you sure you want to delete this domain?"
|
||||||
|
type="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
await deleteDomain({
|
||||||
|
domainId: item.domainId,
|
||||||
|
})
|
||||||
|
.then((_data) => {
|
||||||
|
refetch();
|
||||||
|
toast.success(
|
||||||
|
"Domain deleted successfully",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error deleting domain");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-red-500/10"
|
||||||
|
isLoading={isRemoving}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</DialogAction>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link
|
{/* Domain Details */}
|
||||||
className="flex gap-2 items-center hover:underline transition-all w-full max-w-[calc(100%-4rem)]"
|
<div className="flex flex-wrap gap-3">
|
||||||
target="_blank"
|
<TooltipProvider>
|
||||||
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
<Tooltip>
|
||||||
>
|
<TooltipTrigger asChild>
|
||||||
<span className="truncate text-sm">{item.host}</span>
|
<Badge variant="secondary">
|
||||||
<ExternalLink className="size-4 min-w-4" />
|
<InfoIcon className="size-3 mr-1" />
|
||||||
</Link>
|
Path: {item.path || "/"}
|
||||||
</div>
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>URL path for this service</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
<div className="flex gap-8">
|
<TooltipProvider>
|
||||||
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
|
<Tooltip>
|
||||||
<span>{item.path}</span>
|
<TooltipTrigger asChild>
|
||||||
<span>{item.port}</span>
|
<Badge variant="secondary">
|
||||||
<span>{item.https ? "HTTPS" : "HTTP"}</span>
|
<InfoIcon className="size-3 mr-1" />
|
||||||
|
Port: {item.port}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Container port exposed</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant={item.https ? "outline" : "secondary"}
|
||||||
|
>
|
||||||
|
{item.https ? "HTTPS" : "HTTP"}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
{item.https
|
||||||
|
? "Secure HTTPS connection"
|
||||||
|
: "Standard HTTP connection"}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{item.certificateType && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge variant="outline">
|
||||||
|
Cert: {item.certificateType}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>SSL Certificate Provider</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
validationState?.isValid
|
||||||
|
? "bg-green-500/10 text-green-500 cursor-pointer"
|
||||||
|
: validationState?.error
|
||||||
|
? "bg-red-500/10 text-red-500 cursor-pointer"
|
||||||
|
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
handleValidateDomain(item.host)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{validationState?.isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-3 mr-1 animate-spin" />
|
||||||
|
Checking DNS...
|
||||||
|
</>
|
||||||
|
) : validationState?.isValid ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="size-3 mr-1" />
|
||||||
|
{validationState.message
|
||||||
|
? "Behind Cloudflare"
|
||||||
|
: "DNS Valid"}
|
||||||
|
</>
|
||||||
|
) : validationState?.error ? (
|
||||||
|
<>
|
||||||
|
<XCircle className="size-3 mr-1" />
|
||||||
|
{validationState.error}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="size-3 mr-1" />
|
||||||
|
Validate DNS
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">
|
||||||
|
{validationState?.error &&
|
||||||
|
!validationState.isValid ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-medium text-red-500">
|
||||||
|
Error:
|
||||||
|
</p>
|
||||||
|
<p>{validationState.error}</p>
|
||||||
|
</div>
|
||||||
|
) : validationState?.isValid ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="font-medium text-green-500">
|
||||||
|
{validationState.message
|
||||||
|
? "Info:"
|
||||||
|
: "Valid Configuration:"}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{validationState.message ||
|
||||||
|
`Domain points to ${validationState.resolvedIp}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Click to validate DNS configuration"
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
<div className="flex gap-2">
|
</Card>
|
||||||
<AddDomainCompose
|
|
||||||
composeId={composeId}
|
|
||||||
domainId={item.domainId}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-blue-500/10 "
|
|
||||||
>
|
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
|
||||||
</Button>
|
|
||||||
</AddDomainCompose>
|
|
||||||
<DialogAction
|
|
||||||
title="Delete Domain"
|
|
||||||
description="Are you sure you want to delete this domain?"
|
|
||||||
type="destructive"
|
|
||||||
onClick={async () => {
|
|
||||||
await deleteDomain({
|
|
||||||
domainId: item.domainId,
|
|
||||||
})
|
|
||||||
.then((_data) => {
|
|
||||||
refetch();
|
|
||||||
toast.success("Domain deleted successfully");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error deleting domain");
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-red-500/10"
|
|
||||||
isLoading={isRemoving}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</DialogAction>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -79,6 +79,22 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
|||||||
toast.error("Error updating the Compose config");
|
toast.error("Error updating the Compose config");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add keyboard shortcut for Ctrl+S/Cmd+S
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
|
||||||
|
e.preventDefault();
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [form, onSubmit, isLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full flex flex-col gap-4 ">
|
<div className="w-full flex flex-col gap-4 ">
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -58,6 +59,7 @@ const BitbucketProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
|
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
|
||||||
@@ -84,6 +86,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
bitbucketId: "",
|
bitbucketId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(BitbucketProviderSchema),
|
resolver: zodResolver(BitbucketProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -130,6 +133,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
bitbucketId: data.bitbucketId || "",
|
bitbucketId: data.bitbucketId || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
@@ -145,6 +149,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
sourceType: "bitbucket",
|
sourceType: "bitbucket",
|
||||||
composeStatus: "idle",
|
composeStatus: "idle",
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provided Saved");
|
toast.success("Service Provided Saved");
|
||||||
@@ -469,6 +474,21 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableSubmodules"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -43,6 +44,7 @@ const GitProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch required"),
|
branch: z.string().min(1, "Branch required"),
|
||||||
sshKey: z.string().optional(),
|
sshKey: z.string().optional(),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GitProvider = z.infer<typeof GitProviderSchema>;
|
type GitProvider = z.infer<typeof GitProviderSchema>;
|
||||||
@@ -65,6 +67,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: "./docker-compose.yml",
|
composePath: "./docker-compose.yml",
|
||||||
sshKey: undefined,
|
sshKey: undefined,
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GitProviderSchema),
|
resolver: zodResolver(GitProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -77,6 +80,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
repositoryURL: data.customGitUrl || "",
|
repositoryURL: data.customGitUrl || "",
|
||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
@@ -91,6 +95,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: values.composePath,
|
composePath: values.composePath,
|
||||||
composeStatus: "idle",
|
composeStatus: "idle",
|
||||||
watchPaths: values.watchPaths || [],
|
watchPaths: values.watchPaths || [],
|
||||||
|
enableSubmodules: values.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Git Provider Saved");
|
toast.success("Git Provider Saved");
|
||||||
@@ -295,6 +300,21 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableSubmodules"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row justify-end">
|
<div className="flex flex-row justify-end">
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -59,6 +60,7 @@ const GiteaProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
|
type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
|
||||||
@@ -83,6 +85,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
giteaId: "",
|
giteaId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GiteaProviderSchema),
|
resolver: zodResolver(GiteaProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -136,6 +139,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: data.composePath || "./docker-compose.yml",
|
composePath: data.composePath || "./docker-compose.yml",
|
||||||
giteaId: data.giteaId || "",
|
giteaId: data.giteaId || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
@@ -151,6 +155,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
sourceType: "gitea",
|
sourceType: "gitea",
|
||||||
composeStatus: "idle",
|
composeStatus: "idle",
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
|
enableSubmodules: data.enableSubmodules,
|
||||||
} as any)
|
} as any)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provider Saved");
|
toast.success("Service Provider Saved");
|
||||||
@@ -469,6 +474,21 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableSubmodules"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -39,7 +40,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
|
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -57,6 +58,8 @@ const GithubProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
githubId: z.string().min(1, "Github Provider is required"),
|
githubId: z.string().min(1, "Github Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||||
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GithubProvider = z.infer<typeof GithubProviderSchema>;
|
type GithubProvider = z.infer<typeof GithubProviderSchema>;
|
||||||
@@ -82,13 +85,15 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
githubId: "",
|
githubId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
triggerType: "push",
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GithubProviderSchema),
|
resolver: zodResolver(GithubProviderSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const repository = form.watch("repository");
|
const repository = form.watch("repository");
|
||||||
const githubId = form.watch("githubId");
|
const githubId = form.watch("githubId");
|
||||||
|
const triggerType = form.watch("triggerType");
|
||||||
const { data: repositories, isLoading: isLoadingRepositories } =
|
const { data: repositories, isLoading: isLoadingRepositories } =
|
||||||
api.github.getGithubRepositories.useQuery(
|
api.github.getGithubRepositories.useQuery(
|
||||||
{
|
{
|
||||||
@@ -125,6 +130,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
githubId: data.githubId || "",
|
githubId: data.githubId || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
triggerType: data.triggerType || "push",
|
||||||
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
@@ -140,6 +147,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
sourceType: "github",
|
sourceType: "github",
|
||||||
composeStatus: "idle",
|
composeStatus: "idle",
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
|
enableSubmodules: data.enableSubmodules,
|
||||||
|
triggerType: data.triggerType,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provided Saved");
|
toast.success("Service Provided Saved");
|
||||||
@@ -384,79 +393,140 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="watchPaths"
|
name="triggerType"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="md:col-span-2">
|
<FormItem className="md:col-span-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<FormLabel>Watch Paths</FormLabel>
|
<FormLabel>Trigger Type</FormLabel>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger asChild>
|
||||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
|
||||||
?
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
Add paths to watch for changes. When files in these
|
Choose when to trigger deployments: on push to the
|
||||||
paths change, a new deployment will be triggered.
|
selected branch or when a new tag is created.
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<Select
|
||||||
{field.value?.map((path, index) => (
|
onValueChange={field.onChange}
|
||||||
<Badge key={index} variant="secondary">
|
defaultValue={field.value}
|
||||||
{path}
|
value={field.value}
|
||||||
<X
|
>
|
||||||
className="ml-1 size-3 cursor-pointer"
|
<FormControl>
|
||||||
onClick={() => {
|
<SelectTrigger>
|
||||||
const newPaths = [...(field.value || [])];
|
<SelectValue placeholder="Select a trigger type" />
|
||||||
newPaths.splice(index, 1);
|
</SelectTrigger>
|
||||||
form.setValue("watchPaths", newPaths);
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="push">On Push</SelectItem>
|
||||||
|
<SelectItem value="tag">On Tag</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{triggerType === "push" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="watchPaths"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FormLabel>Watch Paths</FormLabel>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||||
|
?
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Add paths to watch for changes. When files in
|
||||||
|
these paths change, a new deployment will be
|
||||||
|
triggered.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-2">
|
||||||
|
{field.value?.map((path, index) => (
|
||||||
|
<Badge key={index} variant="secondary">
|
||||||
|
{path}
|
||||||
|
<X
|
||||||
|
className="ml-1 size-3 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const newPaths = [...(field.value || [])];
|
||||||
|
newPaths.splice(index, 1);
|
||||||
|
form.setValue("watchPaths", newPaths);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = e.currentTarget;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (value) {
|
||||||
|
const newPaths = [
|
||||||
|
...(field.value || []),
|
||||||
|
value,
|
||||||
|
];
|
||||||
|
form.setValue("watchPaths", newPaths);
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Badge>
|
<Button
|
||||||
))}
|
type="button"
|
||||||
</div>
|
variant="secondary"
|
||||||
<FormControl>
|
onClick={() => {
|
||||||
<div className="flex gap-2">
|
const input = document.querySelector(
|
||||||
<Input
|
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
||||||
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
|
) as HTMLInputElement;
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
const input = e.currentTarget;
|
|
||||||
const value = input.value.trim();
|
const value = input.value.trim();
|
||||||
if (value) {
|
if (value) {
|
||||||
const newPaths = [...(field.value || []), value];
|
const newPaths = [...(field.value || []), value];
|
||||||
form.setValue("watchPaths", newPaths);
|
form.setValue("watchPaths", newPaths);
|
||||||
input.value = "";
|
input.value = "";
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}}
|
>
|
||||||
/>
|
Add
|
||||||
<Button
|
</Button>
|
||||||
type="button"
|
</div>
|
||||||
variant="secondary"
|
</FormControl>
|
||||||
onClick={() => {
|
<FormMessage />
|
||||||
const input = document.querySelector(
|
</FormItem>
|
||||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
)}
|
||||||
) as HTMLInputElement;
|
/>
|
||||||
const value = input.value.trim();
|
)}
|
||||||
if (value) {
|
<FormField
|
||||||
const newPaths = [...(field.value || []), value];
|
control={form.control}
|
||||||
form.setValue("watchPaths", newPaths);
|
name="enableSubmodules"
|
||||||
input.value = "";
|
render={({ field }) => (
|
||||||
}
|
<FormItem className="flex items-center space-x-2">
|
||||||
}}
|
<FormControl>
|
||||||
>
|
<Switch
|
||||||
Add
|
checked={field.value}
|
||||||
</Button>
|
onCheckedChange={field.onChange}
|
||||||
</div>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -60,6 +61,7 @@ const GitlabProviderSchema = z.object({
|
|||||||
branch: z.string().min(1, "Branch is required"),
|
branch: z.string().min(1, "Branch is required"),
|
||||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||||
watchPaths: z.array(z.string()).optional(),
|
watchPaths: z.array(z.string()).optional(),
|
||||||
|
enableSubmodules: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
|
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
|
||||||
@@ -87,6 +89,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
gitlabId: "",
|
gitlabId: "",
|
||||||
branch: "",
|
branch: "",
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
enableSubmodules: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(GitlabProviderSchema),
|
resolver: zodResolver(GitlabProviderSchema),
|
||||||
});
|
});
|
||||||
@@ -136,6 +139,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
composePath: data.composePath,
|
composePath: data.composePath,
|
||||||
gitlabId: data.gitlabId || "",
|
gitlabId: data.gitlabId || "",
|
||||||
watchPaths: data.watchPaths || [],
|
watchPaths: data.watchPaths || [],
|
||||||
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data, form]);
|
}, [form.reset, data, form]);
|
||||||
@@ -153,6 +157,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
sourceType: "gitlab",
|
sourceType: "gitlab",
|
||||||
composeStatus: "idle",
|
composeStatus: "idle",
|
||||||
watchPaths: data.watchPaths,
|
watchPaths: data.watchPaths,
|
||||||
|
enableSubmodules: data.enableSubmodules,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success("Service Provided Saved");
|
toast.success("Service Provided Saved");
|
||||||
@@ -485,6 +490,21 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enableSubmodules"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center space-x-2">
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,347 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
} from "@/components/ui/command";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { PlusIcon } from "lucide-react";
|
|
||||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const AddPostgresBackup1Schema = z.object({
|
|
||||||
destinationId: z.string().min(1, "Destination required"),
|
|
||||||
schedule: z.string().min(1, "Schedule (Cron) required"),
|
|
||||||
// .regex(
|
|
||||||
// new RegExp(
|
|
||||||
// /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/,
|
|
||||||
// ),
|
|
||||||
// "Invalid Cron",
|
|
||||||
// ),
|
|
||||||
prefix: z.string().min(1, "Prefix required"),
|
|
||||||
enabled: z.boolean(),
|
|
||||||
database: z.string().min(1, "Database required"),
|
|
||||||
keepLatestCount: z.coerce.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
databaseId: string;
|
|
||||||
databaseType: "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
|
|
||||||
refetch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
|
|
||||||
const { data, isLoading } = api.destination.all.useQuery();
|
|
||||||
|
|
||||||
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
|
|
||||||
api.backup.create.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<AddPostgresBackup>({
|
|
||||||
defaultValues: {
|
|
||||||
database: "",
|
|
||||||
destinationId: "",
|
|
||||||
enabled: true,
|
|
||||||
prefix: "/",
|
|
||||||
schedule: "",
|
|
||||||
keepLatestCount: undefined,
|
|
||||||
},
|
|
||||||
resolver: zodResolver(AddPostgresBackup1Schema),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
form.reset({
|
|
||||||
database: databaseType === "web-server" ? "dokploy" : "",
|
|
||||||
destinationId: "",
|
|
||||||
enabled: true,
|
|
||||||
prefix: "/",
|
|
||||||
schedule: "",
|
|
||||||
keepLatestCount: undefined,
|
|
||||||
});
|
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: AddPostgresBackup) => {
|
|
||||||
const getDatabaseId =
|
|
||||||
databaseType === "postgres"
|
|
||||||
? {
|
|
||||||
postgresId: databaseId,
|
|
||||||
}
|
|
||||||
: databaseType === "mariadb"
|
|
||||||
? {
|
|
||||||
mariadbId: databaseId,
|
|
||||||
}
|
|
||||||
: databaseType === "mysql"
|
|
||||||
? {
|
|
||||||
mysqlId: databaseId,
|
|
||||||
}
|
|
||||||
: databaseType === "mongo"
|
|
||||||
? {
|
|
||||||
mongoId: databaseId,
|
|
||||||
}
|
|
||||||
: databaseType === "web-server"
|
|
||||||
? {
|
|
||||||
userId: databaseId,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
await createBackup({
|
|
||||||
destinationId: data.destinationId,
|
|
||||||
prefix: data.prefix,
|
|
||||||
schedule: data.schedule,
|
|
||||||
enabled: data.enabled,
|
|
||||||
database: data.database,
|
|
||||||
keepLatestCount: data.keepLatestCount,
|
|
||||||
databaseType,
|
|
||||||
...getDatabaseId,
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
toast.success("Backup Created");
|
|
||||||
refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error creating a backup");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button>
|
|
||||||
<PlusIcon className="h-4 w-4" />
|
|
||||||
Create Backup
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-lg max-h-screen overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Create a backup</DialogTitle>
|
|
||||||
<DialogDescription>Add a new backup</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
id="hook-form-add-backup"
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="grid w-full gap-4"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="destinationId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="">
|
|
||||||
<FormLabel>Destination</FormLabel>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-between !bg-input",
|
|
||||||
!field.value && "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? "Loading...."
|
|
||||||
: field.value
|
|
||||||
? data?.find(
|
|
||||||
(destination) =>
|
|
||||||
destination.destinationId === field.value,
|
|
||||||
)?.name
|
|
||||||
: "Select Destination"}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search Destination..."
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
{isLoading && (
|
|
||||||
<span className="py-6 text-center text-sm">
|
|
||||||
Loading Destinations....
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<CommandEmpty>No destinations found.</CommandEmpty>
|
|
||||||
<ScrollArea className="h-64">
|
|
||||||
<CommandGroup>
|
|
||||||
{data?.map((destination) => (
|
|
||||||
<CommandItem
|
|
||||||
value={destination.destinationId}
|
|
||||||
key={destination.destinationId}
|
|
||||||
onSelect={() => {
|
|
||||||
form.setValue(
|
|
||||||
"destinationId",
|
|
||||||
destination.destinationId,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{destination.name}
|
|
||||||
<CheckIcon
|
|
||||||
className={cn(
|
|
||||||
"ml-auto h-4 w-4",
|
|
||||||
destination.destinationId === field.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</ScrollArea>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="database"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Database</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
disabled={databaseType === "web-server"}
|
|
||||||
placeholder={"dokploy"}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="schedule"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Schedule (Cron)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"0 0 * * *"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="prefix"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Prefix Destination</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"dokploy/"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Use if you want to back up in a specific path of your
|
|
||||||
destination/bucket
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="keepLatestCount"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Keep the latest</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder={"keeps all the backups if left empty"}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Optional. If provided, only keeps the latest N backups
|
|
||||||
in the cloud.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Enabled</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Enable or disable the backup
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
isLoading={isCreatingPostgresBackup}
|
|
||||||
form="hook-form-add-backup"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,767 @@
|
|||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { DatabaseZap, PenBoxIcon, PlusIcon, RefreshCw } from "lucide-react";
|
||||||
|
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
type CacheType = "cache" | "fetch";
|
||||||
|
|
||||||
|
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
|
||||||
|
|
||||||
|
const Schema = z
|
||||||
|
.object({
|
||||||
|
destinationId: z.string().min(1, "Destination required"),
|
||||||
|
schedule: z.string().min(1, "Schedule (Cron) required"),
|
||||||
|
prefix: z.string().min(1, "Prefix required"),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
database: z.string().min(1, "Database required"),
|
||||||
|
keepLatestCount: z.coerce.number().optional(),
|
||||||
|
serviceName: z.string().nullable(),
|
||||||
|
databaseType: z
|
||||||
|
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
|
||||||
|
.optional(),
|
||||||
|
backupType: z.enum(["database", "compose"]),
|
||||||
|
metadata: z
|
||||||
|
.object({
|
||||||
|
postgres: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mariadb: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mongo: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mysql: z
|
||||||
|
.object({
|
||||||
|
databaseRootPassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.backupType === "compose" && !data.databaseType) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database type is required for compose backups",
|
||||||
|
path: ["databaseType"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.backupType === "compose" && data.databaseType) {
|
||||||
|
if (data.databaseType === "postgres") {
|
||||||
|
if (!data.metadata?.postgres?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for PostgreSQL",
|
||||||
|
path: ["metadata", "postgres", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mariadb") {
|
||||||
|
if (!data.metadata?.mariadb?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for MariaDB",
|
||||||
|
path: ["metadata", "mariadb", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.metadata?.mariadb?.databasePassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database password is required for MariaDB",
|
||||||
|
path: ["metadata", "mariadb", "databasePassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mongo") {
|
||||||
|
if (!data.metadata?.mongo?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for MongoDB",
|
||||||
|
path: ["metadata", "mongo", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.metadata?.mongo?.databasePassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database password is required for MongoDB",
|
||||||
|
path: ["metadata", "mongo", "databasePassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mysql") {
|
||||||
|
if (!data.metadata?.mysql?.databaseRootPassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Root password is required for MySQL",
|
||||||
|
path: ["metadata", "mysql", "databaseRootPassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id?: string;
|
||||||
|
backupId?: string;
|
||||||
|
databaseType?: DatabaseType;
|
||||||
|
refetch: () => void;
|
||||||
|
backupType: "database" | "compose";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HandleBackup = ({
|
||||||
|
id,
|
||||||
|
backupId,
|
||||||
|
databaseType = "postgres",
|
||||||
|
refetch,
|
||||||
|
backupType = "database",
|
||||||
|
}: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = api.destination.all.useQuery();
|
||||||
|
const { data: backup } = api.backup.one.useQuery(
|
||||||
|
{
|
||||||
|
backupId: backupId ?? "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!backupId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [cacheType, setCacheType] = useState<CacheType>("cache");
|
||||||
|
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
|
||||||
|
backupId
|
||||||
|
? api.backup.update.useMutation()
|
||||||
|
: api.backup.create.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof Schema>>({
|
||||||
|
defaultValues: {
|
||||||
|
database: databaseType === "web-server" ? "dokploy" : "",
|
||||||
|
destinationId: "",
|
||||||
|
enabled: true,
|
||||||
|
prefix: "/",
|
||||||
|
schedule: "",
|
||||||
|
keepLatestCount: undefined,
|
||||||
|
serviceName: null,
|
||||||
|
databaseType: backupType === "compose" ? undefined : databaseType,
|
||||||
|
backupType: backupType,
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
resolver: zodResolver(Schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: services,
|
||||||
|
isFetching: isLoadingServices,
|
||||||
|
error: errorServices,
|
||||||
|
refetch: refetchServices,
|
||||||
|
} = api.compose.loadServices.useQuery(
|
||||||
|
{
|
||||||
|
composeId: backup?.composeId ?? id ?? "",
|
||||||
|
type: cacheType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
enabled: backupType === "compose" && !!backup?.composeId && !!id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
database: backup?.database
|
||||||
|
? backup?.database
|
||||||
|
: databaseType === "web-server"
|
||||||
|
? "dokploy"
|
||||||
|
: "",
|
||||||
|
destinationId: backup?.destinationId ?? "",
|
||||||
|
enabled: backup?.enabled ?? true,
|
||||||
|
prefix: backup?.prefix ?? "/",
|
||||||
|
schedule: backup?.schedule ?? "",
|
||||||
|
keepLatestCount: backup?.keepLatestCount ?? undefined,
|
||||||
|
serviceName: backup?.serviceName ?? null,
|
||||||
|
databaseType: backup?.databaseType ?? databaseType,
|
||||||
|
backupType: backup?.backupType ?? backupType,
|
||||||
|
metadata: backup?.metadata ?? {},
|
||||||
|
});
|
||||||
|
}, [form, form.reset, backupId, backup]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: z.infer<typeof Schema>) => {
|
||||||
|
const getDatabaseId =
|
||||||
|
backupType === "compose"
|
||||||
|
? {
|
||||||
|
composeId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "postgres"
|
||||||
|
? {
|
||||||
|
postgresId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "mariadb"
|
||||||
|
? {
|
||||||
|
mariadbId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "mysql"
|
||||||
|
? {
|
||||||
|
mysqlId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "mongo"
|
||||||
|
? {
|
||||||
|
mongoId: id,
|
||||||
|
}
|
||||||
|
: databaseType === "web-server"
|
||||||
|
? {
|
||||||
|
userId: id,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
await createBackup({
|
||||||
|
destinationId: data.destinationId,
|
||||||
|
prefix: data.prefix,
|
||||||
|
schedule: data.schedule,
|
||||||
|
enabled: data.enabled,
|
||||||
|
database: data.database,
|
||||||
|
keepLatestCount: data.keepLatestCount ?? null,
|
||||||
|
databaseType: data.databaseType || databaseType,
|
||||||
|
serviceName: data.serviceName,
|
||||||
|
...getDatabaseId,
|
||||||
|
backupId: backupId ?? "",
|
||||||
|
backupType,
|
||||||
|
metadata: data.metadata,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success(`Backup ${backupId ? "Updated" : "Created"}`);
|
||||||
|
refetch();
|
||||||
|
setIsOpen(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(`Error ${backupId ? "updating" : "creating"} a backup`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{backupId ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="group hover:bg-blue-500/10 size-8"
|
||||||
|
>
|
||||||
|
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
{backupId ? "Update Backup" : "Create Backup"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-2xl max-h-screen overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{backupId ? "Update Backup" : "Create Backup"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{backupId ? "Update a backup" : "Add a new backup"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="hook-form-add-backup"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="grid w-full gap-4"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{errorServices && (
|
||||||
|
<AlertBlock type="warning" className="[overflow-wrap:anywhere]">
|
||||||
|
{errorServices?.message}
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="databaseType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Type</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value as DatabaseType);
|
||||||
|
form.setValue("metadata", {});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Select a database type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||||||
|
<SelectItem value="mariadb">MariaDB</SelectItem>
|
||||||
|
<SelectItem value="mysql">MySQL</SelectItem>
|
||||||
|
<SelectItem value="mongo">MongoDB</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="destinationId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="">
|
||||||
|
<FormLabel>Destination</FormLabel>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FormControl>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-between !bg-input",
|
||||||
|
!field.value && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? "Loading...."
|
||||||
|
: field.value
|
||||||
|
? data?.find(
|
||||||
|
(destination) =>
|
||||||
|
destination.destinationId === field.value,
|
||||||
|
)?.name
|
||||||
|
: "Select Destination"}
|
||||||
|
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search Destination..."
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
{isLoading && (
|
||||||
|
<span className="py-6 text-center text-sm">
|
||||||
|
Loading Destinations....
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<CommandEmpty>No destinations found.</CommandEmpty>
|
||||||
|
<ScrollArea className="h-64">
|
||||||
|
<CommandGroup>
|
||||||
|
{data?.map((destination) => (
|
||||||
|
<CommandItem
|
||||||
|
value={destination.destinationId}
|
||||||
|
key={destination.destinationId}
|
||||||
|
onSelect={() => {
|
||||||
|
form.setValue(
|
||||||
|
"destinationId",
|
||||||
|
destination.destinationId,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{destination.name}
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
destination.destinationId === field.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</ScrollArea>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<div className="flex flex-row items-end w-full gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serviceName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Service Name</FormLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value || undefined}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a service name" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{services?.map((service, index) => (
|
||||||
|
<SelectItem
|
||||||
|
value={service}
|
||||||
|
key={`${service}-${index}`}
|
||||||
|
>
|
||||||
|
{service}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{(!services || services.length === 0) && (
|
||||||
|
<SelectItem value="none" disabled>
|
||||||
|
Empty
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "fetch") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("fetch");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Fetch: Will clone the repository and load the
|
||||||
|
services
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "cache") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("cache");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Cache: If you previously deployed this
|
||||||
|
compose, it will read the services from the
|
||||||
|
last deployment/fetch from the repository
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="database"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
disabled={databaseType === "web-server"}
|
||||||
|
placeholder={"dokploy"}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="schedule"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Schedule (Cron)</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={"0 0 * * *"} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="prefix"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Prefix Destination</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder={"dokploy/"} {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Use if you want to back up in a specific path of your
|
||||||
|
destination/bucket
|
||||||
|
</FormDescription>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="keepLatestCount"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Keep the latest</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={"keeps all the backups if left empty"}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Optional. If provided, only keeps the latest N backups
|
||||||
|
in the cloud.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Enabled</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Enable or disable the backup
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<>
|
||||||
|
{form.watch("databaseType") === "postgres" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.postgres.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="postgres" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.watch("databaseType") === "mariadb" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mariadb.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="mariadb" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mariadb.databasePassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.watch("databaseType") === "mongo" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mongo.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="mongo" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mongo.databasePassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.watch("databaseType") === "mysql" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mysql.databaseRootPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Root Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
isLoading={isCreatingPostgresBackup}
|
||||||
|
form="hook-form-add-backup"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{backupId ? "Update" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -32,80 +32,213 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import copy from "copy-to-clipboard";
|
import copy from "copy-to-clipboard";
|
||||||
import { debounce } from "lodash";
|
import { debounce } from "lodash";
|
||||||
import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Copy,
|
||||||
|
RotateCcw,
|
||||||
|
RefreshCw,
|
||||||
|
DatabaseZap,
|
||||||
|
} from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { ServiceType } from "../../application/advanced/show-resources";
|
import type { ServiceType } from "../../application/advanced/show-resources";
|
||||||
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
import { type LogLine, parseLogs } from "../../docker/logs/utils";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
type DatabaseType =
|
||||||
|
| Exclude<ServiceType, "application" | "redis">
|
||||||
|
| "web-server";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
databaseId: string;
|
id: string;
|
||||||
databaseType: Exclude<ServiceType, "application" | "redis"> | "web-server";
|
databaseType?: DatabaseType;
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
|
backupType?: "database" | "compose";
|
||||||
}
|
}
|
||||||
|
|
||||||
const RestoreBackupSchema = z.object({
|
const RestoreBackupSchema = z
|
||||||
destinationId: z
|
.object({
|
||||||
.string({
|
destinationId: z
|
||||||
required_error: "Please select a destination",
|
.string({
|
||||||
})
|
required_error: "Please select a destination",
|
||||||
.min(1, {
|
})
|
||||||
message: "Destination is required",
|
.min(1, {
|
||||||
}),
|
message: "Destination is required",
|
||||||
backupFile: z
|
}),
|
||||||
.string({
|
backupFile: z
|
||||||
required_error: "Please select a backup file",
|
.string({
|
||||||
})
|
required_error: "Please select a backup file",
|
||||||
.min(1, {
|
})
|
||||||
message: "Backup file is required",
|
.min(1, {
|
||||||
}),
|
message: "Backup file is required",
|
||||||
databaseName: z
|
}),
|
||||||
.string({
|
databaseName: z
|
||||||
required_error: "Please enter a database name",
|
.string({
|
||||||
})
|
required_error: "Please enter a database name",
|
||||||
.min(1, {
|
})
|
||||||
message: "Database name is required",
|
.min(1, {
|
||||||
}),
|
message: "Database name is required",
|
||||||
});
|
}),
|
||||||
|
databaseType: z
|
||||||
|
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
|
||||||
|
.optional(),
|
||||||
|
backupType: z.enum(["database", "compose"]).default("database"),
|
||||||
|
serviceName: z.string().nullable().optional(),
|
||||||
|
metadata: z
|
||||||
|
.object({
|
||||||
|
postgres: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mariadb: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mongo: z
|
||||||
|
.object({
|
||||||
|
databaseUser: z.string(),
|
||||||
|
databasePassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
mysql: z
|
||||||
|
.object({
|
||||||
|
databaseRootPassword: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.backupType === "compose" && !data.databaseType) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database type is required for compose backups",
|
||||||
|
path: ["databaseType"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.backupType === "compose" && data.databaseType) {
|
||||||
|
if (data.databaseType === "postgres") {
|
||||||
|
if (!data.metadata?.postgres?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for PostgreSQL",
|
||||||
|
path: ["metadata", "postgres", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mariadb") {
|
||||||
|
if (!data.metadata?.mariadb?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for MariaDB",
|
||||||
|
path: ["metadata", "mariadb", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.metadata?.mariadb?.databasePassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database password is required for MariaDB",
|
||||||
|
path: ["metadata", "mariadb", "databasePassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mongo") {
|
||||||
|
if (!data.metadata?.mongo?.databaseUser) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database user is required for MongoDB",
|
||||||
|
path: ["metadata", "mongo", "databaseUser"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!data.metadata?.mongo?.databasePassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Database password is required for MongoDB",
|
||||||
|
path: ["metadata", "mongo", "databasePassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (data.databaseType === "mysql") {
|
||||||
|
if (!data.metadata?.mysql?.databaseRootPassword) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Root password is required for MySQL",
|
||||||
|
path: ["metadata", "mysql", "databaseRootPassword"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
type RestoreBackup = z.infer<typeof RestoreBackupSchema>;
|
const formatBytes = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return "0 Bytes";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const RestoreBackup = ({
|
export const RestoreBackup = ({
|
||||||
databaseId,
|
id,
|
||||||
databaseType,
|
databaseType,
|
||||||
serverId,
|
serverId,
|
||||||
|
backupType = "database",
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||||
|
|
||||||
const { data: destinations = [] } = api.destination.all.useQuery();
|
const { data: destinations = [] } = api.destination.all.useQuery();
|
||||||
|
|
||||||
const form = useForm<RestoreBackup>({
|
const form = useForm<z.infer<typeof RestoreBackupSchema>>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
destinationId: "",
|
destinationId: "",
|
||||||
backupFile: "",
|
backupFile: "",
|
||||||
databaseName: databaseType === "web-server" ? "dokploy" : "",
|
databaseName: databaseType === "web-server" ? "dokploy" : "",
|
||||||
|
databaseType:
|
||||||
|
backupType === "compose" ? ("postgres" as DatabaseType) : databaseType,
|
||||||
|
metadata: {},
|
||||||
},
|
},
|
||||||
resolver: zodResolver(RestoreBackupSchema),
|
resolver: zodResolver(RestoreBackupSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const destionationId = form.watch("destinationId");
|
const destionationId = form.watch("destinationId");
|
||||||
|
const currentDatabaseType = form.watch("databaseType");
|
||||||
|
const metadata = form.watch("metadata");
|
||||||
|
|
||||||
const debouncedSetSearch = debounce((value: string) => {
|
const debouncedSetSearch = debounce((value: string) => {
|
||||||
|
setDebouncedSearchTerm(value);
|
||||||
|
}, 350);
|
||||||
|
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
setSearch(value);
|
setSearch(value);
|
||||||
}, 300);
|
debouncedSetSearch(value);
|
||||||
|
};
|
||||||
|
|
||||||
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
|
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
|
||||||
{
|
{
|
||||||
destinationId: destionationId,
|
destinationId: destionationId,
|
||||||
search,
|
search: debouncedSearchTerm,
|
||||||
serverId: serverId ?? "",
|
serverId: serverId ?? "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -117,16 +250,15 @@ export const RestoreBackup = ({
|
|||||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||||
const [isDeploying, setIsDeploying] = useState(false);
|
const [isDeploying, setIsDeploying] = useState(false);
|
||||||
|
|
||||||
// const { mutateAsync: restore, isLoading: isRestoring } =
|
|
||||||
// api.backup.restoreBackup.useMutation();
|
|
||||||
|
|
||||||
api.backup.restoreBackupWithLogs.useSubscription(
|
api.backup.restoreBackupWithLogs.useSubscription(
|
||||||
{
|
{
|
||||||
databaseId,
|
databaseId: id,
|
||||||
databaseType,
|
databaseType: currentDatabaseType as DatabaseType,
|
||||||
databaseName: form.watch("databaseName"),
|
databaseName: form.watch("databaseName"),
|
||||||
backupFile: form.watch("backupFile"),
|
backupFile: form.watch("backupFile"),
|
||||||
destinationId: form.watch("destinationId"),
|
destinationId: form.watch("destinationId"),
|
||||||
|
backupType: backupType,
|
||||||
|
metadata: metadata,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: isDeploying,
|
enabled: isDeploying,
|
||||||
@@ -148,10 +280,32 @@ export const RestoreBackup = ({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmit = async (_data: RestoreBackup) => {
|
const onSubmit = async (data: z.infer<typeof RestoreBackupSchema>) => {
|
||||||
|
if (backupType === "compose" && !data.databaseType) {
|
||||||
|
toast.error("Please select a database type");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log({ data });
|
||||||
setIsDeploying(true);
|
setIsDeploying(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [cacheType, setCacheType] = useState<"fetch" | "cache">("cache");
|
||||||
|
const {
|
||||||
|
data: services = [],
|
||||||
|
isLoading: isLoadingServices,
|
||||||
|
refetch: refetchServices,
|
||||||
|
} = api.compose.loadServices.useQuery(
|
||||||
|
{
|
||||||
|
composeId: id,
|
||||||
|
type: cacheType,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
enabled: backupType === "compose",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -160,7 +314,7 @@ export const RestoreBackup = ({
|
|||||||
Restore Backup
|
Restore Backup
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="sm:max-w-lg">
|
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center">
|
<DialogTitle className="flex items-center">
|
||||||
<RotateCcw className="mr-2 size-4" />
|
<RotateCcw className="mr-2 size-4" />
|
||||||
@@ -265,7 +419,7 @@ export const RestoreBackup = ({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Popover>
|
<Popover modal>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Button
|
<Button
|
||||||
@@ -284,7 +438,8 @@ export const RestoreBackup = ({
|
|||||||
<Command>
|
<Command>
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search backup files..."
|
placeholder="Search backup files..."
|
||||||
onValueChange={debouncedSetSearch}
|
value={search}
|
||||||
|
onValueChange={handleSearchChange}
|
||||||
className="h-9"
|
className="h-9"
|
||||||
/>
|
/>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -301,26 +456,51 @@ export const RestoreBackup = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="h-64">
|
<ScrollArea className="h-64">
|
||||||
<CommandGroup>
|
<CommandGroup className="w-96">
|
||||||
{files.map((file) => (
|
{files?.map((file) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={file}
|
value={file.Path}
|
||||||
key={file}
|
key={file.Path}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
form.setValue("backupFile", file);
|
form.setValue("backupFile", file.Path);
|
||||||
|
if (file.IsDir) {
|
||||||
|
setSearch(`${file.Path}/`);
|
||||||
|
setDebouncedSearchTerm(`${file.Path}/`);
|
||||||
|
} else {
|
||||||
|
setSearch(file.Path);
|
||||||
|
setDebouncedSearchTerm(file.Path);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex w-full justify-between">
|
<div className="flex w-full flex-col gap-1">
|
||||||
<span>{file}</span>
|
<div className="flex w-full justify-between">
|
||||||
|
<span className="font-medium">
|
||||||
|
{file.Path}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"ml-auto h-4 w-4",
|
||||||
|
file.Path === field.value
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Size: {formatBytes(file.Size)}
|
||||||
|
</span>
|
||||||
|
{file.IsDir && (
|
||||||
|
<span className="text-blue-500">
|
||||||
|
Directory
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{file.Hashes?.MD5 && (
|
||||||
|
<span>MD5: {file.Hashes.MD5}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CheckIcon
|
|
||||||
className={cn(
|
|
||||||
"ml-auto h-4 w-4",
|
|
||||||
file === field.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
@@ -337,25 +517,270 @@ export const RestoreBackup = ({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name="databaseName"
|
name="databaseName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="">
|
<FormItem>
|
||||||
<FormLabel>Database Name</FormLabel>
|
<FormLabel>Database Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input placeholder="Enter database name" {...field} />
|
||||||
disabled={databaseType === "web-server"}
|
|
||||||
{...field}
|
|
||||||
placeholder="Enter database name"
|
|
||||||
/>
|
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="databaseType"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Type</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(value: DatabaseType) => {
|
||||||
|
field.onChange(value);
|
||||||
|
form.setValue("metadata", {});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select database type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="postgres">PostgreSQL</SelectItem>
|
||||||
|
<SelectItem value="mariadb">MariaDB</SelectItem>
|
||||||
|
<SelectItem value="mongo">MongoDB</SelectItem>
|
||||||
|
<SelectItem value="mysql">MySQL</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="serviceName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormLabel>Service Name</FormLabel>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value || undefined}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a service name" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
{services?.map((service, index) => (
|
||||||
|
<SelectItem
|
||||||
|
value={service}
|
||||||
|
key={`${service}-${index}`}
|
||||||
|
>
|
||||||
|
{service}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
{(!services || services.length === 0) && (
|
||||||
|
<SelectItem value="none" disabled>
|
||||||
|
Empty
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "fetch") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("fetch");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RefreshCw className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Fetch: Will clone the repository and load the
|
||||||
|
services
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
isLoading={isLoadingServices}
|
||||||
|
onClick={() => {
|
||||||
|
if (cacheType === "cache") {
|
||||||
|
refetchServices();
|
||||||
|
} else {
|
||||||
|
setCacheType("cache");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DatabaseZap className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="left"
|
||||||
|
sideOffset={5}
|
||||||
|
className="max-w-[10rem]"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Cache: If you previously deployed this compose,
|
||||||
|
it will read the services from the last
|
||||||
|
deployment/fetch from the repository
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{currentDatabaseType === "postgres" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.postgres.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Enter database user" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentDatabaseType === "mariadb" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mariadb.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter database user"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mariadb.databasePassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter database password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentDatabaseType === "mongo" && (
|
||||||
|
<>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mongo.databaseUser"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database User</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Enter database user"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mongo.databasePassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Database Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter database password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentDatabaseType === "mysql" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="metadata.mysql.databaseRootPassword"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Root Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter root password"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
isLoading={isDeploying}
|
isLoading={isDeploying}
|
||||||
form="hook-form-restore-backup"
|
form="hook-form-restore-backup"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!form.watch("backupFile")}
|
disabled={
|
||||||
|
!form.watch("backupFile") ||
|
||||||
|
(backupType === "compose" && !form.watch("databaseType"))
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Restore
|
Restore
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -19,44 +19,71 @@ import Link from "next/link";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { ServiceType } from "../../application/advanced/show-resources";
|
import type { ServiceType } from "../../application/advanced/show-resources";
|
||||||
import { AddBackup } from "./add-backup";
|
|
||||||
import { RestoreBackup } from "./restore-backup";
|
import { RestoreBackup } from "./restore-backup";
|
||||||
import { UpdateBackup } from "./update-backup";
|
import { HandleBackup } from "./handle-backup";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
MariadbIcon,
|
||||||
|
MongodbIcon,
|
||||||
|
MysqlIcon,
|
||||||
|
PostgresqlIcon,
|
||||||
|
} from "@/components/icons/data-tools-icons";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
type: Exclude<ServiceType, "application" | "redis"> | "web-server";
|
databaseType?: Exclude<ServiceType, "application" | "redis"> | "web-server";
|
||||||
|
backupType?: "database" | "compose";
|
||||||
}
|
}
|
||||||
export const ShowBackups = ({ id, type }: Props) => {
|
export const ShowBackups = ({
|
||||||
|
id,
|
||||||
|
databaseType,
|
||||||
|
backupType = "database",
|
||||||
|
}: Props) => {
|
||||||
const [activeManualBackup, setActiveManualBackup] = useState<
|
const [activeManualBackup, setActiveManualBackup] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>();
|
>();
|
||||||
const queryMap = {
|
const queryMap =
|
||||||
postgres: () =>
|
backupType === "database"
|
||||||
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
? {
|
||||||
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
postgres: () =>
|
||||||
mariadb: () =>
|
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
|
||||||
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
mysql: () =>
|
||||||
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
|
||||||
"web-server": () => api.user.getBackups.useQuery(),
|
mariadb: () =>
|
||||||
};
|
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
|
||||||
|
mongo: () =>
|
||||||
|
api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
|
||||||
|
"web-server": () => api.user.getBackups.useQuery(),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
compose: () =>
|
||||||
|
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
|
||||||
|
};
|
||||||
const { data } = api.destination.all.useQuery();
|
const { data } = api.destination.all.useQuery();
|
||||||
const { data: postgres, refetch } = queryMap[type]
|
const key = backupType === "database" ? databaseType : "compose";
|
||||||
? queryMap[type]()
|
const query = queryMap[key as keyof typeof queryMap];
|
||||||
|
const { data: postgres, refetch } = query
|
||||||
|
? query()
|
||||||
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
|
||||||
|
|
||||||
const mutationMap = {
|
const mutationMap =
|
||||||
postgres: () => api.backup.manualBackupPostgres.useMutation(),
|
backupType === "database"
|
||||||
mysql: () => api.backup.manualBackupMySql.useMutation(),
|
? {
|
||||||
mariadb: () => api.backup.manualBackupMariadb.useMutation(),
|
postgres: api.backup.manualBackupPostgres.useMutation(),
|
||||||
mongo: () => api.backup.manualBackupMongo.useMutation(),
|
mysql: api.backup.manualBackupMySql.useMutation(),
|
||||||
"web-server": () => api.backup.manualBackupWebServer.useMutation(),
|
mariadb: api.backup.manualBackupMariadb.useMutation(),
|
||||||
};
|
mongo: api.backup.manualBackupMongo.useMutation(),
|
||||||
|
"web-server": api.backup.manualBackupWebServer.useMutation(),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
compose: api.backup.manualBackupCompose.useMutation(),
|
||||||
|
};
|
||||||
|
|
||||||
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutationMap[
|
const mutation = mutationMap[key as keyof typeof mutationMap];
|
||||||
type
|
|
||||||
]
|
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutation
|
||||||
? mutationMap[type]()
|
? mutation
|
||||||
: api.backup.manualBackupMongo.useMutation();
|
: api.backup.manualBackupMongo.useMutation();
|
||||||
|
|
||||||
const { mutateAsync: deleteBackup, isLoading: isRemoving } =
|
const { mutateAsync: deleteBackup, isLoading: isRemoving } =
|
||||||
@@ -78,16 +105,18 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
|
|
||||||
{postgres && postgres?.backups?.length > 0 && (
|
{postgres && postgres?.backups?.length > 0 && (
|
||||||
<div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto">
|
<div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto">
|
||||||
{type !== "web-server" && (
|
{databaseType !== "web-server" && (
|
||||||
<AddBackup
|
<HandleBackup
|
||||||
databaseId={id}
|
id={id}
|
||||||
databaseType={type}
|
databaseType={databaseType}
|
||||||
|
backupType={backupType}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<RestoreBackup
|
<RestoreBackup
|
||||||
databaseId={id}
|
id={id}
|
||||||
databaseType={type}
|
databaseType={databaseType}
|
||||||
|
backupType={backupType}
|
||||||
serverId={"serverId" in postgres ? postgres.serverId : undefined}
|
serverId={"serverId" in postgres ? postgres.serverId : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,7 +139,7 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div className="flex flex-col gap-4 w-full">
|
||||||
{postgres?.backups.length === 0 ? (
|
{postgres?.backups.length === 0 ? (
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
|
||||||
<DatabaseBackup className="size-8 text-muted-foreground" />
|
<DatabaseBackup className="size-8 text-muted-foreground" />
|
||||||
@@ -118,14 +147,15 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
No backups configured
|
No backups configured
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
|
||||||
<AddBackup
|
<HandleBackup
|
||||||
databaseId={id}
|
id={id}
|
||||||
databaseType={type}
|
databaseType={databaseType}
|
||||||
|
backupType={backupType}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
/>
|
/>
|
||||||
<RestoreBackup
|
<RestoreBackup
|
||||||
databaseId={id}
|
id={id}
|
||||||
databaseType={type}
|
databaseType={databaseType}
|
||||||
serverId={
|
serverId={
|
||||||
"serverId" in postgres ? postgres.serverId : undefined
|
"serverId" in postgres ? postgres.serverId : undefined
|
||||||
}
|
}
|
||||||
@@ -133,56 +163,118 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col pt-2">
|
<div className="flex flex-col pt-2 gap-4">
|
||||||
|
{backupType === "compose" && (
|
||||||
|
<AlertBlock title="Compose Backups">
|
||||||
|
Make sure the compose is running before creating a backup.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{postgres?.backups.map((backup) => (
|
{postgres?.backups.map((backup) => (
|
||||||
<div key={backup.backupId}>
|
<div key={backup.backupId}>
|
||||||
<div className="flex w-full flex-col md:flex-row md:items-center justify-between gap-4 md:gap-10 border rounded-lg p-4">
|
<div className="flex w-full flex-col md:flex-row md:items-start justify-between gap-4 border rounded-lg p-4 hover:bg-muted/50 transition-colors">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-6 flex-col gap-8">
|
<div className="flex flex-col w-full gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex items-center gap-3">
|
||||||
<span className="font-medium">Destination</span>
|
{backup.backupType === "compose" && (
|
||||||
<span className="text-sm text-muted-foreground">
|
<div className="flex items-center justify-center size-10 rounded-lg">
|
||||||
{backup.destination.name}
|
{backup.databaseType === "postgres" && (
|
||||||
</span>
|
<PostgresqlIcon className="size-7" />
|
||||||
|
)}
|
||||||
|
{backup.databaseType === "mysql" && (
|
||||||
|
<MysqlIcon className="size-7" />
|
||||||
|
)}
|
||||||
|
{backup.databaseType === "mariadb" && (
|
||||||
|
<MariadbIcon className="size-7" />
|
||||||
|
)}
|
||||||
|
{backup.databaseType === "mongo" && (
|
||||||
|
<MongodbIcon className="size-7" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{backup.backupType === "compose" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium">
|
||||||
|
{backup.serviceName}
|
||||||
|
</h3>
|
||||||
|
<span className="px-1.5 py-0.5 rounded-full bg-muted text-xs font-medium capitalize">
|
||||||
|
{backup.databaseType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"size-1.5 rounded-full",
|
||||||
|
backup.enabled
|
||||||
|
? "bg-green-500"
|
||||||
|
: "bg-red-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{backup.enabled ? "Active" : "Inactive"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<span className="font-medium">Database</span>
|
<div className="flex flex-wrap gap-x-8 gap-y-2">
|
||||||
<span className="text-sm text-muted-foreground">
|
<div className="min-w-[200px]">
|
||||||
{backup.database}
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
</span>
|
Destination
|
||||||
</div>
|
</span>
|
||||||
<div className="flex flex-col gap-1">
|
<p className="font-medium text-sm mt-0.5">
|
||||||
<span className="font-medium">Scheduled</span>
|
{backup.destination.name}
|
||||||
<span className="text-sm text-muted-foreground">
|
</p>
|
||||||
{backup.schedule}
|
</div>
|
||||||
</span>
|
|
||||||
</div>
|
<div className="min-w-[150px]">
|
||||||
<div className="flex flex-col gap-1">
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
<span className="font-medium">Prefix Storage</span>
|
Database
|
||||||
<span className="text-sm text-muted-foreground">
|
</span>
|
||||||
{backup.prefix}
|
<p className="font-medium text-sm mt-0.5">
|
||||||
</span>
|
{backup.database}
|
||||||
</div>
|
</p>
|
||||||
<div className="flex flex-col gap-1">
|
</div>
|
||||||
<span className="font-medium">Enabled</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
<div className="min-w-[120px]">
|
||||||
{backup.enabled ? "Yes" : "No"}
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
</span>
|
Schedule
|
||||||
</div>
|
</span>
|
||||||
<div className="flex flex-col gap-1">
|
<p className="font-medium text-sm mt-0.5">
|
||||||
<span className="font-medium">Keep Latest</span>
|
{backup.schedule}
|
||||||
<span className="text-sm text-muted-foreground">
|
</p>
|
||||||
{backup.keepLatestCount || "All"}
|
</div>
|
||||||
</span>
|
|
||||||
|
<div className="min-w-[150px]">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Prefix Storage
|
||||||
|
</span>
|
||||||
|
<p className="font-medium text-sm mt-0.5">
|
||||||
|
{backup.prefix}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-[100px]">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
Keep Latest
|
||||||
|
</span>
|
||||||
|
<p className="font-medium text-sm mt-0.5">
|
||||||
|
{backup.keepLatestCount || "All"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-4">
|
|
||||||
|
<div className="flex flex-row md:flex-col gap-1.5">
|
||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
isLoading={
|
isLoading={
|
||||||
isManualBackup &&
|
isManualBackup &&
|
||||||
activeManualBackup === backup.backupId
|
activeManualBackup === backup.backupId
|
||||||
@@ -205,14 +297,15 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
setActiveManualBackup(undefined);
|
setActiveManualBackup(undefined);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Play className="size-5 text-muted-foreground" />
|
<Play className="size-4 " />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Run Manual Backup</TooltipContent>
|
<TooltipContent>Run Manual Backup</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<UpdateBackup
|
<HandleBackup
|
||||||
|
backupType={backup.backupType}
|
||||||
backupId={backup.backupId}
|
backupId={backup.backupId}
|
||||||
refetch={refetch}
|
refetch={refetch}
|
||||||
/>
|
/>
|
||||||
@@ -236,7 +329,7 @@ export const ShowBackups = ({ id, type }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="group hover:bg-red-500/10"
|
className="group hover:bg-red-500/10 size-8"
|
||||||
isLoading={isRemoving}
|
isLoading={isRemoving}
|
||||||
>
|
>
|
||||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||||
|
|||||||
@@ -1,329 +0,0 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
} from "@/components/ui/command";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { CheckIcon, ChevronsUpDown, PenBoxIcon } from "lucide-react";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const UpdateBackupSchema = z.object({
|
|
||||||
destinationId: z.string().min(1, "Destination required"),
|
|
||||||
schedule: z.string().min(1, "Schedule (Cron) required"),
|
|
||||||
prefix: z.string().min(1, "Prefix required"),
|
|
||||||
enabled: z.boolean(),
|
|
||||||
database: z.string().min(1, "Database required"),
|
|
||||||
keepLatestCount: z.coerce.number().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type UpdateBackup = z.infer<typeof UpdateBackupSchema>;
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
backupId: string;
|
|
||||||
refetch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UpdateBackup = ({ backupId, refetch }: Props) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const { data, isLoading } = api.destination.all.useQuery();
|
|
||||||
const { data: backup } = api.backup.one.useQuery(
|
|
||||||
{
|
|
||||||
backupId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
enabled: !!backupId,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { mutateAsync, isLoading: isLoadingUpdate } =
|
|
||||||
api.backup.update.useMutation();
|
|
||||||
|
|
||||||
const form = useForm<UpdateBackup>({
|
|
||||||
defaultValues: {
|
|
||||||
database: "",
|
|
||||||
destinationId: "",
|
|
||||||
enabled: true,
|
|
||||||
prefix: "/",
|
|
||||||
schedule: "",
|
|
||||||
keepLatestCount: undefined,
|
|
||||||
},
|
|
||||||
resolver: zodResolver(UpdateBackupSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (backup) {
|
|
||||||
form.reset({
|
|
||||||
database: backup.database,
|
|
||||||
destinationId: backup.destinationId,
|
|
||||||
enabled: backup.enabled || false,
|
|
||||||
prefix: backup.prefix,
|
|
||||||
schedule: backup.schedule,
|
|
||||||
keepLatestCount: backup.keepLatestCount
|
|
||||||
? Number(backup.keepLatestCount)
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [form, form.reset, backup]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: UpdateBackup) => {
|
|
||||||
await mutateAsync({
|
|
||||||
backupId,
|
|
||||||
destinationId: data.destinationId,
|
|
||||||
prefix: data.prefix,
|
|
||||||
schedule: data.schedule,
|
|
||||||
enabled: data.enabled,
|
|
||||||
database: data.database,
|
|
||||||
keepLatestCount: data.keepLatestCount as number | null,
|
|
||||||
})
|
|
||||||
.then(async () => {
|
|
||||||
toast.success("Backup Updated");
|
|
||||||
refetch();
|
|
||||||
setIsOpen(false);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error updating the Backup");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="group hover:bg-blue-500/10 "
|
|
||||||
>
|
|
||||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Update Backup</DialogTitle>
|
|
||||||
<DialogDescription>Update the backup</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
id="hook-form-update-backup"
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="grid w-full gap-4"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="destinationId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="">
|
|
||||||
<FormLabel>Destination</FormLabel>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-between !bg-input",
|
|
||||||
!field.value && "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? "Loading...."
|
|
||||||
: field.value
|
|
||||||
? data?.find(
|
|
||||||
(destination) =>
|
|
||||||
destination.destinationId === field.value,
|
|
||||||
)?.name
|
|
||||||
: "Select Destination"}
|
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="p-0" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search Destination..."
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
{isLoading && (
|
|
||||||
<span className="py-6 text-center text-sm">
|
|
||||||
Loading Destinations....
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<CommandEmpty>No destinations found.</CommandEmpty>
|
|
||||||
<ScrollArea className="h-64">
|
|
||||||
<CommandGroup>
|
|
||||||
{data?.map((destination) => (
|
|
||||||
<CommandItem
|
|
||||||
value={destination.destinationId}
|
|
||||||
key={destination.destinationId}
|
|
||||||
onSelect={() => {
|
|
||||||
form.setValue(
|
|
||||||
"destinationId",
|
|
||||||
destination.destinationId,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{destination.name}
|
|
||||||
<CheckIcon
|
|
||||||
className={cn(
|
|
||||||
"ml-auto h-4 w-4",
|
|
||||||
destination.destinationId === field.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</ScrollArea>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="database"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Database</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"dokploy"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="schedule"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Schedule (Cron)</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"0 0 * * *"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="prefix"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Prefix Destination</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input placeholder={"dokploy/"} {...field} />
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Use if you want to back up in a specific path of your
|
|
||||||
destination/bucket
|
|
||||||
</FormDescription>
|
|
||||||
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="keepLatestCount"
|
|
||||||
render={({ field }) => {
|
|
||||||
return (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Keep the latest</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
placeholder={"keeps all the backups if left empty"}
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Optional. If provided, only keeps the latest N backups
|
|
||||||
in the cloud.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Enabled</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Enable or disable the backup
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
isLoading={isLoadingUpdate}
|
|
||||||
form="hook-form-update-backup"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -307,7 +307,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
|||||||
>
|
>
|
||||||
{templates?.map((template) => (
|
{templates?.map((template) => (
|
||||||
<div
|
<div
|
||||||
key={template.id}
|
key={template?.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col border rounded-lg overflow-hidden relative",
|
"flex flex-col border rounded-lg overflow-hidden relative",
|
||||||
viewMode === "icon" && "h-[200px]",
|
viewMode === "icon" && "h-[200px]",
|
||||||
@@ -315,7 +315,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Badge className="absolute top-2 right-2" variant="blue">
|
<Badge className="absolute top-2 right-2" variant="blue">
|
||||||
{template.version}
|
{template?.version}
|
||||||
</Badge>
|
</Badge>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -324,21 +324,21 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template.id}/${template.logo}`}
|
src={`${customBaseUrl || "https://templates.dokploy.com/"}/blueprints/${template?.id}/${template?.logo}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"object-contain",
|
"object-contain",
|
||||||
viewMode === "detailed" ? "size-24" : "size-16",
|
viewMode === "detailed" ? "size-24" : "size-16",
|
||||||
)}
|
)}
|
||||||
alt={template.name}
|
alt={template?.name}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<span className="text-sm font-medium line-clamp-1">
|
<span className="text-sm font-medium line-clamp-1">
|
||||||
{template.name}
|
{template?.name}
|
||||||
</span>
|
</span>
|
||||||
{viewMode === "detailed" &&
|
{viewMode === "detailed" &&
|
||||||
template.tags.length > 0 && (
|
template?.tags?.length > 0 && (
|
||||||
<div className="flex flex-wrap justify-center gap-1.5">
|
<div className="flex flex-wrap justify-center gap-1.5">
|
||||||
{template.tags.map((tag) => (
|
{template?.tags?.map((tag) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={tag}
|
key={tag}
|
||||||
variant="green"
|
variant="green"
|
||||||
@@ -356,7 +356,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
|||||||
{viewMode === "detailed" && (
|
{viewMode === "detailed" && (
|
||||||
<ScrollArea className="flex-1 p-6">
|
<ScrollArea className="flex-1 p-6">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{template.description}
|
{template?.description}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
@@ -372,25 +372,27 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
|||||||
>
|
>
|
||||||
{viewMode === "detailed" && (
|
{viewMode === "detailed" && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link
|
{template?.links?.github && (
|
||||||
href={template.links.github}
|
|
||||||
target="_blank"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<GithubIcon className="size-5" />
|
|
||||||
</Link>
|
|
||||||
{template.links.website && (
|
|
||||||
<Link
|
<Link
|
||||||
href={template.links.website}
|
href={template?.links?.github}
|
||||||
|
target="_blank"
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<GithubIcon className="size-5" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{template?.links?.website && (
|
||||||
|
<Link
|
||||||
|
href={template?.links?.website}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<Globe className="size-5" />
|
<Globe className="size-5" />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{template.links.docs && (
|
{template?.links?.docs && (
|
||||||
<Link
|
<Link
|
||||||
href={template.links.docs}
|
href={template?.links?.docs}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
@@ -419,7 +421,7 @@ export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
|||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will create an application from the{" "}
|
This will create an application from the{" "}
|
||||||
{template.name} template and add it to your
|
{template?.name} template and add it to your
|
||||||
project.
|
project.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const AddProjectSchema = z.object({
|
const AddProjectSchema = z.object({
|
||||||
name: z.string().min(1, {
|
name: z
|
||||||
message: "Name is required",
|
.string()
|
||||||
}),
|
.min(1, {
|
||||||
|
message: "Name is required",
|
||||||
|
})
|
||||||
|
.regex(/^[a-zA-Z]/, {
|
||||||
|
message: "Project name cannot start with a number",
|
||||||
|
}),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,18 +102,6 @@ export const HandleProject = ({ projectId }: Props) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
// useEffect(() => {
|
|
||||||
// const getUsers = async () => {
|
|
||||||
// const users = await authClient.admin.listUsers({
|
|
||||||
// query: {
|
|
||||||
// limit: 100,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
// console.log(users);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// getUsers();
|
|
||||||
// });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export const ShowProjects = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
|
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
|
||||||
{filteredProjects?.map((project) => {
|
{filteredProjects?.map((project) => {
|
||||||
const emptyServices =
|
const emptyServices =
|
||||||
project?.mariadb.length === 0 &&
|
project?.mariadb.length === 0 &&
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const AiForm = () => {
|
|||||||
key={config.aiId}
|
key={config.aiId}
|
||||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{config.name}
|
{config.name}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export const ShowCertificates = () => {
|
|||||||
key={certificate.certificateId}
|
key={certificate.certificateId}
|
||||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-2 flex-col">
|
<div className="flex gap-2 flex-col">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
|
|||||||
@@ -13,53 +13,65 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
|||||||
bytes[i] = binaryStr.charCodeAt(i);
|
bytes[i] = binaryStr.charCodeAt(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
let dateFound = 0;
|
// ASN.1 tag for UTCTime is 0x17, GeneralizedTime is 0x18
|
||||||
|
// We need to find the second occurrence of either tag as it's the "not after" (expiration) date
|
||||||
|
let dateFound = false;
|
||||||
for (let i = 0; i < bytes.length - 2; i++) {
|
for (let i = 0; i < bytes.length - 2; i++) {
|
||||||
if (bytes[i] === 0x17 || bytes[i] === 0x18) {
|
// Look for sequence containing validity period (0x30)
|
||||||
const dateType = bytes[i];
|
if (bytes[i] === 0x30) {
|
||||||
const dateLength = bytes[i + 1];
|
// Check next bytes for UTCTime or GeneralizedTime
|
||||||
if (typeof dateLength === "undefined") continue;
|
let j = i + 1;
|
||||||
|
while (j < bytes.length - 2) {
|
||||||
|
if (bytes[j] === 0x17 || bytes[j] === 0x18) {
|
||||||
|
const dateType = bytes[j];
|
||||||
|
const dateLength = bytes[j + 1];
|
||||||
|
if (typeof dateLength === "undefined") break;
|
||||||
|
|
||||||
if (dateFound === 0) {
|
if (!dateFound) {
|
||||||
dateFound++;
|
// Skip "not before" date
|
||||||
i += dateLength + 1;
|
dateFound = true;
|
||||||
continue;
|
j += dateLength + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Found "not after" date
|
||||||
|
let dateStr = "";
|
||||||
|
for (let k = 0; k < dateLength; k++) {
|
||||||
|
const charCode = bytes[j + 2 + k];
|
||||||
|
if (typeof charCode === "undefined") continue;
|
||||||
|
dateStr += String.fromCharCode(charCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateType === 0x17) {
|
||||||
|
// UTCTime (YYMMDDhhmmssZ)
|
||||||
|
const year = Number.parseInt(dateStr.slice(0, 2));
|
||||||
|
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
|
||||||
|
return new Date(
|
||||||
|
Date.UTC(
|
||||||
|
fullYear,
|
||||||
|
Number.parseInt(dateStr.slice(2, 4)) - 1,
|
||||||
|
Number.parseInt(dateStr.slice(4, 6)),
|
||||||
|
Number.parseInt(dateStr.slice(6, 8)),
|
||||||
|
Number.parseInt(dateStr.slice(8, 10)),
|
||||||
|
Number.parseInt(dateStr.slice(10, 12)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneralizedTime (YYYYMMDDhhmmssZ)
|
||||||
|
return new Date(
|
||||||
|
Date.UTC(
|
||||||
|
Number.parseInt(dateStr.slice(0, 4)),
|
||||||
|
Number.parseInt(dateStr.slice(4, 6)) - 1,
|
||||||
|
Number.parseInt(dateStr.slice(6, 8)),
|
||||||
|
Number.parseInt(dateStr.slice(8, 10)),
|
||||||
|
Number.parseInt(dateStr.slice(10, 12)),
|
||||||
|
Number.parseInt(dateStr.slice(12, 14)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
j++;
|
||||||
}
|
}
|
||||||
|
|
||||||
let dateStr = "";
|
|
||||||
for (let j = 0; j < dateLength; j++) {
|
|
||||||
const charCode = bytes[i + 2 + j];
|
|
||||||
if (typeof charCode === "undefined") continue;
|
|
||||||
dateStr += String.fromCharCode(charCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dateType === 0x17) {
|
|
||||||
// UTCTime (YYMMDDhhmmssZ)
|
|
||||||
const year = Number.parseInt(dateStr.slice(0, 2));
|
|
||||||
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
|
|
||||||
return new Date(
|
|
||||||
Date.UTC(
|
|
||||||
fullYear,
|
|
||||||
Number.parseInt(dateStr.slice(2, 4)) - 1,
|
|
||||||
Number.parseInt(dateStr.slice(4, 6)),
|
|
||||||
Number.parseInt(dateStr.slice(6, 8)),
|
|
||||||
Number.parseInt(dateStr.slice(8, 10)),
|
|
||||||
Number.parseInt(dateStr.slice(10, 12)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// GeneralizedTime (YYYYMMDDhhmmssZ)
|
|
||||||
return new Date(
|
|
||||||
Date.UTC(
|
|
||||||
Number.parseInt(dateStr.slice(0, 4)),
|
|
||||||
Number.parseInt(dateStr.slice(4, 6)) - 1,
|
|
||||||
Number.parseInt(dateStr.slice(6, 8)),
|
|
||||||
Number.parseInt(dateStr.slice(8, 10)),
|
|
||||||
Number.parseInt(dateStr.slice(10, 12)),
|
|
||||||
Number.parseInt(dateStr.slice(12, 14)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export const ShowRegistry = () => {
|
|||||||
key={registry.registryId}
|
key={registry.registryId}
|
||||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-2 flex-col">
|
<div className="flex gap-2 flex-col">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const ShowDestinations = () => {
|
|||||||
key={destination.destinationId}
|
key={destination.destinationId}
|
||||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{index + 1}. {destination.name}
|
{index + 1}. {destination.name}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { SparklesIcon } from "lucide-react";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { SetupMonitoring } from "./servers/setup-monitoring";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export const EnablePaidFeatures = () => {
|
||||||
|
const { data, refetch } = api.user.get.useQuery();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const { mutateAsync: saveLicense } = api.user.saveLicense.useMutation();
|
||||||
|
const { mutateAsync: deactivateLicense } =
|
||||||
|
api.user.deactivateLicense.useMutation();
|
||||||
|
const { mutateAsync: validateLicense } =
|
||||||
|
api.user.validateLicense.useMutation();
|
||||||
|
const { mutateAsync: update } = api.user.update.useMutation();
|
||||||
|
const [licenseKey, setLicenseKey] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.user?.enablePaidFeatures) {
|
||||||
|
setLicenseKey(data.user.licenseKey || "");
|
||||||
|
}
|
||||||
|
}, [data?.user?.enablePaidFeatures]);
|
||||||
|
|
||||||
|
const handleSaveLicense = async () => {
|
||||||
|
if (!licenseKey) {
|
||||||
|
toast.error("Please enter a license key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
await saveLicense({
|
||||||
|
licenseKey,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("License validated successfully");
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
toast.error("Error validating license", {
|
||||||
|
description: e.message,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
refetch();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValidateLicense = async () => {
|
||||||
|
if (!licenseKey) {
|
||||||
|
toast.error("Please enter a license key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
await validateLicense({
|
||||||
|
licenseKey,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success("License validated successfully");
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
toast.error("Error validating license", {
|
||||||
|
description: e.message,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||||
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-xl flex flex-row items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-primary/10">
|
||||||
|
<SparklesIcon className="size-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
Paid Features
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="mt-2">
|
||||||
|
Unlock advanced capabilities like monitoring and enhanced
|
||||||
|
performance tracking
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-row items-center justify-between p-4 border rounded-lg bg-card/50 hover:bg-card/80 transition-colors">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-medium">Enable Premium Features</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Access advanced monitoring tools and premium capabilities
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
className="ml-4"
|
||||||
|
checked={data?.user?.enablePaidFeatures}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
update({
|
||||||
|
enablePaidFeatures: !data?.user?.enablePaidFeatures,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
`Premium features ${
|
||||||
|
data?.user?.enablePaidFeatures
|
||||||
|
? "disabled"
|
||||||
|
: "enabled"
|
||||||
|
} successfully`,
|
||||||
|
);
|
||||||
|
refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error updating premium features");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data?.user?.enablePaidFeatures && (
|
||||||
|
<div className="flex flex-row items-center gap-4 p-4 border rounded-lg bg-card/50">
|
||||||
|
<div className="flex-grow">
|
||||||
|
<Input
|
||||||
|
placeholder="Enter your license key"
|
||||||
|
value={licenseKey}
|
||||||
|
disabled={data?.user?.licenseKey !== null}
|
||||||
|
onChange={(e) => setLicenseKey(e.target.value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!data?.user?.licenseKey ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveLicense}
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? "Saving..." : "Save License"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleValidateLicense}
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? "Validating..." : "Validate"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
deactivateLicense({
|
||||||
|
licenseKey,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setLicenseKey("");
|
||||||
|
toast.success("License removed successfully");
|
||||||
|
refetch();
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
toast.error("Error removing license", {
|
||||||
|
description: e.message,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
{data?.user?.enablePaidFeatures && <SetupMonitoring />}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -248,7 +248,9 @@ export const AddGitlabProvider = () => {
|
|||||||
name="groupName"
|
name="groupName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Group Name (Optional)</FormLabel>
|
<FormLabel>
|
||||||
|
Group Name (Optional, Comma-Separated List)
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="For organization/group access use the slugish name of the group eg: my-org"
|
placeholder="For organization/group access use the slugish name of the group eg: my-org"
|
||||||
|
|||||||
@@ -156,7 +156,9 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
|||||||
name="groupName"
|
name="groupName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Group Name (Optional)</FormLabel>
|
<FormLabel>
|
||||||
|
Group Name (Optional, Comma-Separated List)
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="For organization/group access use the slugish name of the group eg: my-org"
|
placeholder="For organization/group access use the slugish name of the group eg: my-org"
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const ShowNotifications = () => {
|
|||||||
key={notification.notificationId}
|
key={notification.notificationId}
|
||||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||||
<span className="text-sm flex flex-row items-center gap-4">
|
<span className="text-sm flex flex-row items-center gap-4">
|
||||||
{notification.notificationType === "slack" && (
|
{notification.notificationType === "slack" && (
|
||||||
<div className="flex items-center justify-center rounded-lg">
|
<div className="flex items-center justify-center rounded-lg">
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const PasswordSchema = z.object({
|
|||||||
password: z.string().min(8, {
|
password: z.string().min(8, {
|
||||||
message: "Password is required",
|
message: "Password is required",
|
||||||
}),
|
}),
|
||||||
|
issuer: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const PinSchema = z.object({
|
const PinSchema = z.object({
|
||||||
@@ -60,12 +61,86 @@ export const Enable2FA = () => {
|
|||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [step, setStep] = useState<"password" | "verify">("password");
|
const [step, setStep] = useState<"password" | "verify">("password");
|
||||||
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
|
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
|
||||||
|
const [otpValue, setOtpValue] = useState("");
|
||||||
|
|
||||||
|
const handleVerifySubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const result = await authClient.twoFactor.verifyTotp({
|
||||||
|
code: otpValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
|
||||||
|
toast.error("Invalid verification code");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.data) {
|
||||||
|
throw new Error("No response received from server");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("2FA configured successfully");
|
||||||
|
utils.user.get.invalidate();
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const errorMessage =
|
||||||
|
error.message === "Failed to fetch"
|
||||||
|
? "Connection error. Please check your internet connection."
|
||||||
|
: error.message;
|
||||||
|
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} else {
|
||||||
|
toast.error("Error verifying 2FA code", {
|
||||||
|
description: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordForm = useForm<PasswordForm>({
|
||||||
|
resolver: zodResolver(PasswordSchema),
|
||||||
|
defaultValues: {
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pinForm = useForm<PinForm>({
|
||||||
|
resolver: zodResolver(PinSchema),
|
||||||
|
defaultValues: {
|
||||||
|
pin: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDialogOpen) {
|
||||||
|
setStep("password");
|
||||||
|
setData(null);
|
||||||
|
setBackupCodes([]);
|
||||||
|
setOtpValue("");
|
||||||
|
passwordForm.reset({
|
||||||
|
password: "",
|
||||||
|
issuer: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isDialogOpen, passwordForm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step === "verify") {
|
||||||
|
setOtpValue("");
|
||||||
|
}
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
const handlePasswordSubmit = async (formData: PasswordForm) => {
|
const handlePasswordSubmit = async (formData: PasswordForm) => {
|
||||||
setIsPasswordLoading(true);
|
setIsPasswordLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data: enableData, error } = await authClient.twoFactor.enable({
|
const { data: enableData, error } = await authClient.twoFactor.enable({
|
||||||
password: formData.password,
|
password: formData.password,
|
||||||
|
issuer: formData.issuer,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!enableData) {
|
if (!enableData) {
|
||||||
@@ -103,75 +178,6 @@ export const Enable2FA = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVerifySubmit = async (formData: PinForm) => {
|
|
||||||
try {
|
|
||||||
const result = await authClient.twoFactor.verifyTotp({
|
|
||||||
code: formData.pin,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
|
|
||||||
pinForm.setError("pin", {
|
|
||||||
message: "Invalid code. Please try again.",
|
|
||||||
});
|
|
||||||
toast.error("Invalid verification code");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw result.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.data) {
|
|
||||||
throw new Error("No response received from server");
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success("2FA configured successfully");
|
|
||||||
utils.user.get.invalidate();
|
|
||||||
setIsDialogOpen(false);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
const errorMessage =
|
|
||||||
error.message === "Failed to fetch"
|
|
||||||
? "Connection error. Please check your internet connection."
|
|
||||||
: error.message;
|
|
||||||
|
|
||||||
pinForm.setError("pin", {
|
|
||||||
message: errorMessage,
|
|
||||||
});
|
|
||||||
toast.error(errorMessage);
|
|
||||||
} else {
|
|
||||||
pinForm.setError("pin", {
|
|
||||||
message: "Error verifying code",
|
|
||||||
});
|
|
||||||
toast.error("Error verifying 2FA code");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const passwordForm = useForm<PasswordForm>({
|
|
||||||
resolver: zodResolver(PasswordSchema),
|
|
||||||
defaultValues: {
|
|
||||||
password: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const pinForm = useForm<PinForm>({
|
|
||||||
resolver: zodResolver(PinSchema),
|
|
||||||
defaultValues: {
|
|
||||||
pin: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDialogOpen) {
|
|
||||||
setStep("password");
|
|
||||||
setData(null);
|
|
||||||
setBackupCodes([]);
|
|
||||||
passwordForm.reset();
|
|
||||||
pinForm.reset();
|
|
||||||
}
|
|
||||||
}, [isDialogOpen, passwordForm, pinForm]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@@ -217,6 +223,27 @@ export const Enable2FA = () => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={passwordForm.control}
|
||||||
|
name="issuer"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Issuer</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your issuer"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Use a custom issuer to identify the service you're
|
||||||
|
authenticating with.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -228,11 +255,7 @@ export const Enable2FA = () => {
|
|||||||
</Form>
|
</Form>
|
||||||
) : (
|
) : (
|
||||||
<Form {...pinForm}>
|
<Form {...pinForm}>
|
||||||
<form
|
<form onSubmit={handleVerifySubmit} className="space-y-6">
|
||||||
id="pin-form"
|
|
||||||
onSubmit={pinForm.handleSubmit(handleVerifySubmit)}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-6 justify-center items-center">
|
<div className="flex flex-col gap-6 justify-center items-center">
|
||||||
{data?.qrCodeUrl ? (
|
{data?.qrCodeUrl ? (
|
||||||
<>
|
<>
|
||||||
@@ -284,36 +307,33 @@ export const Enable2FA = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<div className="flex flex-col justify-center items-center">
|
||||||
control={pinForm.control}
|
<FormLabel>Verification Code</FormLabel>
|
||||||
name="pin"
|
<InputOTP
|
||||||
render={({ field }) => (
|
maxLength={6}
|
||||||
<FormItem className="flex flex-col justify-center items-center">
|
value={otpValue}
|
||||||
<FormLabel>Verification Code</FormLabel>
|
onChange={setOtpValue}
|
||||||
<FormControl>
|
autoComplete="off"
|
||||||
<InputOTP maxLength={6} {...field}>
|
>
|
||||||
<InputOTPGroup>
|
<InputOTPGroup>
|
||||||
<InputOTPSlot index={0} />
|
<InputOTPSlot index={0} />
|
||||||
<InputOTPSlot index={1} />
|
<InputOTPSlot index={1} />
|
||||||
<InputOTPSlot index={2} />
|
<InputOTPSlot index={2} />
|
||||||
<InputOTPSlot index={3} />
|
<InputOTPSlot index={3} />
|
||||||
<InputOTPSlot index={4} />
|
<InputOTPSlot index={4} />
|
||||||
<InputOTPSlot index={5} />
|
<InputOTPSlot index={5} />
|
||||||
</InputOTPGroup>
|
</InputOTPGroup>
|
||||||
</InputOTP>
|
</InputOTP>
|
||||||
</FormControl>
|
<FormDescription>
|
||||||
<FormDescription>
|
Enter the 6-digit code from your authenticator app
|
||||||
Enter the 6-digit code from your authenticator app
|
</FormDescription>
|
||||||
</FormDescription>
|
</div>
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
isLoading={isPasswordLoading}
|
isLoading={isPasswordLoading}
|
||||||
|
disabled={otpValue.length !== 6}
|
||||||
>
|
>
|
||||||
Enable 2FA
|
Enable 2FA
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const randomImages = [
|
|||||||
export const ProfileForm = () => {
|
export const ProfileForm = () => {
|
||||||
const _utils = api.useUtils();
|
const _utils = api.useUtils();
|
||||||
const { data, refetch, isLoading } = api.user.get.useQuery();
|
const { data, refetch, isLoading } = api.user.get.useQuery();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync,
|
mutateAsync,
|
||||||
isLoading: isUpdating,
|
isLoading: isUpdating,
|
||||||
@@ -84,12 +85,17 @@ export const ProfileForm = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset(
|
||||||
email: data?.user?.email || "",
|
{
|
||||||
password: "",
|
email: data?.user?.email || "",
|
||||||
image: data?.user?.image || "",
|
password: form.getValues("password") || "",
|
||||||
currentPassword: "",
|
image: data?.user?.image || "",
|
||||||
});
|
currentPassword: form.getValues("currentPassword") || "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
keepValues: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (data.user.email) {
|
if (data.user.email) {
|
||||||
generateSHA256Hash(data.user.email).then((hash) => {
|
generateSHA256Hash(data.user.email).then((hash) => {
|
||||||
@@ -97,8 +103,7 @@ export const ProfileForm = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
form.reset();
|
}, [form, data]);
|
||||||
}, [form, form.reset, data]);
|
|
||||||
|
|
||||||
const onSubmit = async (values: Profile) => {
|
const onSubmit = async (values: Profile) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -110,7 +115,12 @@ export const ProfileForm = () => {
|
|||||||
.then(async () => {
|
.then(async () => {
|
||||||
await refetch();
|
await refetch();
|
||||||
toast.success("Profile Updated");
|
toast.success("Profile Updated");
|
||||||
form.reset();
|
form.reset({
|
||||||
|
email: values.email,
|
||||||
|
password: "",
|
||||||
|
image: values.image,
|
||||||
|
currentPassword: "",
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error("Error updating the profile");
|
toast.error("Error updating the profile");
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ export const ShowDokployActions = () => {
|
|||||||
const { mutateAsync: reloadServer, isLoading } =
|
const { mutateAsync: reloadServer, isLoading } =
|
||||||
api.settings.reloadServer.useMutation();
|
api.settings.reloadServer.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation();
|
||||||
|
const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild disabled={isLoading}>
|
<DropdownMenuTrigger asChild disabled={isLoading}>
|
||||||
@@ -69,6 +72,36 @@ export const ShowDokployActions = () => {
|
|||||||
{t("settings.server.webServer.updateServerIp")}
|
{t("settings.server.webServer.updateServerIp")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</UpdateServerIp>
|
</UpdateServerIp>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={async () => {
|
||||||
|
await cleanRedis()
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Redis cleaned");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error cleaning Redis");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clean Redis
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={async () => {
|
||||||
|
await reloadRedis()
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Redis reloaded");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error("Error reloading Redis");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reload Redis
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export const SetupMonitoring = ({ serverId }: Props) => {
|
|||||||
|
|
||||||
const { mutateAsync } = serverId
|
const { mutateAsync } = serverId
|
||||||
? api.server.setupMonitoring.useMutation()
|
? api.server.setupMonitoring.useMutation()
|
||||||
: api.admin.setupMonitoring.useMutation();
|
: api.user.setupMonitoring.useMutation();
|
||||||
|
|
||||||
const generateToken = () => {
|
const generateToken = () => {
|
||||||
const array = new Uint8Array(64);
|
const array = new Uint8Array(64);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const ShowDestinations = () => {
|
|||||||
key={sshKey.sshKeyId}
|
key={sshKey.sshKeyId}
|
||||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export const ShowInvitations = () => {
|
|||||||
const { data, isLoading, refetch } =
|
const { data, isLoading, refetch } =
|
||||||
api.organization.allInvitations.useQuery();
|
api.organization.allInvitations.useQuery();
|
||||||
|
|
||||||
|
const { mutateAsync: removeInvitation } =
|
||||||
|
api.organization.removeInvitation.useMutation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||||
@@ -182,6 +185,22 @@ export const ShowInvitations = () => {
|
|||||||
Cancel Invitation
|
Cancel Invitation
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
onSelect={async (_e) => {
|
||||||
|
await removeInvitation({
|
||||||
|
invitationId: invitation.id,
|
||||||
|
}).then(() => {
|
||||||
|
refetch();
|
||||||
|
toast.success(
|
||||||
|
"Invitation removed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove Invitation
|
||||||
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { GlobeIcon } from "lucide-react";
|
import { GlobeIcon } from "lucide-react";
|
||||||
@@ -33,11 +35,19 @@ import { z } from "zod";
|
|||||||
|
|
||||||
const addServerDomain = z
|
const addServerDomain = z
|
||||||
.object({
|
.object({
|
||||||
domain: z.string().min(1, { message: "URL is required" }),
|
domain: z.string(),
|
||||||
letsEncryptEmail: z.string(),
|
letsEncryptEmail: z.string(),
|
||||||
|
https: z.boolean().optional(),
|
||||||
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
|
if (data.https && !data.certificateType) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["certificateType"],
|
||||||
|
message: "Required",
|
||||||
|
});
|
||||||
|
}
|
||||||
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
|
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
@@ -61,15 +71,18 @@ export const WebDomain = () => {
|
|||||||
domain: "",
|
domain: "",
|
||||||
certificateType: "none",
|
certificateType: "none",
|
||||||
letsEncryptEmail: "",
|
letsEncryptEmail: "",
|
||||||
|
https: false,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addServerDomain),
|
resolver: zodResolver(addServerDomain),
|
||||||
});
|
});
|
||||||
|
const https = form.watch("https");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
domain: data?.user?.host || "",
|
domain: data?.user?.host || "",
|
||||||
certificateType: data?.user?.certificateType,
|
certificateType: data?.user?.certificateType,
|
||||||
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
|
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
|
||||||
|
https: data?.user?.https || false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, form.reset, data]);
|
}, [form, form.reset, data]);
|
||||||
@@ -79,6 +92,7 @@ export const WebDomain = () => {
|
|||||||
host: data.domain,
|
host: data.domain,
|
||||||
letsEncryptEmail: data.letsEncryptEmail,
|
letsEncryptEmail: data.letsEncryptEmail,
|
||||||
certificateType: data.certificateType,
|
certificateType: data.certificateType,
|
||||||
|
https: data.https,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await refetch();
|
await refetch();
|
||||||
@@ -155,44 +169,67 @@ export const WebDomain = () => {
|
|||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="certificateType"
|
name="https"
|
||||||
render={({ field }) => {
|
render={({ field }) => (
|
||||||
return (
|
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm w-full col-span-2">
|
||||||
<FormItem className="md:col-span-2">
|
<div className="space-y-0.5">
|
||||||
<FormLabel>
|
<FormLabel>HTTPS</FormLabel>
|
||||||
{t("settings.server.domain.form.certificate.label")}
|
<FormDescription>
|
||||||
</FormLabel>
|
Automatically provision SSL Certificate.
|
||||||
<Select
|
</FormDescription>
|
||||||
onValueChange={field.onChange}
|
|
||||||
value={field.value}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t(
|
|
||||||
"settings.server.domain.form.certificate.placeholder",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={"none"}>
|
|
||||||
{t(
|
|
||||||
"settings.server.domain.form.certificateOptions.none",
|
|
||||||
)}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value={"letsencrypt"}>
|
|
||||||
{t(
|
|
||||||
"settings.server.domain.form.certificateOptions.letsencrypt",
|
|
||||||
)}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</div>
|
||||||
);
|
<FormControl>
|
||||||
}}
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
{https && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="certificateType"
|
||||||
|
render={({ field }) => {
|
||||||
|
return (
|
||||||
|
<FormItem className="md:col-span-2">
|
||||||
|
<FormLabel>
|
||||||
|
{t("settings.server.domain.form.certificate.label")}
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"settings.server.domain.form.certificate.placeholder",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={"none"}>
|
||||||
|
{t(
|
||||||
|
"settings.server.domain.form.certificateOptions.none",
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={"letsencrypt"}>
|
||||||
|
{t(
|
||||||
|
"settings.server.domain.form.certificateOptions.letsencrypt",
|
||||||
|
)}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex w-full justify-end col-span-2">
|
<div className="flex w-full justify-end col-span-2">
|
||||||
<Button isLoading={isLoading} type="submit">
|
<Button isLoading={isLoading} type="submit">
|
||||||
|
|||||||
@@ -120,17 +120,6 @@ export const UserNav = () => {
|
|||||||
Docker
|
Docker
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data?.role === "owner" && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
router.push("/dashboard/settings");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -144,17 +133,6 @@ export const UserNav = () => {
|
|||||||
Servers
|
Servers
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data?.role === "owner" && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
router.push("/dashboard/settings");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
|||||||
@@ -26,15 +26,20 @@ const dockerComposeServices = [
|
|||||||
{ label: "secrets", type: "keyword", info: "Define secrets" },
|
{ label: "secrets", type: "keyword", info: "Define secrets" },
|
||||||
].map((opt) => ({
|
].map((opt) => ({
|
||||||
...opt,
|
...opt,
|
||||||
apply: (view: EditorView, completion: Completion) => {
|
apply: (
|
||||||
|
view: EditorView,
|
||||||
|
completion: Completion,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
) => {
|
||||||
const insert = `${completion.label}:`;
|
const insert = `${completion.label}:`;
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
changes: {
|
changes: {
|
||||||
from: view.state.selection.main.from,
|
from,
|
||||||
to: view.state.selection.main.to,
|
to,
|
||||||
insert,
|
insert,
|
||||||
},
|
},
|
||||||
selection: { anchor: view.state.selection.main.from + insert.length },
|
selection: { anchor: from + insert.length },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -74,15 +79,20 @@ const dockerComposeServiceOptions = [
|
|||||||
{ label: "networks", type: "keyword", info: "Networks to join" },
|
{ label: "networks", type: "keyword", info: "Networks to join" },
|
||||||
].map((opt) => ({
|
].map((opt) => ({
|
||||||
...opt,
|
...opt,
|
||||||
apply: (view: EditorView, completion: Completion) => {
|
apply: (
|
||||||
|
view: EditorView,
|
||||||
|
completion: Completion,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
) => {
|
||||||
const insert = `${completion.label}: `;
|
const insert = `${completion.label}: `;
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
changes: {
|
changes: {
|
||||||
from: view.state.selection.main.from,
|
from,
|
||||||
to: view.state.selection.main.to,
|
to,
|
||||||
insert,
|
insert,
|
||||||
},
|
},
|
||||||
selection: { anchor: view.state.selection.main.from + insert.length },
|
selection: { anchor: from + insert.length },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -99,6 +109,7 @@ function dockerComposeComplete(
|
|||||||
const line = context.state.doc.lineAt(context.pos);
|
const line = context.state.doc.lineAt(context.pos);
|
||||||
const indentation = /^\s*/.exec(line.text)?.[0].length || 0;
|
const indentation = /^\s*/.exec(line.text)?.[0].length || 0;
|
||||||
|
|
||||||
|
// If we're at the root level
|
||||||
if (indentation === 0) {
|
if (indentation === 0) {
|
||||||
return {
|
return {
|
||||||
from: word.from,
|
from: word.from,
|
||||||
|
|||||||
1
apps/dokploy/drizzle/0084_thin_iron_lad.sql
Normal file
1
apps/dokploy/drizzle/0084_thin_iron_lad.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "user_temp" ADD COLUMN "https" boolean DEFAULT false NOT NULL;
|
||||||
2
apps/dokploy/drizzle/0085_equal_captain_stacy.sql
Normal file
2
apps/dokploy/drizzle/0085_equal_captain_stacy.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "application" ADD COLUMN "enableSubmodules" boolean DEFAULT false;--> statement-breakpoint
|
||||||
|
ALTER TABLE "compose" ADD COLUMN "enableSubmodules" boolean DEFAULT false;
|
||||||
2
apps/dokploy/drizzle/0086_rainy_gertrude_yorkes.sql
Normal file
2
apps/dokploy/drizzle/0086_rainy_gertrude_yorkes.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "application" ALTER COLUMN "enableSubmodules" SET NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "compose" ALTER COLUMN "enableSubmodules" SET NOT NULL;
|
||||||
3
apps/dokploy/drizzle/0087_lively_risque.sql
Normal file
3
apps/dokploy/drizzle/0087_lively_risque.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
CREATE TYPE "public"."triggerType" AS ENUM('push', 'tag');--> statement-breakpoint
|
||||||
|
ALTER TABLE "application" ADD COLUMN "triggerType" "triggerType" DEFAULT 'push';--> statement-breakpoint
|
||||||
|
ALTER TABLE "compose" ADD COLUMN "triggerType" "triggerType" DEFAULT 'push';
|
||||||
5
apps/dokploy/drizzle/0088_same_ezekiel.sql
Normal file
5
apps/dokploy/drizzle/0088_same_ezekiel.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TYPE "public"."backupType" AS ENUM('database', 'compose');--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" ADD COLUMN "serviceName" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" ADD COLUMN "backupType" "backupType" DEFAULT 'database' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" ADD COLUMN "composeId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "backup" ADD CONSTRAINT "backup_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;
|
||||||
1
apps/dokploy/drizzle/0089_dazzling_marrow.sql
Normal file
1
apps/dokploy/drizzle/0089_dazzling_marrow.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "backup" ADD COLUMN "metadata" jsonb;
|
||||||
1
apps/dokploy/drizzle/0090_tiny_phil_sheldon.sql
Normal file
1
apps/dokploy/drizzle/0090_tiny_phil_sheldon.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "user_temp" ADD COLUMN "licenseKey" text;
|
||||||
5369
apps/dokploy/drizzle/meta/0084_snapshot.json
Normal file
5369
apps/dokploy/drizzle/meta/0084_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5383
apps/dokploy/drizzle/meta/0085_snapshot.json
Normal file
5383
apps/dokploy/drizzle/meta/0085_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5383
apps/dokploy/drizzle/meta/0086_snapshot.json
Normal file
5383
apps/dokploy/drizzle/meta/0086_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5407
apps/dokploy/drizzle/meta/0087_snapshot.json
Normal file
5407
apps/dokploy/drizzle/meta/0087_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5448
apps/dokploy/drizzle/meta/0088_snapshot.json
Normal file
5448
apps/dokploy/drizzle/meta/0088_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5454
apps/dokploy/drizzle/meta/0089_snapshot.json
Normal file
5454
apps/dokploy/drizzle/meta/0089_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5460
apps/dokploy/drizzle/meta/0090_snapshot.json
Normal file
5460
apps/dokploy/drizzle/meta/0090_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -589,6 +589,55 @@
|
|||||||
"when": 1743288371413,
|
"when": 1743288371413,
|
||||||
"tag": "0083_parallel_stranger",
|
"tag": "0083_parallel_stranger",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 84,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1743923992280,
|
||||||
|
"tag": "0084_thin_iron_lad",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 85,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1745705609181,
|
||||||
|
"tag": "0085_equal_captain_stacy",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 86,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1745706676004,
|
||||||
|
"tag": "0086_rainy_gertrude_yorkes",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 87,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1745723563822,
|
||||||
|
"tag": "0087_lively_risque",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 88,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1745801614194,
|
||||||
|
"tag": "0088_same_ezekiel",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 89,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1745812150155,
|
||||||
|
"tag": "0089_dazzling_marrow",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 90,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1746156076450,
|
||||||
|
"tag": "0090_tiny_phil_sheldon",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Sorted list based off of population of the country / speakers of the language.
|
||||||
|
*/
|
||||||
export const Languages = {
|
export const Languages = {
|
||||||
english: { code: "en", name: "English" },
|
english: { code: "en", name: "English" },
|
||||||
|
spanish: { code: "es", name: "Español" },
|
||||||
|
chineseSimplified: { code: "zh-Hans", name: "简体中文" },
|
||||||
|
chineseTraditional: { code: "zh-Hant", name: "繁體中文" },
|
||||||
|
portuguese: { code: "pt-br", name: "Português" },
|
||||||
|
russian: { code: "ru", name: "Русский" },
|
||||||
|
japanese: { code: "ja", name: "日本語" },
|
||||||
|
german: { code: "de", name: "Deutsch" },
|
||||||
|
korean: { code: "ko", name: "한국어" },
|
||||||
|
french: { code: "fr", name: "Français" },
|
||||||
|
turkish: { code: "tr", name: "Türkçe" },
|
||||||
|
italian: { code: "it", name: "Italiano" },
|
||||||
polish: { code: "pl", name: "Polski" },
|
polish: { code: "pl", name: "Polski" },
|
||||||
ukrainian: { code: "uk", name: "Українська" },
|
ukrainian: { code: "uk", name: "Українська" },
|
||||||
russian: { code: "ru", name: "Русский" },
|
|
||||||
french: { code: "fr", name: "Français" },
|
|
||||||
german: { code: "de", name: "Deutsch" },
|
|
||||||
chineseTraditional: { code: "zh-Hant", name: "繁體中文" },
|
|
||||||
chineseSimplified: { code: "zh-Hans", name: "简体中文" },
|
|
||||||
turkish: { code: "tr", name: "Türkçe" },
|
|
||||||
kazakh: { code: "kz", name: "Қазақ" },
|
|
||||||
persian: { code: "fa", name: "فارسی" },
|
persian: { code: "fa", name: "فارسی" },
|
||||||
korean: { code: "ko", name: "한국어" },
|
dutch: { code: "nl", name: "Nederlands" },
|
||||||
portuguese: { code: "pt-br", name: "Português" },
|
indonesian: { code: "id", name: "Bahasa Indonesia" },
|
||||||
italian: { code: "it", name: "Italiano" },
|
kazakh: { code: "kz", name: "Қазақ" },
|
||||||
japanese: { code: "ja", name: "日本語" },
|
|
||||||
spanish: { code: "es", name: "Español" },
|
|
||||||
norwegian: { code: "no", name: "Norsk" },
|
norwegian: { code: "no", name: "Norsk" },
|
||||||
azerbaijani: { code: "az", name: "Azərbaycan" },
|
azerbaijani: { code: "az", name: "Azərbaycan" },
|
||||||
indonesian: { code: "id", name: "Bahasa Indonesia" },
|
|
||||||
malayalam: { code: "ml", name: "മലയാളം" },
|
malayalam: { code: "ml", name: "മലയാളം" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.21.1",
|
"version": "v0.21.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
"adm-zip": "^0.5.14",
|
"adm-zip": "^0.5.14",
|
||||||
"ai": "^4.0.23",
|
"ai": "^4.0.23",
|
||||||
"bcrypt": "5.1.1",
|
"bcrypt": "5.1.1",
|
||||||
"better-auth": "1.2.4",
|
"better-auth": "1.2.6",
|
||||||
"bl": "6.0.11",
|
"bl": "6.0.11",
|
||||||
"boxen": "^7.1.1",
|
"boxen": "^7.1.1",
|
||||||
"bullmq": "5.4.2",
|
"bullmq": "5.4.2",
|
||||||
|
|||||||
@@ -89,6 +89,115 @@ export default async function handler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle tag creation event
|
||||||
|
if (
|
||||||
|
req.headers["x-github-event"] === "push" &&
|
||||||
|
githubBody?.ref?.startsWith("refs/tags/")
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const tagName = githubBody?.ref.replace("refs/tags/", "");
|
||||||
|
const repository = githubBody?.repository?.name;
|
||||||
|
const owner = githubBody?.repository?.owner?.name;
|
||||||
|
const deploymentTitle = `Tag created: ${tagName}`;
|
||||||
|
const deploymentHash = extractHash(req.headers, githubBody);
|
||||||
|
|
||||||
|
// Find applications configured to deploy on tag
|
||||||
|
const apps = await db.query.applications.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(applications.sourceType, "github"),
|
||||||
|
eq(applications.autoDeploy, true),
|
||||||
|
eq(applications.triggerType, "tag"),
|
||||||
|
eq(applications.repository, repository),
|
||||||
|
eq(applications.owner, owner),
|
||||||
|
eq(applications.githubId, githubResult.githubId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const app of apps) {
|
||||||
|
const jobData: DeploymentJob = {
|
||||||
|
applicationId: app.applicationId as string,
|
||||||
|
titleLog: deploymentTitle,
|
||||||
|
descriptionLog: `Hash: ${deploymentHash}`,
|
||||||
|
type: "deploy",
|
||||||
|
applicationType: "application",
|
||||||
|
server: !!app.serverId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (IS_CLOUD && app.serverId) {
|
||||||
|
jobData.serverId = app.serverId;
|
||||||
|
await deploy(jobData);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await myQueue.add(
|
||||||
|
"deployments",
|
||||||
|
{ ...jobData },
|
||||||
|
{
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find compose apps configured to deploy on tag
|
||||||
|
const composeApps = await db.query.compose.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(compose.sourceType, "github"),
|
||||||
|
eq(compose.autoDeploy, true),
|
||||||
|
eq(compose.triggerType, "tag"),
|
||||||
|
eq(compose.repository, repository),
|
||||||
|
eq(compose.owner, owner),
|
||||||
|
eq(compose.githubId, githubResult.githubId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const composeApp of composeApps) {
|
||||||
|
const jobData: DeploymentJob = {
|
||||||
|
composeId: composeApp.composeId as string,
|
||||||
|
titleLog: deploymentTitle,
|
||||||
|
type: "deploy",
|
||||||
|
applicationType: "compose",
|
||||||
|
descriptionLog: `Hash: ${deploymentHash}`,
|
||||||
|
server: !!composeApp.serverId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (IS_CLOUD && composeApp.serverId) {
|
||||||
|
jobData.serverId = composeApp.serverId;
|
||||||
|
await deploy(jobData);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await myQueue.add(
|
||||||
|
"deployments",
|
||||||
|
{ ...jobData },
|
||||||
|
{
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalApps = apps.length + composeApps.length;
|
||||||
|
|
||||||
|
if (totalApps === 0) {
|
||||||
|
res
|
||||||
|
.status(200)
|
||||||
|
.json({ message: "No apps configured to deploy on tag" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
message: `Deployed ${totalApps} apps based on tag ${tagName}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deploying applications on tag:", error);
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ message: "Error deploying applications on tag", error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (req.headers["x-github-event"] === "push") {
|
if (req.headers["x-github-event"] === "push") {
|
||||||
try {
|
try {
|
||||||
const branchName = githubBody?.ref?.replace("refs/heads/", "");
|
const branchName = githubBody?.ref?.replace("refs/heads/", "");
|
||||||
@@ -105,6 +214,7 @@ export default async function handler(
|
|||||||
where: and(
|
where: and(
|
||||||
eq(applications.sourceType, "github"),
|
eq(applications.sourceType, "github"),
|
||||||
eq(applications.autoDeploy, true),
|
eq(applications.autoDeploy, true),
|
||||||
|
eq(applications.triggerType, "push"),
|
||||||
eq(applications.branch, branchName),
|
eq(applications.branch, branchName),
|
||||||
eq(applications.repository, repository),
|
eq(applications.repository, repository),
|
||||||
eq(applications.owner, owner),
|
eq(applications.owner, owner),
|
||||||
@@ -150,6 +260,7 @@ export default async function handler(
|
|||||||
where: and(
|
where: and(
|
||||||
eq(compose.sourceType, "github"),
|
eq(compose.sourceType, "github"),
|
||||||
eq(compose.autoDeploy, true),
|
eq(compose.autoDeploy, true),
|
||||||
|
eq(compose.triggerType, "push"),
|
||||||
eq(compose.branch, branchName),
|
eq(compose.branch, branchName),
|
||||||
eq(compose.repository, repository),
|
eq(compose.repository, repository),
|
||||||
eq(compose.owner, owner),
|
eq(compose.owner, owner),
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
|
||||||
import { ShowPaidMonitoring } from "@/components/dashboard/monitoring/paid/servers/show-paid-monitoring";
|
import { ShowPaidMonitoring } from "@/components/dashboard/monitoring/paid/servers/show-paid-monitoring";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||||
@@ -23,7 +26,7 @@ const Dashboard = () => {
|
|||||||
const { data: monitoring, isLoading } = api.user.getMetricsToken.useQuery();
|
const { data: monitoring, isLoading } = api.user.getMetricsToken.useQuery();
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 pb-10">
|
<div className="space-y-4 pb-10">
|
||||||
{/* <AlertBlock>
|
<AlertBlock>
|
||||||
You are watching the <strong>Free</strong> plan.{" "}
|
You are watching the <strong>Free</strong> plan.{" "}
|
||||||
<a
|
<a
|
||||||
href="https://dokploy.com#pricing"
|
href="https://dokploy.com#pricing"
|
||||||
@@ -34,7 +37,7 @@ const Dashboard = () => {
|
|||||||
Upgrade
|
Upgrade
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
to get more features.
|
to get more features.
|
||||||
</AlertBlock> */}
|
</AlertBlock>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Card className="bg-sidebar p-2.5 rounded-xl mx-auto items-center">
|
<Card className="bg-sidebar p-2.5 rounded-xl mx-auto items-center">
|
||||||
<div className="rounded-xl bg-background flex shadow-md px-4 min-h-[50vh] justify-center items-center text-muted-foreground">
|
<div className="rounded-xl bg-background flex shadow-md px-4 min-h-[50vh] justify-center items-center text-muted-foreground">
|
||||||
@@ -44,15 +47,15 @@ const Dashboard = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* {monitoring?.enabledFeatures && (
|
{monitoring?.enabledFeatures && (
|
||||||
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
|
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
|
||||||
<Label className="text-muted-foreground">Change Monitoring</Label>
|
<Label className="text-muted-foreground">Change Monitoring</Label>
|
||||||
<Switch
|
<Switch
|
||||||
checked={toggleMonitoring}
|
checked={toggleMonitoring}
|
||||||
onCheckedChange={setToggleMonitoring}
|
onCheckedChange={_setToggleMonitoring}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)} */}
|
)}
|
||||||
{toggleMonitoring ? (
|
{toggleMonitoring ? (
|
||||||
<Card className="bg-sidebar p-2.5 rounded-xl mx-auto">
|
<Card className="bg-sidebar p-2.5 rounded-xl mx-auto">
|
||||||
<div className="rounded-xl bg-background shadow-md">
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
|||||||
@@ -314,31 +314,43 @@ const Project = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applicationActions = {
|
const applicationActions = {
|
||||||
|
start: api.application.start.useMutation(),
|
||||||
|
stop: api.application.stop.useMutation(),
|
||||||
move: api.application.move.useMutation(),
|
move: api.application.move.useMutation(),
|
||||||
delete: api.application.delete.useMutation(),
|
delete: api.application.delete.useMutation(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const postgresActions = {
|
const postgresActions = {
|
||||||
|
start: api.postgres.start.useMutation(),
|
||||||
|
stop: api.postgres.stop.useMutation(),
|
||||||
move: api.postgres.move.useMutation(),
|
move: api.postgres.move.useMutation(),
|
||||||
delete: api.postgres.remove.useMutation(),
|
delete: api.postgres.remove.useMutation(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mysqlActions = {
|
const mysqlActions = {
|
||||||
|
start: api.mysql.start.useMutation(),
|
||||||
|
stop: api.mysql.stop.useMutation(),
|
||||||
move: api.mysql.move.useMutation(),
|
move: api.mysql.move.useMutation(),
|
||||||
delete: api.mysql.remove.useMutation(),
|
delete: api.mysql.remove.useMutation(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mariadbActions = {
|
const mariadbActions = {
|
||||||
|
start: api.mariadb.start.useMutation(),
|
||||||
|
stop: api.mariadb.stop.useMutation(),
|
||||||
move: api.mariadb.move.useMutation(),
|
move: api.mariadb.move.useMutation(),
|
||||||
delete: api.mariadb.remove.useMutation(),
|
delete: api.mariadb.remove.useMutation(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const redisActions = {
|
const redisActions = {
|
||||||
|
start: api.redis.start.useMutation(),
|
||||||
|
stop: api.redis.stop.useMutation(),
|
||||||
move: api.redis.move.useMutation(),
|
move: api.redis.move.useMutation(),
|
||||||
delete: api.redis.remove.useMutation(),
|
delete: api.redis.remove.useMutation(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mongoActions = {
|
const mongoActions = {
|
||||||
|
start: api.mongo.start.useMutation(),
|
||||||
|
stop: api.mongo.stop.useMutation(),
|
||||||
move: api.mongo.move.useMutation(),
|
move: api.mongo.move.useMutation(),
|
||||||
delete: api.mongo.remove.useMutation(),
|
delete: api.mongo.remove.useMutation(),
|
||||||
};
|
};
|
||||||
@@ -348,7 +360,34 @@ const Project = (
|
|||||||
setIsBulkActionLoading(true);
|
setIsBulkActionLoading(true);
|
||||||
for (const serviceId of selectedServices) {
|
for (const serviceId of selectedServices) {
|
||||||
try {
|
try {
|
||||||
await composeActions.start.mutateAsync({ composeId: serviceId });
|
const service = filteredServices.find((s) => s.id === serviceId);
|
||||||
|
if (!service) continue;
|
||||||
|
|
||||||
|
switch (service.type) {
|
||||||
|
case "application":
|
||||||
|
await applicationActions.start.mutateAsync({
|
||||||
|
applicationId: serviceId,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "compose":
|
||||||
|
await composeActions.start.mutateAsync({ composeId: serviceId });
|
||||||
|
break;
|
||||||
|
case "postgres":
|
||||||
|
await postgresActions.start.mutateAsync({ postgresId: serviceId });
|
||||||
|
break;
|
||||||
|
case "mysql":
|
||||||
|
await mysqlActions.start.mutateAsync({ mysqlId: serviceId });
|
||||||
|
break;
|
||||||
|
case "mariadb":
|
||||||
|
await mariadbActions.start.mutateAsync({ mariadbId: serviceId });
|
||||||
|
break;
|
||||||
|
case "redis":
|
||||||
|
await redisActions.start.mutateAsync({ redisId: serviceId });
|
||||||
|
break;
|
||||||
|
case "mongo":
|
||||||
|
await mongoActions.start.mutateAsync({ mongoId: serviceId });
|
||||||
|
break;
|
||||||
|
}
|
||||||
success++;
|
success++;
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
toast.error(`Error starting service ${serviceId}`);
|
toast.error(`Error starting service ${serviceId}`);
|
||||||
@@ -368,7 +407,34 @@ const Project = (
|
|||||||
setIsBulkActionLoading(true);
|
setIsBulkActionLoading(true);
|
||||||
for (const serviceId of selectedServices) {
|
for (const serviceId of selectedServices) {
|
||||||
try {
|
try {
|
||||||
await composeActions.stop.mutateAsync({ composeId: serviceId });
|
const service = filteredServices.find((s) => s.id === serviceId);
|
||||||
|
if (!service) continue;
|
||||||
|
|
||||||
|
switch (service.type) {
|
||||||
|
case "application":
|
||||||
|
await applicationActions.stop.mutateAsync({
|
||||||
|
applicationId: serviceId,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "compose":
|
||||||
|
await composeActions.stop.mutateAsync({ composeId: serviceId });
|
||||||
|
break;
|
||||||
|
case "postgres":
|
||||||
|
await postgresActions.stop.mutateAsync({ postgresId: serviceId });
|
||||||
|
break;
|
||||||
|
case "mysql":
|
||||||
|
await mysqlActions.stop.mutateAsync({ mysqlId: serviceId });
|
||||||
|
break;
|
||||||
|
case "mariadb":
|
||||||
|
await mariadbActions.stop.mutateAsync({ mariadbId: serviceId });
|
||||||
|
break;
|
||||||
|
case "redis":
|
||||||
|
await redisActions.stop.mutateAsync({ redisId: serviceId });
|
||||||
|
break;
|
||||||
|
case "mongo":
|
||||||
|
await mongoActions.stop.mutateAsync({ mongoId: serviceId });
|
||||||
|
break;
|
||||||
|
}
|
||||||
success++;
|
success++;
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
toast.error(`Error stopping service ${serviceId}`);
|
toast.error(`Error stopping service ${serviceId}`);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -65,7 +66,7 @@ type TabState =
|
|||||||
const Service = (
|
const Service = (
|
||||||
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
props: InferGetServerSidePropsType<typeof getServerSideProps>,
|
||||||
) => {
|
) => {
|
||||||
const [_toggleMonitoring, _setToggleMonitoring] = useState(false);
|
const [_toggleMonitoring, _setToggleMonitoring] = useState(true);
|
||||||
const { applicationId, activeTab } = props;
|
const { applicationId, activeTab } = props;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { projectId } = router.query;
|
const { projectId } = router.query;
|
||||||
@@ -86,6 +87,7 @@ const Service = (
|
|||||||
|
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
|
const { data: monitoring } = api.user.getMetricsToken.useQuery();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-10">
|
<div className="pb-10">
|
||||||
@@ -215,7 +217,7 @@ const Service = (
|
|||||||
router.push(newPath);
|
router.push(newPath);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
|
||||||
<TabsList
|
<TabsList
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex gap-8 justify-start max-xl:overflow-x-scroll overflow-y-hidden",
|
"flex gap-8 justify-start max-xl:overflow-x-scroll overflow-y-hidden",
|
||||||
@@ -265,21 +267,19 @@ const Service = (
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* {monitoring?.enabledFeatures &&
|
{monitoring?.enabledFeatures && (
|
||||||
isCloud &&
|
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
|
||||||
data?.serverId && (
|
<Label className="text-muted-foreground">
|
||||||
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
|
Change Monitoring
|
||||||
<Label className="text-muted-foreground">
|
</Label>
|
||||||
Change Monitoring
|
<Switch
|
||||||
</Label>
|
checked={_toggleMonitoring}
|
||||||
<Switch
|
onCheckedChange={_setToggleMonitoring}
|
||||||
checked={toggleMonitoring}
|
/>
|
||||||
onCheckedChange={setToggleMonitoring}
|
</div>
|
||||||
/>
|
)}
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
|
|
||||||
{/* {toggleMonitoring ? (
|
{_toggleMonitoring ? (
|
||||||
<ContainerPaidMonitoring
|
<ContainerPaidMonitoring
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
baseUrl={`http://${monitoring?.serverIp}:${monitoring?.metricsConfig?.server?.port}`}
|
baseUrl={`http://${monitoring?.serverIp}:${monitoring?.metricsConfig?.server?.port}`}
|
||||||
@@ -287,13 +287,13 @@ const Service = (
|
|||||||
monitoring?.metricsConfig?.server?.token || ""
|
monitoring?.metricsConfig?.server?.token || ""
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : ( */}
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<ContainerFreeMonitoring
|
<ContainerFreeMonitoring
|
||||||
appName={data?.appName || ""}
|
appName={data?.appName || ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* )} */}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show"
|
|||||||
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
|
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
|
||||||
import { ShowDockerLogsStack } from "@/components/dashboard/compose/logs/show-stack";
|
import { ShowDockerLogsStack } from "@/components/dashboard/compose/logs/show-stack";
|
||||||
import { UpdateCompose } from "@/components/dashboard/compose/update-compose";
|
import { UpdateCompose } from "@/components/dashboard/compose/update-compose";
|
||||||
|
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
|
||||||
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
|
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
|
||||||
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
|
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
|
||||||
import { ProjectLayout } from "@/components/layouts/project-layout";
|
import { ProjectLayout } from "@/components/layouts/project-layout";
|
||||||
@@ -212,21 +213,22 @@ const Service = (
|
|||||||
router.push(newPath);
|
router.push(newPath);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
|
||||||
<TabsList
|
<TabsList
|
||||||
className={cn(
|
className={cn(
|
||||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
"lg:grid lg:w-fit max-md:overflow-y-scroll justify-start",
|
||||||
isCloud && data?.serverId
|
isCloud && data?.serverId
|
||||||
? "md:grid-cols-7"
|
? "lg:grid-cols-8"
|
||||||
: data?.serverId
|
: data?.serverId
|
||||||
? "md:grid-cols-6"
|
? "lg:grid-cols-7"
|
||||||
: "md:grid-cols-7",
|
: "lg:grid-cols-8",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TabsTrigger value="general">General</TabsTrigger>
|
<TabsTrigger value="general">General</TabsTrigger>
|
||||||
<TabsTrigger value="environment">Environment</TabsTrigger>
|
<TabsTrigger value="environment">Environment</TabsTrigger>
|
||||||
<TabsTrigger value="domains">Domains</TabsTrigger>
|
<TabsTrigger value="domains">Domains</TabsTrigger>
|
||||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||||
|
<TabsTrigger value="backups">Backups</TabsTrigger>
|
||||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||||
{((data?.serverId && isCloud) || !data?.server) && (
|
{((data?.serverId && isCloud) || !data?.server) && (
|
||||||
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
|
||||||
@@ -245,6 +247,11 @@ const Service = (
|
|||||||
<ShowEnvironment id={composeId} type="compose" />
|
<ShowEnvironment id={composeId} type="compose" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="backups">
|
||||||
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
|
<ShowBackups id={composeId} backupType="compose" />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="monitoring">
|
<TabsContent value="monitoring">
|
||||||
<div className="pt-2.5">
|
<div className="pt-2.5">
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ const Mariadb = (
|
|||||||
router.push(newPath, undefined, { shallow: true });
|
router.push(newPath, undefined, { shallow: true });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
|
||||||
<TabsList
|
<TabsList
|
||||||
className={cn(
|
className={cn(
|
||||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
||||||
@@ -271,7 +271,7 @@ const Mariadb = (
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="backups">
|
<TabsContent value="backups">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowBackups id={mariadbId} type="mariadb" />
|
<ShowBackups id={mariadbId} databaseType="mariadb" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="advanced">
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ const Mongo = (
|
|||||||
router.push(newPath, undefined, { shallow: true });
|
router.push(newPath, undefined, { shallow: true });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
|
||||||
<TabsList
|
<TabsList
|
||||||
className={cn(
|
className={cn(
|
||||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
||||||
@@ -272,7 +272,11 @@ const Mongo = (
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="backups">
|
<TabsContent value="backups">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowBackups id={mongoId} type="mongo" />
|
<ShowBackups
|
||||||
|
id={mongoId}
|
||||||
|
databaseType="mongo"
|
||||||
|
backupType="database"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="advanced">
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ const MySql = (
|
|||||||
router.push(newPath, undefined, { shallow: true });
|
router.push(newPath, undefined, { shallow: true });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
|
||||||
<TabsList
|
<TabsList
|
||||||
className={cn(
|
className={cn(
|
||||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start ",
|
"md:grid md:w-fit max-md:overflow-y-scroll justify-start ",
|
||||||
@@ -252,7 +252,11 @@ const MySql = (
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="backups">
|
<TabsContent value="backups">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowBackups id={mysqlId} type="mysql" />
|
<ShowBackups
|
||||||
|
id={mysqlId}
|
||||||
|
databaseType="mysql"
|
||||||
|
backupType="database"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="advanced">
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ const Postgresql = (
|
|||||||
router.push(newPath, undefined, { shallow: true });
|
router.push(newPath, undefined, { shallow: true });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
|
||||||
<TabsList
|
<TabsList
|
||||||
className={cn(
|
className={cn(
|
||||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
||||||
@@ -251,7 +251,11 @@ const Postgresql = (
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="backups">
|
<TabsContent value="backups">
|
||||||
<div className="flex flex-col gap-4 pt-2.5">
|
<div className="flex flex-col gap-4 pt-2.5">
|
||||||
<ShowBackups id={postgresId} type="postgres" />
|
<ShowBackups
|
||||||
|
id={postgresId}
|
||||||
|
databaseType="postgres"
|
||||||
|
backupType="database"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="advanced">
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ const Redis = (
|
|||||||
router.push(newPath, undefined, { shallow: true });
|
router.push(newPath, undefined, { shallow: true });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
|
||||||
<TabsList
|
<TabsList
|
||||||
className={cn(
|
className={cn(
|
||||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
|
||||||
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { DialogFooter } from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { appRouter } from "@/server/api/root";
|
|
||||||
import { api } from "@/utils/api";
|
|
||||||
import { validateRequest } from "@dokploy/server";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
|
||||||
import { Settings } from "lucide-react";
|
|
||||||
import type { GetServerSidePropsContext } from "next";
|
|
||||||
import { type ReactElement, useEffect } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import superjson from "superjson";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
const settings = z.object({
|
|
||||||
cleanCacheOnApplications: z.boolean(),
|
|
||||||
cleanCacheOnCompose: z.boolean(),
|
|
||||||
cleanCacheOnPreviews: z.boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type SettingsType = z.infer<typeof settings>;
|
|
||||||
|
|
||||||
const Page = () => {
|
|
||||||
const { data, refetch } = api.user.get.useQuery();
|
|
||||||
const { mutateAsync, isLoading, isError, error } =
|
|
||||||
api.user.update.useMutation();
|
|
||||||
const form = useForm<SettingsType>({
|
|
||||||
defaultValues: {
|
|
||||||
cleanCacheOnApplications: false,
|
|
||||||
cleanCacheOnCompose: false,
|
|
||||||
cleanCacheOnPreviews: false,
|
|
||||||
},
|
|
||||||
resolver: zodResolver(settings),
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
form.reset({
|
|
||||||
cleanCacheOnApplications: data?.user.cleanupCacheApplications || false,
|
|
||||||
cleanCacheOnCompose: data?.user.cleanupCacheOnCompose || false,
|
|
||||||
cleanCacheOnPreviews: data?.user.cleanupCacheOnPreviews || false,
|
|
||||||
});
|
|
||||||
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
|
|
||||||
|
|
||||||
const onSubmit = async (values: SettingsType) => {
|
|
||||||
await mutateAsync({
|
|
||||||
cleanupCacheApplications: values.cleanCacheOnApplications,
|
|
||||||
cleanupCacheOnCompose: values.cleanCacheOnCompose,
|
|
||||||
cleanupCacheOnPreviews: values.cleanCacheOnPreviews,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
toast.success("Settings updated");
|
|
||||||
refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Something went wrong");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
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="">
|
|
||||||
<CardTitle className="text-xl flex flex-row gap-2">
|
|
||||||
<Settings className="size-6 text-muted-foreground self-center" />
|
|
||||||
Settings
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>Manage your Dokploy settings</CardDescription>
|
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
id="hook-form-add-security"
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="grid w-full gap-2"
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="cleanCacheOnApplications"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Clean Cache on Applications</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Clean the cache after every application deployment
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="cleanCacheOnPreviews"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Clean Cache on Previews</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Clean the cache after every preview deployment
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="cleanCacheOnCompose"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>Clean Cache on Compose</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
Clean the cache after every compose deployment
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
isLoading={isLoading}
|
|
||||||
form="hook-form-add-security"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</CardContent>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
|
|
||||||
Page.getLayout = (page: ReactElement) => {
|
|
||||||
return <DashboardLayout metaName="Server">{page}</DashboardLayout>;
|
|
||||||
};
|
|
||||||
export async function getServerSideProps(
|
|
||||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
|
||||||
) {
|
|
||||||
const { req, res } = ctx;
|
|
||||||
const { user, session } = await validateRequest(ctx.req);
|
|
||||||
if (!user) {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
permanent: true,
|
|
||||||
destination: "/",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (user.role === "member") {
|
|
||||||
return {
|
|
||||||
redirect: {
|
|
||||||
permanent: true,
|
|
||||||
destination: "/dashboard/settings/profile",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
trpcState: helpers.dehydrate(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { WebDomain } from "@/components/dashboard/settings/web-domain";
|
import { WebDomain } from "@/components/dashboard/settings/web-domain";
|
||||||
import { WebServer } from "@/components/dashboard/settings/web-server";
|
import { WebServer } from "@/components/dashboard/settings/web-server";
|
||||||
|
import { EnablePaidFeatures } from "@/components/dashboard/settings/enable-paid-features";
|
||||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||||
import { appRouter } from "@/server/api/root";
|
import { appRouter } from "@/server/api/root";
|
||||||
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
import { getLocale, serverSideTranslations } from "@/utils/i18n";
|
||||||
@@ -12,6 +13,7 @@ import { api } from "@/utils/api";
|
|||||||
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
|
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data: user } = api.user.get.useQuery();
|
const { data: user } = api.user.get.useQuery();
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -20,9 +22,14 @@ const Page = () => {
|
|||||||
<WebServer />
|
<WebServer />
|
||||||
<div className="w-full flex flex-col gap-4">
|
<div className="w-full flex flex-col gap-4">
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||||
<ShowBackups id={user?.userId ?? ""} type="web-server" />
|
<ShowBackups
|
||||||
|
id={user?.userId ?? ""}
|
||||||
|
databaseType="web-server"
|
||||||
|
backupType="database"
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
{!isCloud && <EnablePaidFeatures />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
1
apps/dokploy/public/locales/nl/common.json
Normal file
1
apps/dokploy/public/locales/nl/common.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
58
apps/dokploy/public/locales/nl/settings.json
Normal file
58
apps/dokploy/public/locales/nl/settings.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"settings.common.save": "Opslaan",
|
||||||
|
"settings.common.enterTerminal": "Terminal",
|
||||||
|
"settings.server.domain.title": "Server Domein",
|
||||||
|
"settings.server.domain.description": "Voeg een domein toe aan jouw server applicatie.",
|
||||||
|
"settings.server.domain.form.domain": "Domein",
|
||||||
|
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt Email",
|
||||||
|
"settings.server.domain.form.certificate.label": "Certificaat Aanbieder",
|
||||||
|
"settings.server.domain.form.certificate.placeholder": "Select een certificaat",
|
||||||
|
"settings.server.domain.form.certificateOptions.none": "Geen",
|
||||||
|
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
|
||||||
|
|
||||||
|
"settings.server.webServer.title": "Web Server",
|
||||||
|
"settings.server.webServer.description": "Herlaad of maak de web server schoon.",
|
||||||
|
"settings.server.webServer.actions": "Acties",
|
||||||
|
"settings.server.webServer.reload": "Herladen",
|
||||||
|
"settings.server.webServer.watchLogs": "Bekijk Logs",
|
||||||
|
"settings.server.webServer.updateServerIp": "Update de Server IP",
|
||||||
|
"settings.server.webServer.server.label": "Server",
|
||||||
|
"settings.server.webServer.traefik.label": "Traefik",
|
||||||
|
"settings.server.webServer.traefik.modifyEnv": "Bewerk Omgeving",
|
||||||
|
"settings.server.webServer.traefik.managePorts": "Extra Poort Mappings",
|
||||||
|
"settings.server.webServer.traefik.managePortsDescription": "Bewerk extra Poorten voor Traefik",
|
||||||
|
"settings.server.webServer.traefik.targetPort": "Doel Poort",
|
||||||
|
"settings.server.webServer.traefik.publishedPort": "Gepubliceerde Poort",
|
||||||
|
"settings.server.webServer.traefik.addPort": "Voeg Poort toe",
|
||||||
|
"settings.server.webServer.traefik.portsUpdated": "Poorten succesvol aangepast",
|
||||||
|
"settings.server.webServer.traefik.portsUpdateError": "Poorten niet succesvol aangepast",
|
||||||
|
"settings.server.webServer.traefik.publishMode": "Publiceer Mode",
|
||||||
|
"settings.server.webServer.storage.label": "Opslag",
|
||||||
|
"settings.server.webServer.storage.cleanUnusedImages": "Maak ongebruikte images schoon",
|
||||||
|
"settings.server.webServer.storage.cleanUnusedVolumes": "Maak ongebruikte volumes schoon",
|
||||||
|
"settings.server.webServer.storage.cleanStoppedContainers": "Maak gestopte containers schoon",
|
||||||
|
"settings.server.webServer.storage.cleanDockerBuilder": "Maak Docker Builder & Systeem schoon",
|
||||||
|
"settings.server.webServer.storage.cleanMonitoring": "Maak monitoor schoon",
|
||||||
|
"settings.server.webServer.storage.cleanAll": "Maak alles schoon",
|
||||||
|
|
||||||
|
"settings.profile.title": "Account",
|
||||||
|
"settings.profile.description": "Veramder details van account.",
|
||||||
|
"settings.profile.email": "Email",
|
||||||
|
"settings.profile.password": "Wachtwoord",
|
||||||
|
"settings.profile.avatar": "Profiel Icoon",
|
||||||
|
|
||||||
|
"settings.appearance.title": "Uiterlijk",
|
||||||
|
"settings.appearance.description": "Verander het thema van je dashboard.",
|
||||||
|
"settings.appearance.theme": "Thema",
|
||||||
|
"settings.appearance.themeDescription": "Selecteer een thema voor je dashboard.",
|
||||||
|
"settings.appearance.themes.light": "Licht",
|
||||||
|
"settings.appearance.themes.dark": "Donker",
|
||||||
|
"settings.appearance.themes.system": "Systeem",
|
||||||
|
"settings.appearance.language": "Taal",
|
||||||
|
"settings.appearance.languageDescription": "Selecteer een taal voor je dashboard.",
|
||||||
|
|
||||||
|
"settings.terminal.connectionSettings": "Verbindings instellingen",
|
||||||
|
"settings.terminal.ipAddress": "IP Address",
|
||||||
|
"settings.terminal.port": "Poort",
|
||||||
|
"settings.terminal.username": "Gebruikersnaam"
|
||||||
|
}
|
||||||
@@ -1 +1,78 @@
|
|||||||
{}
|
{
|
||||||
|
"dashboard.title": "仪表盘",
|
||||||
|
"dashboard.overview": "概览",
|
||||||
|
"dashboard.projects": "项目",
|
||||||
|
"dashboard.servers": "服务器",
|
||||||
|
"dashboard.docker": "Docker",
|
||||||
|
"dashboard.monitoring": "监控",
|
||||||
|
"dashboard.settings": "设置",
|
||||||
|
"dashboard.logout": "退出登录",
|
||||||
|
"dashboard.profile": "个人资料",
|
||||||
|
"dashboard.terminal": "终端",
|
||||||
|
"dashboard.containers": "容器",
|
||||||
|
"dashboard.images": "镜像",
|
||||||
|
"dashboard.volumes": "卷",
|
||||||
|
"dashboard.networks": "网络",
|
||||||
|
"button.create": "创建",
|
||||||
|
"button.edit": "编辑",
|
||||||
|
"button.delete": "删除",
|
||||||
|
"button.cancel": "取消",
|
||||||
|
"button.save": "保存",
|
||||||
|
"button.confirm": "确认",
|
||||||
|
"button.back": "返回",
|
||||||
|
"button.next": "下一步",
|
||||||
|
"button.finish": "完成",
|
||||||
|
"status.running": "运行中",
|
||||||
|
"status.stopped": "已停止",
|
||||||
|
"status.error": "错误",
|
||||||
|
"status.pending": "等待中",
|
||||||
|
"status.success": "成功",
|
||||||
|
"status.failed": "失败",
|
||||||
|
"form.required": "必填",
|
||||||
|
"form.invalid": "无效",
|
||||||
|
"form.submit": "提交",
|
||||||
|
"form.reset": "重置",
|
||||||
|
"notification.success": "操作成功",
|
||||||
|
"notification.error": "操作失败",
|
||||||
|
"notification.warning": "警告",
|
||||||
|
"notification.info": "信息",
|
||||||
|
"time.now": "刚刚",
|
||||||
|
"time.minutes": "分钟前",
|
||||||
|
"time.hours": "小时前",
|
||||||
|
"time.days": "天前",
|
||||||
|
"filter.all": "全部",
|
||||||
|
"filter.active": "活跃",
|
||||||
|
"filter.inactive": "不活跃",
|
||||||
|
"sort.asc": "升序",
|
||||||
|
"sort.desc": "降序",
|
||||||
|
"search.placeholder": "搜索...",
|
||||||
|
"search.noResults": "无结果",
|
||||||
|
"pagination.prev": "上一页",
|
||||||
|
"pagination.next": "下一页",
|
||||||
|
"pagination.of": "共 {0} 页",
|
||||||
|
"error.notFound": "未找到",
|
||||||
|
"error.serverError": "服务器错误",
|
||||||
|
"error.unauthorized": "未授权",
|
||||||
|
"error.forbidden": "禁止访问",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"empty": "暂无数据",
|
||||||
|
"more": "更多",
|
||||||
|
"less": "收起",
|
||||||
|
"project.create": "创建项目",
|
||||||
|
"project.edit": "编辑项目",
|
||||||
|
"project.delete": "删除项目",
|
||||||
|
"project.name": "项目名称",
|
||||||
|
"project.description": "项目描述",
|
||||||
|
"service.create": "创建服务",
|
||||||
|
"service.edit": "编辑服务",
|
||||||
|
"service.delete": "删除服务",
|
||||||
|
"service.name": "服务名称",
|
||||||
|
"service.type": "服务类型",
|
||||||
|
"domain.add": "添加域名",
|
||||||
|
"domain.remove": "移除域名",
|
||||||
|
"environment.variables": "环境变量",
|
||||||
|
"environment.add": "添加环境变量",
|
||||||
|
"environment.edit": "编辑环境变量",
|
||||||
|
"environment.name": "变量名",
|
||||||
|
"environment.value": "变量值"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
{
|
{
|
||||||
"settings.common.save": "保存",
|
"settings.common.save": "保存",
|
||||||
"settings.common.enterTerminal": "进入终端",
|
"settings.common.enterTerminal": "终端",
|
||||||
"settings.server.domain.title": "域名设置",
|
"settings.server.domain.title": "服务器域名",
|
||||||
"settings.server.domain.description": "添加域名到服务器",
|
"settings.server.domain.description": "为您的服务器应用添加域名。",
|
||||||
"settings.server.domain.form.domain": "域名",
|
"settings.server.domain.form.domain": "域名",
|
||||||
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt 邮箱",
|
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt 邮箱",
|
||||||
"settings.server.domain.form.certificate.label": "证书",
|
"settings.server.domain.form.certificate.label": "证书提供商",
|
||||||
"settings.server.domain.form.certificate.placeholder": "选择一个证书",
|
"settings.server.domain.form.certificate.placeholder": "选择证书",
|
||||||
"settings.server.domain.form.certificateOptions.none": "无",
|
"settings.server.domain.form.certificateOptions.none": "无",
|
||||||
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
|
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
|
||||||
|
"settings.server.webServer.title": "Web 服务器",
|
||||||
"settings.server.webServer.title": "服务器设置",
|
"settings.server.webServer.description": "重载或清理 Web 服务器。",
|
||||||
"settings.server.webServer.description": "管理服务器",
|
|
||||||
"settings.server.webServer.actions": "操作",
|
"settings.server.webServer.actions": "操作",
|
||||||
"settings.server.webServer.reload": "重新加载",
|
"settings.server.webServer.reload": "重新加载",
|
||||||
"settings.server.webServer.watchLogs": "查看日志",
|
"settings.server.webServer.watchLogs": "查看日志",
|
||||||
@@ -19,40 +18,50 @@
|
|||||||
"settings.server.webServer.server.label": "服务器",
|
"settings.server.webServer.server.label": "服务器",
|
||||||
"settings.server.webServer.traefik.label": "Traefik",
|
"settings.server.webServer.traefik.label": "Traefik",
|
||||||
"settings.server.webServer.traefik.modifyEnv": "修改环境变量",
|
"settings.server.webServer.traefik.modifyEnv": "修改环境变量",
|
||||||
"settings.server.webServer.traefik.managePorts": "端口转发",
|
"settings.server.webServer.traefik.managePorts": "额外端口映射",
|
||||||
"settings.server.webServer.traefik.managePortsDescription": "添加或删除 Traefik 的其他端口",
|
"settings.server.webServer.traefik.managePortsDescription": "为 Traefik 添加或删除额外端口",
|
||||||
"settings.server.webServer.traefik.targetPort": "目标端口",
|
"settings.server.webServer.traefik.targetPort": "目标端口",
|
||||||
"settings.server.webServer.traefik.publishedPort": "对外端口",
|
"settings.server.webServer.traefik.publishedPort": "发布端口",
|
||||||
"settings.server.webServer.traefik.addPort": "添加端口",
|
"settings.server.webServer.traefik.addPort": "添加端口",
|
||||||
"settings.server.webServer.traefik.portsUpdated": "端口更新成功",
|
"settings.server.webServer.traefik.portsUpdated": "端口更新成功",
|
||||||
"settings.server.webServer.traefik.portsUpdateError": "端口更新失败",
|
"settings.server.webServer.traefik.portsUpdateError": "端口更新失败",
|
||||||
"settings.server.webServer.traefik.publishMode": "端口映射",
|
"settings.server.webServer.traefik.publishMode": "发布模式",
|
||||||
"settings.server.webServer.storage.label": "存储空间",
|
"settings.server.webServer.storage.label": "存储空间",
|
||||||
"settings.server.webServer.storage.cleanUnusedImages": "清理未使用的镜像",
|
"settings.server.webServer.storage.cleanUnusedImages": "清理未使用的镜像",
|
||||||
"settings.server.webServer.storage.cleanUnusedVolumes": "清理未使用的卷",
|
"settings.server.webServer.storage.cleanUnusedVolumes": "清理未使用的卷",
|
||||||
"settings.server.webServer.storage.cleanStoppedContainers": "清理已停止的容器",
|
"settings.server.webServer.storage.cleanStoppedContainers": "清理已停止的容器",
|
||||||
"settings.server.webServer.storage.cleanDockerBuilder": "清理 Docker Builder 与 系统缓存",
|
"settings.server.webServer.storage.cleanDockerBuilder": "清理 Docker Builder 和系统",
|
||||||
"settings.server.webServer.storage.cleanMonitoring": "清理监控数据",
|
"settings.server.webServer.storage.cleanMonitoring": "清理监控数据",
|
||||||
"settings.server.webServer.storage.cleanAll": "清理所有内容",
|
"settings.server.webServer.storage.cleanAll": "清理所有内容",
|
||||||
|
|
||||||
"settings.profile.title": "账户",
|
"settings.profile.title": "账户",
|
||||||
"settings.profile.description": "更改您的个人资料",
|
"settings.profile.description": "在此更改您的个人资料详情。",
|
||||||
"settings.profile.email": "邮箱",
|
"settings.profile.email": "邮箱",
|
||||||
"settings.profile.password": "密码",
|
"settings.profile.password": "密码",
|
||||||
"settings.profile.avatar": "头像",
|
"settings.profile.avatar": "头像",
|
||||||
|
|
||||||
"settings.appearance.title": "外观",
|
"settings.appearance.title": "外观",
|
||||||
"settings.appearance.description": "自定义面板主题",
|
"settings.appearance.description": "自定义您的仪表盘主题。",
|
||||||
"settings.appearance.theme": "主题",
|
"settings.appearance.theme": "主题",
|
||||||
"settings.appearance.themeDescription": "选择面板主题",
|
"settings.appearance.themeDescription": "为您的仪表盘选择主题",
|
||||||
"settings.appearance.themes.light": "明亮",
|
"settings.appearance.themes.light": "明亮",
|
||||||
"settings.appearance.themes.dark": "黑暗",
|
"settings.appearance.themes.dark": "暗黑",
|
||||||
"settings.appearance.themes.system": "系统主题",
|
"settings.appearance.themes.system": "跟随系统",
|
||||||
"settings.appearance.language": "语言",
|
"settings.appearance.language": "语言",
|
||||||
"settings.appearance.languageDescription": "选择面板语言",
|
"settings.appearance.languageDescription": "为您的仪表盘选择语言",
|
||||||
|
"settings.terminal.connectionSettings": "连接设置",
|
||||||
"settings.terminal.connectionSettings": "终端设置",
|
"settings.terminal.ipAddress": "IP 地址",
|
||||||
"settings.terminal.ipAddress": "IP",
|
|
||||||
"settings.terminal.port": "端口",
|
"settings.terminal.port": "端口",
|
||||||
"settings.terminal.username": "用户名"
|
"settings.terminal.username": "用户名",
|
||||||
|
"settings.settings": "设置",
|
||||||
|
"settings.general": "通用设置",
|
||||||
|
"settings.security": "安全",
|
||||||
|
"settings.users": "用户管理",
|
||||||
|
"settings.roles": "角色管理",
|
||||||
|
"settings.permissions": "权限",
|
||||||
|
"settings.api": "API设置",
|
||||||
|
"settings.certificates": "证书管理",
|
||||||
|
"settings.ssh": "SSH密钥",
|
||||||
|
"settings.backups": "备份",
|
||||||
|
"settings.logs": "日志",
|
||||||
|
"settings.updates": "更新",
|
||||||
|
"settings.network": "网络"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createTRPCRouter } from "../api/trpc";
|
import { createTRPCRouter } from "../api/trpc";
|
||||||
import { adminRouter } from "./routers/admin";
|
|
||||||
import { aiRouter } from "./routers/ai";
|
import { aiRouter } from "./routers/ai";
|
||||||
import { applicationRouter } from "./routers/application";
|
import { applicationRouter } from "./routers/application";
|
||||||
import { backupRouter } from "./routers/backup";
|
import { backupRouter } from "./routers/backup";
|
||||||
@@ -42,7 +41,6 @@ import { userRouter } from "./routers/user";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
admin: adminRouter,
|
|
||||||
docker: dockerRouter,
|
docker: dockerRouter,
|
||||||
project: projectRouter,
|
project: projectRouter,
|
||||||
application: applicationRouter,
|
application: applicationRouter,
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { apiUpdateWebServerMonitoring } from "@/server/db/schema";
|
|
||||||
import {
|
|
||||||
IS_CLOUD,
|
|
||||||
findUserById,
|
|
||||||
setupWebMonitoring,
|
|
||||||
updateUser,
|
|
||||||
} from "@dokploy/server";
|
|
||||||
import { TRPCError } from "@trpc/server";
|
|
||||||
import { adminProcedure, createTRPCRouter } from "../trpc";
|
|
||||||
|
|
||||||
export const adminRouter = createTRPCRouter({
|
|
||||||
setupMonitoring: adminProcedure
|
|
||||||
.input(apiUpdateWebServerMonitoring)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
|
||||||
try {
|
|
||||||
if (IS_CLOUD) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: "UNAUTHORIZED",
|
|
||||||
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, {
|
|
||||||
metricsConfig: {
|
|
||||||
server: {
|
|
||||||
type: "Dokploy",
|
|
||||||
refreshRate: input.metricsConfig.server.refreshRate,
|
|
||||||
port: input.metricsConfig.server.port,
|
|
||||||
token: input.metricsConfig.server.token,
|
|
||||||
cronJob: input.metricsConfig.server.cronJob,
|
|
||||||
urlCallback: input.metricsConfig.server.urlCallback,
|
|
||||||
retentionDays: input.metricsConfig.server.retentionDays,
|
|
||||||
thresholds: {
|
|
||||||
cpu: input.metricsConfig.server.thresholds.cpu,
|
|
||||||
memory: input.metricsConfig.server.thresholds.memory,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
containers: {
|
|
||||||
refreshRate: input.metricsConfig.containers.refreshRate,
|
|
||||||
services: {
|
|
||||||
include: input.metricsConfig.containers.services.include || [],
|
|
||||||
exclude: input.metricsConfig.containers.services.exclude || [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentServer = await setupWebMonitoring(user.id);
|
|
||||||
return currentServer;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
findApplicationById,
|
findApplicationById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
getApplicationStats,
|
getApplicationStats,
|
||||||
|
mechanizeDockerContainer,
|
||||||
readConfig,
|
readConfig,
|
||||||
readRemoteConfig,
|
readRemoteConfig,
|
||||||
removeDeployments,
|
removeDeployments,
|
||||||
@@ -132,28 +133,36 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
.input(apiReloadApplication)
|
.input(apiReloadApplication)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const application = await findApplicationById(input.applicationId);
|
const application = await findApplicationById(input.applicationId);
|
||||||
if (
|
|
||||||
application.project.organizationId !== ctx.session.activeOrganizationId
|
try {
|
||||||
) {
|
if (
|
||||||
|
application.project.organizationId !==
|
||||||
|
ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to reload this application",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (application.serverId) {
|
||||||
|
await stopServiceRemote(application.serverId, input.appName);
|
||||||
|
} else {
|
||||||
|
await stopService(input.appName);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateApplicationStatus(input.applicationId, "idle");
|
||||||
|
await mechanizeDockerContainer(application);
|
||||||
|
await updateApplicationStatus(input.applicationId, "done");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
await updateApplicationStatus(input.applicationId, "error");
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
message: "You are not authorized to reload this application",
|
message: "Error reloading application",
|
||||||
|
cause: error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (application.serverId) {
|
|
||||||
await stopServiceRemote(application.serverId, input.appName);
|
|
||||||
} else {
|
|
||||||
await stopService(input.appName);
|
|
||||||
}
|
|
||||||
await updateApplicationStatus(input.applicationId, "idle");
|
|
||||||
|
|
||||||
if (application.serverId) {
|
|
||||||
await startServiceRemote(application.serverId, input.appName);
|
|
||||||
} else {
|
|
||||||
await startService(input.appName);
|
|
||||||
}
|
|
||||||
await updateApplicationStatus(input.applicationId, "done");
|
|
||||||
return true;
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
delete: protectedProcedure
|
delete: protectedProcedure
|
||||||
@@ -346,6 +355,8 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
applicationStatus: "idle",
|
applicationStatus: "idle",
|
||||||
githubId: input.githubId,
|
githubId: input.githubId,
|
||||||
watchPaths: input.watchPaths,
|
watchPaths: input.watchPaths,
|
||||||
|
triggerType: input.triggerType,
|
||||||
|
enableSubmodules: input.enableSubmodules,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -373,6 +384,7 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
gitlabProjectId: input.gitlabProjectId,
|
gitlabProjectId: input.gitlabProjectId,
|
||||||
gitlabPathNamespace: input.gitlabPathNamespace,
|
gitlabPathNamespace: input.gitlabPathNamespace,
|
||||||
watchPaths: input.watchPaths,
|
watchPaths: input.watchPaths,
|
||||||
|
enableSubmodules: input.enableSubmodules,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -398,6 +410,7 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
applicationStatus: "idle",
|
applicationStatus: "idle",
|
||||||
bitbucketId: input.bitbucketId,
|
bitbucketId: input.bitbucketId,
|
||||||
watchPaths: input.watchPaths,
|
watchPaths: input.watchPaths,
|
||||||
|
enableSubmodules: input.enableSubmodules,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -423,6 +436,7 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
applicationStatus: "idle",
|
applicationStatus: "idle",
|
||||||
giteaId: input.giteaId,
|
giteaId: input.giteaId,
|
||||||
watchPaths: input.watchPaths,
|
watchPaths: input.watchPaths,
|
||||||
|
enableSubmodules: input.enableSubmodules,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -470,6 +484,7 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
sourceType: "git",
|
sourceType: "git",
|
||||||
applicationStatus: "idle",
|
applicationStatus: "idle",
|
||||||
watchPaths: input.watchPaths,
|
watchPaths: input.watchPaths,
|
||||||
|
enableSubmodules: input.enableSubmodules,
|
||||||
});
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
createBackup,
|
createBackup,
|
||||||
findBackupById,
|
findBackupById,
|
||||||
|
findComposeByBackupId,
|
||||||
|
findComposeById,
|
||||||
findMariadbByBackupId,
|
findMariadbByBackupId,
|
||||||
findMariadbById,
|
findMariadbById,
|
||||||
findMongoByBackupId,
|
findMongoByBackupId,
|
||||||
@@ -31,12 +33,17 @@ import {
|
|||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
|
|
||||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||||
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
|
import { runComposeBackup } from "@dokploy/server/utils/backups/compose";
|
||||||
|
import {
|
||||||
|
getS3Credentials,
|
||||||
|
normalizeS3Path,
|
||||||
|
} from "@dokploy/server/utils/backups/utils";
|
||||||
import {
|
import {
|
||||||
execAsync,
|
execAsync,
|
||||||
execAsyncRemote,
|
execAsyncRemote,
|
||||||
} from "@dokploy/server/utils/process/execAsync";
|
} from "@dokploy/server/utils/process/execAsync";
|
||||||
import {
|
import {
|
||||||
|
restoreComposeBackup,
|
||||||
restoreMariadbBackup,
|
restoreMariadbBackup,
|
||||||
restoreMongoBackup,
|
restoreMongoBackup,
|
||||||
restoreMySqlBackup,
|
restoreMySqlBackup,
|
||||||
@@ -47,6 +54,18 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { observable } from "@trpc/server/observable";
|
import { observable } from "@trpc/server/observable";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
interface RcloneFile {
|
||||||
|
Path: string;
|
||||||
|
Name: string;
|
||||||
|
Size: number;
|
||||||
|
IsDir: boolean;
|
||||||
|
Tier?: string;
|
||||||
|
Hashes?: {
|
||||||
|
MD5?: string;
|
||||||
|
SHA1?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const backupRouter = createTRPCRouter({
|
export const backupRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(apiCreateBackup)
|
.input(apiCreateBackup)
|
||||||
@@ -67,6 +86,11 @@ export const backupRouter = createTRPCRouter({
|
|||||||
serverId = backup.mongo.serverId;
|
serverId = backup.mongo.serverId;
|
||||||
} else if (databaseType === "mariadb" && backup.mariadb?.serverId) {
|
} else if (databaseType === "mariadb" && backup.mariadb?.serverId) {
|
||||||
serverId = backup.mariadb.serverId;
|
serverId = backup.mariadb.serverId;
|
||||||
|
} else if (
|
||||||
|
backup.backupType === "compose" &&
|
||||||
|
backup.compose?.serverId
|
||||||
|
) {
|
||||||
|
serverId = backup.compose.serverId;
|
||||||
}
|
}
|
||||||
const server = await findServerById(serverId);
|
const server = await findServerById(serverId);
|
||||||
|
|
||||||
@@ -217,6 +241,22 @@ export const backupRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
manualBackupCompose: protectedProcedure
|
||||||
|
.input(apiFindOneBackup)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const backup = await findBackupById(input.backupId);
|
||||||
|
const compose = await findComposeByBackupId(backup.backupId);
|
||||||
|
await runComposeBackup(compose, backup);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Error running manual Compose backup ",
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
manualBackupMongo: protectedProcedure
|
manualBackupMongo: protectedProcedure
|
||||||
.input(apiFindOneBackup)
|
.input(apiFindOneBackup)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
@@ -257,7 +297,7 @@ export const backupRouter = createTRPCRouter({
|
|||||||
const lastSlashIndex = input.search.lastIndexOf("/");
|
const lastSlashIndex = input.search.lastIndexOf("/");
|
||||||
const baseDir =
|
const baseDir =
|
||||||
lastSlashIndex !== -1
|
lastSlashIndex !== -1
|
||||||
? input.search.slice(0, lastSlashIndex + 1)
|
? normalizeS3Path(input.search.slice(0, lastSlashIndex + 1))
|
||||||
: "";
|
: "";
|
||||||
const searchTerm =
|
const searchTerm =
|
||||||
lastSlashIndex !== -1
|
lastSlashIndex !== -1
|
||||||
@@ -265,32 +305,47 @@ export const backupRouter = createTRPCRouter({
|
|||||||
: input.search;
|
: input.search;
|
||||||
|
|
||||||
const searchPath = baseDir ? `${bucketPath}/${baseDir}` : bucketPath;
|
const searchPath = baseDir ? `${bucketPath}/${baseDir}` : bucketPath;
|
||||||
const listCommand = `rclone lsf ${rcloneFlags.join(" ")} "${searchPath}" | head -n 100`;
|
const listCommand = `rclone lsjson ${rcloneFlags.join(" ")} "${searchPath}" --no-mimetype --no-modtime 2>/dev/null`;
|
||||||
|
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
|
|
||||||
if (input.serverId) {
|
if (input.serverId) {
|
||||||
const result = await execAsyncRemote(listCommand, input.serverId);
|
const result = await execAsyncRemote(input.serverId, listCommand);
|
||||||
stdout = result.stdout;
|
stdout = result.stdout;
|
||||||
} else {
|
} else {
|
||||||
const result = await execAsync(listCommand);
|
const result = await execAsync(listCommand);
|
||||||
stdout = result.stdout;
|
stdout = result.stdout;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = stdout.split("\n").filter(Boolean);
|
let files: RcloneFile[] = [];
|
||||||
|
try {
|
||||||
|
files = JSON.parse(stdout) as RcloneFile[];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing JSON response:", error);
|
||||||
|
console.error("Raw stdout:", stdout);
|
||||||
|
throw new Error("Failed to parse backup files list");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit to first 100 files
|
||||||
|
|
||||||
const results = baseDir
|
const results = baseDir
|
||||||
? files.map((file) => `${baseDir}${file}`)
|
? files.map((file) => ({
|
||||||
|
...file,
|
||||||
|
Path: `${baseDir}${file.Path}`,
|
||||||
|
}))
|
||||||
: files;
|
: files;
|
||||||
|
|
||||||
if (searchTerm) {
|
if (searchTerm) {
|
||||||
return results.filter((file) =>
|
return results
|
||||||
file.toLowerCase().includes(searchTerm.toLowerCase()),
|
.filter((file) =>
|
||||||
);
|
file.Path.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
)
|
||||||
|
.slice(0, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return results.slice(0, 100);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Error in listBackupFiles:", error);
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message:
|
message:
|
||||||
@@ -321,78 +376,96 @@ export const backupRouter = createTRPCRouter({
|
|||||||
"mongo",
|
"mongo",
|
||||||
"web-server",
|
"web-server",
|
||||||
]),
|
]),
|
||||||
|
backupType: z.enum(["database", "compose"]),
|
||||||
databaseName: z.string().min(1),
|
databaseName: z.string().min(1),
|
||||||
backupFile: z.string().min(1),
|
backupFile: z.string().min(1),
|
||||||
destinationId: z.string().min(1),
|
destinationId: z.string().min(1),
|
||||||
|
metadata: z.any(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.subscription(async ({ input }) => {
|
.subscription(async ({ input }) => {
|
||||||
const destination = await findDestinationById(input.destinationId);
|
const destination = await findDestinationById(input.destinationId);
|
||||||
if (input.databaseType === "postgres") {
|
if (input.backupType === "database") {
|
||||||
const postgres = await findPostgresById(input.databaseId);
|
if (input.databaseType === "postgres") {
|
||||||
|
const postgres = await findPostgresById(input.databaseId);
|
||||||
|
|
||||||
return observable<string>((emit) => {
|
return observable<string>((emit) => {
|
||||||
restorePostgresBackup(
|
restorePostgresBackup(
|
||||||
postgres,
|
postgres,
|
||||||
destination,
|
destination,
|
||||||
input.databaseName,
|
input.databaseName,
|
||||||
input.backupFile,
|
input.backupFile,
|
||||||
(log) => {
|
(log) => {
|
||||||
emit.next(log);
|
emit.next(log);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
if (input.databaseType === "mysql") {
|
|
||||||
const mysql = await findMySqlById(input.databaseId);
|
|
||||||
return observable<string>((emit) => {
|
|
||||||
restoreMySqlBackup(
|
|
||||||
mysql,
|
|
||||||
destination,
|
|
||||||
input.databaseName,
|
|
||||||
input.backupFile,
|
|
||||||
(log) => {
|
|
||||||
emit.next(log);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (input.databaseType === "mariadb") {
|
|
||||||
const mariadb = await findMariadbById(input.databaseId);
|
|
||||||
return observable<string>((emit) => {
|
|
||||||
restoreMariadbBackup(
|
|
||||||
mariadb,
|
|
||||||
destination,
|
|
||||||
input.databaseName,
|
|
||||||
input.backupFile,
|
|
||||||
(log) => {
|
|
||||||
emit.next(log);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (input.databaseType === "mongo") {
|
|
||||||
const mongo = await findMongoById(input.databaseId);
|
|
||||||
return observable<string>((emit) => {
|
|
||||||
restoreMongoBackup(
|
|
||||||
mongo,
|
|
||||||
destination,
|
|
||||||
input.databaseName,
|
|
||||||
input.backupFile,
|
|
||||||
(log) => {
|
|
||||||
emit.next(log);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (input.databaseType === "web-server") {
|
|
||||||
return observable<string>((emit) => {
|
|
||||||
restoreWebServerBackup(destination, input.backupFile, (log) => {
|
|
||||||
emit.next(log);
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
if (input.databaseType === "mysql") {
|
||||||
|
const mysql = await findMySqlById(input.databaseId);
|
||||||
|
return observable<string>((emit) => {
|
||||||
|
restoreMySqlBackup(
|
||||||
|
mysql,
|
||||||
|
destination,
|
||||||
|
input.databaseName,
|
||||||
|
input.backupFile,
|
||||||
|
(log) => {
|
||||||
|
emit.next(log);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.databaseType === "mariadb") {
|
||||||
|
const mariadb = await findMariadbById(input.databaseId);
|
||||||
|
return observable<string>((emit) => {
|
||||||
|
restoreMariadbBackup(
|
||||||
|
mariadb,
|
||||||
|
destination,
|
||||||
|
input.databaseName,
|
||||||
|
input.backupFile,
|
||||||
|
(log) => {
|
||||||
|
emit.next(log);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.databaseType === "mongo") {
|
||||||
|
const mongo = await findMongoById(input.databaseId);
|
||||||
|
return observable<string>((emit) => {
|
||||||
|
restoreMongoBackup(
|
||||||
|
mongo,
|
||||||
|
destination,
|
||||||
|
input.databaseName,
|
||||||
|
input.backupFile,
|
||||||
|
(log) => {
|
||||||
|
emit.next(log);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (input.databaseType === "web-server") {
|
||||||
|
return observable<string>((emit) => {
|
||||||
|
restoreWebServerBackup(destination, input.backupFile, (log) => {
|
||||||
|
emit.next(log);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (input.backupType === "compose") {
|
||||||
|
const compose = await findComposeById(input.databaseId);
|
||||||
|
return observable<string>((emit) => {
|
||||||
|
restoreComposeBackup(
|
||||||
|
compose,
|
||||||
|
destination,
|
||||||
|
input.databaseName,
|
||||||
|
input.backupFile,
|
||||||
|
input.metadata,
|
||||||
|
(log) => {
|
||||||
|
emit.next(log);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
removeDomain,
|
removeDomain,
|
||||||
removeDomainById,
|
removeDomainById,
|
||||||
updateDomainById,
|
updateDomainById,
|
||||||
|
validateDomain,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -224,4 +225,15 @@ export const domainRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
validateDomain: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
domain: z.string(),
|
||||||
|
serverIp: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return validateDomain(input.domain, input.serverIp);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export const mountRouter = createTRPCRouter({
|
|||||||
update: protectedProcedure
|
update: protectedProcedure
|
||||||
.input(apiUpdateMount)
|
.input(apiUpdateMount)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await updateMount(input.mountId, input);
|
return await updateMount(input.mountId, input);
|
||||||
return true;
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -157,4 +157,31 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
orderBy: [desc(invitation.status), desc(invitation.expiresAt)],
|
orderBy: [desc(invitation.status), desc(invitation.expiresAt)],
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
removeInvitation: adminProcedure
|
||||||
|
.input(z.object({ invitationId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const invitationResult = await db.query.invitation.findFirst({
|
||||||
|
where: eq(invitation.id, input.invitationId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!invitationResult) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Invitation not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
invitationResult?.organizationId !== ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You are not allowed to remove this invitation",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db
|
||||||
|
.delete(invitation)
|
||||||
|
.where(eq(invitation.id, input.invitationId));
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
createRegistry,
|
createRegistry,
|
||||||
execAsync,
|
|
||||||
execAsyncRemote,
|
execAsyncRemote,
|
||||||
|
execFileAsync,
|
||||||
findRegistryById,
|
findRegistryById,
|
||||||
removeRegistry,
|
removeRegistry,
|
||||||
updateRegistry,
|
updateRegistry,
|
||||||
@@ -83,7 +83,13 @@ export const registryRouter = createTRPCRouter({
|
|||||||
.input(apiTestRegistry)
|
.input(apiTestRegistry)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
try {
|
try {
|
||||||
const loginCommand = `echo ${input.password} | docker login ${input.registryUrl} --username ${input.username} --password-stdin`;
|
const args = [
|
||||||
|
"login",
|
||||||
|
input.registryUrl,
|
||||||
|
"--username",
|
||||||
|
input.username,
|
||||||
|
"--password-stdin",
|
||||||
|
];
|
||||||
|
|
||||||
if (IS_CLOUD && !input.serverId) {
|
if (IS_CLOUD && !input.serverId) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -93,9 +99,14 @@ export const registryRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (input.serverId && input.serverId !== "none") {
|
if (input.serverId && input.serverId !== "none") {
|
||||||
await execAsyncRemote(input.serverId, loginCommand);
|
await execAsyncRemote(
|
||||||
|
input.serverId,
|
||||||
|
`echo ${input.password} | docker ${args.join(" ")}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
await execAsync(loginCommand);
|
await execFileAsync("docker", args, {
|
||||||
|
input: Buffer.from(input.password).toString(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -79,6 +79,33 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
await execAsync(`docker service update --force ${stdout.trim()}`);
|
await execAsync(`docker service update --force ${stdout.trim()}`);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
|
cleanRedis: adminProcedure.mutation(async () => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdout: containerId } = await execAsync(
|
||||||
|
`docker ps --filter "name=dokploy-redis" --filter "status=running" -q | head -n 1`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!containerId) {
|
||||||
|
throw new Error("Redis container not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const redisContainerId = containerId.trim();
|
||||||
|
|
||||||
|
await execAsync(`docker exec -i ${redisContainerId} redis-cli flushall`);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
reloadRedis: adminProcedure.mutation(async () => {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await execAsync("docker service scale dokploy-redis=0");
|
||||||
|
await execAsync("docker service scale dokploy-redis=1");
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
reloadTraefik: adminProcedure
|
reloadTraefik: adminProcedure
|
||||||
.input(apiServerSchema)
|
.input(apiServerSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
@@ -184,6 +211,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
letsEncryptEmail: input.letsEncryptEmail,
|
letsEncryptEmail: input.letsEncryptEmail,
|
||||||
}),
|
}),
|
||||||
certificateType: input.certificateType,
|
certificateType: input.certificateType,
|
||||||
|
https: input.https,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
findUserById,
|
findUserById,
|
||||||
getUserByToken,
|
getUserByToken,
|
||||||
removeUserById,
|
removeUserById,
|
||||||
|
setupWebMonitoring,
|
||||||
updateUser,
|
updateUser,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
apiAssignPermissions,
|
apiAssignPermissions,
|
||||||
apiFindOneToken,
|
apiFindOneToken,
|
||||||
apiUpdateUser,
|
apiUpdateUser,
|
||||||
|
apiUpdateWebServerMonitoring,
|
||||||
apikey,
|
apikey,
|
||||||
invitation,
|
invitation,
|
||||||
member,
|
member,
|
||||||
@@ -27,7 +29,11 @@ import {
|
|||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from "../trpc";
|
} from "../trpc";
|
||||||
|
import {
|
||||||
|
validateLicense,
|
||||||
|
activateLicense,
|
||||||
|
deactivateLicense,
|
||||||
|
} from "@/server/utils/validate-license";
|
||||||
const apiCreateApiKey = z.object({
|
const apiCreateApiKey = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
prefix: z.string().optional(),
|
prefix: z.string().optional(),
|
||||||
@@ -160,6 +166,73 @@ export const userRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return await updateUser(ctx.user.id, input);
|
return await updateUser(ctx.user.id, input);
|
||||||
}),
|
}),
|
||||||
|
saveLicense: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
licenseKey: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const owner = await findUserById(ctx.user.ownerId);
|
||||||
|
|
||||||
|
const result = await activateLicense(
|
||||||
|
input.licenseKey,
|
||||||
|
owner?.serverIp || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateUser(ctx.user.id, {
|
||||||
|
licenseKey: input.licenseKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
deactivateLicense: adminProcedure
|
||||||
|
.input(z.object({ licenseKey: z.string().min(1) }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const owner = await findUserById(ctx.user.ownerId);
|
||||||
|
const result = await deactivateLicense(
|
||||||
|
input.licenseKey,
|
||||||
|
owner?.serverIp || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateUser(ctx.user.id, {
|
||||||
|
licenseKey: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
validateLicense: adminProcedure
|
||||||
|
.input(z.object({ licenseKey: z.string().min(1) }))
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const owner = await findUserById(ctx.user.ownerId);
|
||||||
|
const result = await validateLicense(
|
||||||
|
input.licenseKey,
|
||||||
|
owner?.serverIp || "",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: result.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
getUserByToken: publicProcedure
|
getUserByToken: publicProcedure
|
||||||
.input(apiFindOneToken)
|
.input(apiFindOneToken)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
@@ -349,4 +422,62 @@ export const userRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return organizations.length;
|
return organizations.length;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
setupMonitoring: adminProcedure
|
||||||
|
.input(apiUpdateWebServerMonitoring)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Feature disabled on cloud",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await findUserById(ctx.user.id);
|
||||||
|
|
||||||
|
if (!validateLicense(user?.licenseKey || "", user?.serverIp || "")) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Invalid license key",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (user.id !== ctx.user.ownerId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to setup the monitoring",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateUser(user.id, {
|
||||||
|
metricsConfig: {
|
||||||
|
server: {
|
||||||
|
type: "Dokploy",
|
||||||
|
refreshRate: input.metricsConfig.server.refreshRate,
|
||||||
|
port: input.metricsConfig.server.port,
|
||||||
|
token: input.metricsConfig.server.token,
|
||||||
|
cronJob: input.metricsConfig.server.cronJob,
|
||||||
|
urlCallback: input.metricsConfig.server.urlCallback,
|
||||||
|
retentionDays: input.metricsConfig.server.retentionDays,
|
||||||
|
thresholds: {
|
||||||
|
cpu: input.metricsConfig.server.thresholds.cpu,
|
||||||
|
memory: input.metricsConfig.server.thresholds.memory,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
containers: {
|
||||||
|
refreshRate: input.metricsConfig.containers.refreshRate,
|
||||||
|
services: {
|
||||||
|
include: input.metricsConfig.containers.services.include || [],
|
||||||
|
exclude: input.metricsConfig.containers.services.exclude || [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentServer = await setupWebMonitoring(user.id);
|
||||||
|
return currentServer;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { setupTerminalWebSocketServer } from "./wss/terminal";
|
|||||||
|
|
||||||
config({ path: ".env" });
|
config({ path: ".env" });
|
||||||
const PORT = Number.parseInt(process.env.PORT || "3000", 10);
|
const PORT = Number.parseInt(process.env.PORT || "3000", 10);
|
||||||
|
const HOST = process.env.HOST || "0.0.0.0";
|
||||||
const dev = process.env.NODE_ENV !== "production";
|
const dev = process.env.NODE_ENV !== "production";
|
||||||
const app = next({ dev, turbopack: process.env.TURBOPACK === "1" });
|
const app = next({ dev, turbopack: process.env.TURBOPACK === "1" });
|
||||||
const handle = app.getRequestHandler();
|
const handle = app.getRequestHandler();
|
||||||
@@ -46,8 +47,8 @@ void app.prepare().then(async () => {
|
|||||||
await initializeNetwork();
|
await initializeNetwork();
|
||||||
createDefaultTraefikConfig();
|
createDefaultTraefikConfig();
|
||||||
createDefaultServerTraefikConfig();
|
createDefaultServerTraefikConfig();
|
||||||
await initCronJobs();
|
|
||||||
await migration();
|
await migration();
|
||||||
|
await initCronJobs();
|
||||||
await sendDokployRestartNotifications();
|
await sendDokployRestartNotifications();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,8 +56,8 @@ void app.prepare().then(async () => {
|
|||||||
await migration();
|
await migration();
|
||||||
}
|
}
|
||||||
|
|
||||||
server.listen(PORT);
|
server.listen(PORT, HOST);
|
||||||
console.log("Server Started:", PORT);
|
console.log(`Server Started on: http://${HOST}:${PORT}`);
|
||||||
if (!IS_CLOUD) {
|
if (!IS_CLOUD) {
|
||||||
console.log("Starting Deployment Worker");
|
console.log("Starting Deployment Worker");
|
||||||
const { deploymentWorker } = await import("./queues/deployments-queue");
|
const { deploymentWorker } = await import("./queues/deployments-queue");
|
||||||
|
|||||||
47
apps/dokploy/server/utils/validate-license.ts
Normal file
47
apps/dokploy/server/utils/validate-license.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
const licensesUrl = process.env.LICENSES_URL || "http://localhost:4002";
|
||||||
|
|
||||||
|
export const validateLicense = async (licenseKey: string, serverIp: string) => {
|
||||||
|
const response = await fetch(`${licensesUrl}/api/license/validate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ licenseKey, serverIp }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok && data.error?.issues) {
|
||||||
|
console.log("Validation errors:", data.error.issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const activateLicense = async (licenseKey: string, serverIp: string) => {
|
||||||
|
const response = await fetch(`${licensesUrl}/api/license/activate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ licenseKey, serverIp }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deactivateLicense = async (
|
||||||
|
licenseKey: string,
|
||||||
|
serverIp: string,
|
||||||
|
) => {
|
||||||
|
const response = await fetch(`${licensesUrl}/api/license/deactivate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ licenseKey, serverIp }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type http from "node:http";
|
import type http from "node:http";
|
||||||
import { validateRequest } from "@dokploy/server/index";
|
|
||||||
import { applyWSSHandler } from "@trpc/server/adapters/ws";
|
import { applyWSSHandler } from "@trpc/server/adapters/ws";
|
||||||
import { WebSocketServer } from "ws";
|
import { WebSocketServer } from "ws";
|
||||||
import { appRouter } from "../api/root";
|
import { appRouter } from "../api/root";
|
||||||
import { createTRPCContext } from "../api/trpc";
|
import { createTRPCContext } from "../api/trpc";
|
||||||
|
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||||
|
|
||||||
export const setupDrawerLogsWebSocketServer = (
|
export const setupDrawerLogsWebSocketServer = (
|
||||||
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
||||||
@@ -13,11 +13,13 @@ export const setupDrawerLogsWebSocketServer = (
|
|||||||
path: "/drawer-logs",
|
path: "/drawer-logs",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set up tRPC WebSocket handler
|
||||||
applyWSSHandler({
|
applyWSSHandler({
|
||||||
wss: wssTerm,
|
wss: wssTerm,
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: createTRPCContext as any,
|
createContext: createTRPCContext as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on("upgrade", (req, socket, head) => {
|
server.on("upgrade", (req, socket, head) => {
|
||||||
const { pathname } = new URL(req.url || "", `http://${req.headers.host}`);
|
const { pathname } = new URL(req.url || "", `http://${req.headers.host}`);
|
||||||
|
|
||||||
|
|||||||
2
apps/licenses/.env.example
Normal file
2
apps/licenses/.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
LEMON_SQUEEZY_API_KEY=""
|
||||||
|
LEMON_SQUEEZY_STORE_ID=""
|
||||||
28
apps/licenses/.gitignore
vendored
Normal file
28
apps/licenses/.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# dev
|
||||||
|
.yarn/
|
||||||
|
!.yarn/releases
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/*.code-snippets
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/usage.statistics.xml
|
||||||
|
.idea/shelf
|
||||||
|
|
||||||
|
# deps
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# env
|
||||||
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user