mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-16 11:25:24 +02:00
Compare commits
180 Commits
2326-add-s
...
v0.26.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85632fd0c2 | ||
|
|
31cdae1b72 | ||
|
|
702af64444 | ||
|
|
eef27b67c2 | ||
|
|
70f50dd8bc | ||
|
|
3e25b97b99 | ||
|
|
22927c2716 | ||
|
|
8ab4ee8e0e | ||
|
|
99aa34f27e | ||
|
|
48be8544cf | ||
|
|
ee411ac74f | ||
|
|
c233ddb520 | ||
|
|
0cfe87cb72 | ||
|
|
7998b296a2 | ||
|
|
9e20f66bf5 | ||
|
|
1dc943ef5b | ||
|
|
0f63fdac4e | ||
|
|
ec8c516aa3 | ||
|
|
58be8f91c0 | ||
|
|
2036ac3dc8 | ||
|
|
17f83f746a | ||
|
|
bcd1cbe920 | ||
|
|
3993263615 | ||
|
|
97bd4de4f1 | ||
|
|
2fc29ff7c8 | ||
|
|
4a74016b52 | ||
|
|
bd4964f70f | ||
|
|
07bf520e9b | ||
|
|
c42e859215 | ||
|
|
e666cfb374 | ||
|
|
1d9b9ff9b6 | ||
|
|
6c61919202 | ||
|
|
a9a42d2066 | ||
|
|
0f6ac310b5 | ||
|
|
c267faef08 | ||
|
|
d2be0855c1 | ||
|
|
c9aaee149a | ||
|
|
d435553839 | ||
|
|
28f40066a2 | ||
|
|
22e6a06426 | ||
|
|
94faf78f16 | ||
|
|
c4351482fa | ||
|
|
5412c5a873 | ||
|
|
212006ba9e | ||
|
|
18d12d1a6f | ||
|
|
5d5af8f57f | ||
|
|
7f8f97c48f | ||
|
|
67865c5283 | ||
|
|
817264eae4 | ||
|
|
5360df7a53 | ||
|
|
fec4daa59b | ||
|
|
aae7906e77 | ||
|
|
86d14465cb | ||
|
|
f84c659121 | ||
|
|
89cb9c24c9 | ||
|
|
c7fcea7d6a | ||
|
|
d4555e6985 | ||
|
|
daa54cea8d | ||
|
|
77aff700fd | ||
|
|
cdb0de9a72 | ||
|
|
a5353e5457 | ||
|
|
a9b8beb50b | ||
|
|
6022f2f6a3 | ||
|
|
075e387bb6 | ||
|
|
568293ef3c | ||
|
|
a9ae39dc94 | ||
|
|
55a9640e31 | ||
|
|
32d5959733 | ||
|
|
bccd531457 | ||
|
|
f5de5130f3 | ||
|
|
bd751658be | ||
|
|
9b23aa9c8c | ||
|
|
dbc1396fa6 | ||
|
|
4210eefd37 | ||
|
|
91050ce3a5 | ||
|
|
9394d97163 | ||
|
|
f91a3aab25 | ||
|
|
9fbd0dce9a | ||
|
|
9e405c0728 | ||
|
|
44892404c1 | ||
|
|
1362fdd4b4 | ||
|
|
c7c3b1018b | ||
|
|
0d9b72e00a | ||
|
|
80ed041420 | ||
|
|
ba9c2ef369 | ||
|
|
8bd4f403c4 | ||
|
|
7ea7ee739f | ||
|
|
4873baa975 | ||
|
|
287dfb5402 | ||
|
|
439fba1f4b | ||
|
|
1ba24630a8 | ||
|
|
de6c1a7981 | ||
|
|
7948721f5a | ||
|
|
99cb80757c | ||
|
|
7467ada3a9 | ||
|
|
f0f2188652 | ||
|
|
2c1dfe9377 | ||
|
|
9e8efab909 | ||
|
|
35612b21a0 | ||
|
|
7873af1c39 | ||
|
|
ade727e2ed | ||
|
|
ac1fb6fb86 | ||
|
|
b3168f75d0 | ||
|
|
0e7b550642 | ||
|
|
3e1030edda | ||
|
|
3ea90de4e1 | ||
|
|
bccef0da4c | ||
|
|
dc28ddba2a | ||
|
|
ed312dc1c0 | ||
|
|
6cafb15dbb | ||
|
|
c34fdf7a46 | ||
|
|
e627c9af99 | ||
|
|
18e609313b | ||
|
|
fbf840bf6e | ||
|
|
76613de095 | ||
|
|
5a7f55ea63 | ||
|
|
be3403af0c | ||
|
|
f190cc548c | ||
|
|
4df9b935a8 | ||
|
|
b9becbafd8 | ||
|
|
60be376a4f | ||
|
|
ef9732d5d9 | ||
|
|
d35307ead6 | ||
|
|
c98db390dc | ||
|
|
0c7265c9c9 | ||
|
|
f1ef1d8489 | ||
|
|
fbd095334c | ||
|
|
e3832eff07 | ||
|
|
25b7069e31 | ||
|
|
0fbb063d06 | ||
|
|
56d4e61c1f | ||
|
|
7ce36a50e8 | ||
|
|
4f5b557a60 | ||
|
|
d09163a24e | ||
|
|
e1d8505757 | ||
|
|
b6de55c4d9 | ||
|
|
e22d503182 | ||
|
|
32631e957a | ||
|
|
79d3c1d7f3 | ||
|
|
9213061c26 | ||
|
|
085ef35b46 | ||
|
|
c9f356e314 | ||
|
|
4f691d27b2 | ||
|
|
3c70db9fc8 | ||
|
|
5890b321b2 | ||
|
|
d02913d69e | ||
|
|
c459997453 | ||
|
|
1c0673b327 | ||
|
|
334d9c91ef | ||
|
|
615d89ee0c | ||
|
|
0c24507872 | ||
|
|
d2cd01aff7 | ||
|
|
6349cabf27 | ||
|
|
94536ab05a | ||
|
|
8e5be8dbcb | ||
|
|
046606e496 | ||
|
|
e9cf1f4caa | ||
|
|
ee0a299343 | ||
|
|
1b77c8029b | ||
|
|
48e4fd3ddf | ||
|
|
276f870e74 | ||
|
|
6e86fafa5e | ||
|
|
008788a38a | ||
|
|
b4a14e6e76 | ||
|
|
e64ee98d99 | ||
|
|
95d0da25a0 | ||
|
|
0cc8c02359 | ||
|
|
7f3fe52b53 | ||
|
|
b5bc384664 | ||
|
|
39d0b9649f | ||
|
|
1ce880bd6d | ||
|
|
8a8ed58fef | ||
|
|
95714c1749 | ||
|
|
a181b7b8b8 | ||
|
|
0e2f1e2832 | ||
|
|
2ec495b2f2 | ||
|
|
178ccb3f45 | ||
|
|
a47a5f3b9e | ||
|
|
95bf60ac75 | ||
|
|
544408886e |
@@ -16,11 +16,11 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server
|
|||||||
|
|
||||||
|
|
||||||
# Deploy only the dokploy app
|
# Deploy only the dokploy app
|
||||||
ARG NEXT_PUBLIC_UMAMI_HOST
|
# ARG NEXT_PUBLIC_UMAMI_HOST
|
||||||
ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
|
# ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
|
||||||
|
|
||||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
# ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
# ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||||
|
|
||||||
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||||
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
|
||||||
|
|||||||
209
apps/dokploy/__test__/cluster/upload.test.ts
Normal file
209
apps/dokploy/__test__/cluster/upload.test.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import type { Registry } from "@dokploy/server";
|
||||||
|
import { getRegistryTag } from "@dokploy/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("getRegistryTag", () => {
|
||||||
|
// Helper to create a mock registry
|
||||||
|
const createMockRegistry = (overrides: Partial<Registry> = {}): Registry => {
|
||||||
|
return {
|
||||||
|
registryId: "test-registry-id",
|
||||||
|
registryName: "Test Registry",
|
||||||
|
username: "myuser",
|
||||||
|
password: "test-password",
|
||||||
|
registryUrl: "docker.io",
|
||||||
|
registryType: "cloud",
|
||||||
|
imagePrefix: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
organizationId: "test-org-id",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("with username (no imagePrefix)", () => {
|
||||||
|
it("should handle simple image name without tag", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("docker.io/myuser/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with tag", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "nginx:latest");
|
||||||
|
expect(result).toBe("docker.io/myuser/nginx:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with username already present (no duplication)", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe("docker.io/myuser/myprivaterepo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with username and tag already present", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo:latest");
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe("docker.io/myuser/myprivaterepo:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle complex image name with username", () => {
|
||||||
|
const registry = createMockRegistry({ username: "siumauricio" });
|
||||||
|
const result = getRegistryTag(
|
||||||
|
registry,
|
||||||
|
"siumauricio/app-parse-multi-byte-port-e32uh7",
|
||||||
|
);
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe(
|
||||||
|
"docker.io/siumauricio/app-parse-multi-byte-port-e32uh7",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with different username (should not duplicate)", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "otheruser/myprivaterepo");
|
||||||
|
expect(result).toBe("docker.io/myuser/myprivaterepo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with full registry URL (no username)", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "docker.io/nginx");
|
||||||
|
// Should add username since imageName doesn't have one
|
||||||
|
expect(result).toBe("docker.io/myuser/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with custom registry URL and username", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "ghcr.io/myuser/repo");
|
||||||
|
// Should not duplicate username even if registry URL is different
|
||||||
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with custom registry URL (different username)", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "ghcr.io/otheruser/repo");
|
||||||
|
// Should use registry username, not the one in imageName
|
||||||
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("with imagePrefix", () => {
|
||||||
|
it("should use imagePrefix instead of username", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("docker.io/myorg/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use imagePrefix with image tag", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx:latest");
|
||||||
|
expect(result).toBe("docker.io/myorg/nginx:latest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle imagePrefix with username already in image name", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||||
|
expect(result).toBe("docker.io/myorg/myprivaterepo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle imagePrefix matching image name prefix", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myorg/myprivaterepo");
|
||||||
|
// Should not duplicate prefix
|
||||||
|
expect(result).toBe("docker.io/myorg/myprivaterepo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("without registryUrl", () => {
|
||||||
|
it("should work without registryUrl", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
registryUrl: "",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("myuser/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should work without registryUrl with imagePrefix", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
registryUrl: "",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("myorg/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle username already present without registryUrl", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
registryUrl: "",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe("myuser/myprivaterepo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("with custom registryUrl", () => {
|
||||||
|
it("should handle custom registry URL", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
registryUrl: "ghcr.io",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("ghcr.io/myuser/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle custom registry URL with imagePrefix", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
imagePrefix: "myorg",
|
||||||
|
registryUrl: "ghcr.io",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "nginx");
|
||||||
|
expect(result).toBe("ghcr.io/myorg/nginx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle custom registry URL with username already present", () => {
|
||||||
|
const registry = createMockRegistry({
|
||||||
|
username: "myuser",
|
||||||
|
registryUrl: "ghcr.io",
|
||||||
|
});
|
||||||
|
const result = getRegistryTag(registry, "myuser/myprivaterepo");
|
||||||
|
// Should not duplicate username
|
||||||
|
expect(result).toBe("ghcr.io/myuser/myprivaterepo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle empty image name", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "");
|
||||||
|
expect(result).toBe("docker.io/myuser/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with multiple slashes", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "org/suborg/repo");
|
||||||
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle image name with username at different position", () => {
|
||||||
|
const registry = createMockRegistry({ username: "myuser" });
|
||||||
|
const result = getRegistryTag(registry, "org/myuser/repo");
|
||||||
|
expect(result).toBe("docker.io/myuser/repo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
215
apps/dokploy/__test__/compose/domain/host-rule-format.test.ts
Normal file
215
apps/dokploy/__test__/compose/domain/host-rule-format.test.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import type { Domain } from "@dokploy/server";
|
||||||
|
import { createDomainLabels } from "@dokploy/server";
|
||||||
|
import { parse, stringify } from "yaml";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for Traefik Host rule label format.
|
||||||
|
*
|
||||||
|
* These tests verify that the Host rule is generated with the correct format:
|
||||||
|
* - Host(`domain.com`) - with opening and closing parentheses
|
||||||
|
* - Host(`domain.com`) && PathPrefix(`/path`) - for path-based routing
|
||||||
|
*
|
||||||
|
* Issue: https://github.com/Dokploy/dokploy/issues/3161
|
||||||
|
* The bug caused Host rules to be malformed as Host`domain.com`)
|
||||||
|
* (missing opening parenthesis) which broke all domain routing.
|
||||||
|
*/
|
||||||
|
describe("Host rule format regression tests", () => {
|
||||||
|
const baseDomain: Domain = {
|
||||||
|
host: "example.com",
|
||||||
|
port: 8080,
|
||||||
|
https: false,
|
||||||
|
uniqueConfigKey: 1,
|
||||||
|
customCertResolver: null,
|
||||||
|
certificateType: "none",
|
||||||
|
applicationId: "",
|
||||||
|
composeId: "",
|
||||||
|
domainType: "compose",
|
||||||
|
serviceName: "test-app",
|
||||||
|
domainId: "",
|
||||||
|
path: "/",
|
||||||
|
createdAt: "",
|
||||||
|
previewDeploymentId: "",
|
||||||
|
internalPath: "/",
|
||||||
|
stripPath: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("Host rule format validation", () => {
|
||||||
|
it("should generate Host rule with correct parentheses format", async () => {
|
||||||
|
const labels = await createDomainLabels("test-app", baseDomain, "web");
|
||||||
|
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||||
|
|
||||||
|
expect(ruleLabel).toBeDefined();
|
||||||
|
// Verify exact format: Host(`domain`)
|
||||||
|
expect(ruleLabel).toMatch(/Host\(`[^`]+`\)/);
|
||||||
|
// Ensure opening parenthesis is present after Host
|
||||||
|
expect(ruleLabel).toContain("Host(`example.com`)");
|
||||||
|
// Ensure it does NOT have the malformed format
|
||||||
|
expect(ruleLabel).not.toMatch(/Host`[^`]+`\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate PathPrefix with correct parentheses format", async () => {
|
||||||
|
const labels = await createDomainLabels(
|
||||||
|
"test-app",
|
||||||
|
{ ...baseDomain, path: "/api" },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||||
|
|
||||||
|
expect(ruleLabel).toBeDefined();
|
||||||
|
// Verify PathPrefix format
|
||||||
|
expect(ruleLabel).toMatch(/PathPrefix\(`[^`]+`\)/);
|
||||||
|
expect(ruleLabel).toContain("PathPrefix(`/api`)");
|
||||||
|
// Ensure opening parenthesis is present
|
||||||
|
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should generate combined Host and PathPrefix with correct format", async () => {
|
||||||
|
const labels = await createDomainLabels(
|
||||||
|
"test-app",
|
||||||
|
{ ...baseDomain, path: "/api/v1" },
|
||||||
|
"websecure",
|
||||||
|
);
|
||||||
|
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||||
|
|
||||||
|
expect(ruleLabel).toBeDefined();
|
||||||
|
expect(ruleLabel).toBe(
|
||||||
|
"traefik.http.routers.test-app-1-websecure.rule=Host(`example.com`) && PathPrefix(`/api/v1`)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("YAML serialization preserves Host rule format", () => {
|
||||||
|
it("should preserve Host rule format through YAML stringify/parse", async () => {
|
||||||
|
const labels = await createDomainLabels("test-app", baseDomain, "web");
|
||||||
|
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||||
|
|
||||||
|
// Simulate compose file structure
|
||||||
|
const composeSpec = {
|
||||||
|
services: {
|
||||||
|
myapp: {
|
||||||
|
image: "nginx",
|
||||||
|
labels: labels,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stringify to YAML
|
||||||
|
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
|
||||||
|
|
||||||
|
// Parse back
|
||||||
|
const parsed = parse(yamlOutput) as typeof composeSpec;
|
||||||
|
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
|
||||||
|
l.includes(".rule="),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify format is preserved
|
||||||
|
expect(parsedRuleLabel).toBe(ruleLabel);
|
||||||
|
expect(parsedRuleLabel).toContain("Host(`example.com`)");
|
||||||
|
expect(parsedRuleLabel).not.toMatch(/Host`[^`]+`\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should preserve complex rule format through YAML serialization", async () => {
|
||||||
|
const labels = await createDomainLabels(
|
||||||
|
"test-app",
|
||||||
|
{ ...baseDomain, path: "/api", https: true },
|
||||||
|
"websecure",
|
||||||
|
);
|
||||||
|
|
||||||
|
const composeSpec = {
|
||||||
|
services: {
|
||||||
|
myapp: {
|
||||||
|
labels: labels,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const yamlOutput = stringify(composeSpec, { lineWidth: 1000 });
|
||||||
|
const parsed = parse(yamlOutput) as typeof composeSpec;
|
||||||
|
const parsedRuleLabel = parsed.services.myapp.labels.find((l: string) =>
|
||||||
|
l.includes(".rule="),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(parsedRuleLabel).toContain(
|
||||||
|
"Host(`example.com`) && PathPrefix(`/api`)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Edge cases for domain names", () => {
|
||||||
|
const domainCases = [
|
||||||
|
{ name: "simple domain", host: "example.com" },
|
||||||
|
{ name: "subdomain", host: "app.example.com" },
|
||||||
|
{ name: "deep subdomain", host: "api.v1.app.example.com" },
|
||||||
|
{ name: "numeric domain", host: "123.example.com" },
|
||||||
|
{ name: "hyphenated domain", host: "my-app.example-host.com" },
|
||||||
|
{ name: "localhost", host: "localhost" },
|
||||||
|
{ name: "IP address style", host: "192.168.1.100" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, host } of domainCases) {
|
||||||
|
it(`should generate correct Host rule for ${name}: ${host}`, async () => {
|
||||||
|
const labels = await createDomainLabels(
|
||||||
|
"test-app",
|
||||||
|
{ ...baseDomain, host },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||||
|
|
||||||
|
expect(ruleLabel).toBeDefined();
|
||||||
|
expect(ruleLabel).toContain(`Host(\`${host}\`)`);
|
||||||
|
// Verify parenthesis is present
|
||||||
|
expect(ruleLabel).toMatch(
|
||||||
|
new RegExp(`Host\\(\\\`${host.replace(/\./g, "\\.")}\\\`\\)`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Multiple domains scenario", () => {
|
||||||
|
it("should generate correct format for both web and websecure entrypoints", async () => {
|
||||||
|
const webLabels = await createDomainLabels("test-app", baseDomain, "web");
|
||||||
|
const websecureLabels = await createDomainLabels(
|
||||||
|
"test-app",
|
||||||
|
baseDomain,
|
||||||
|
"websecure",
|
||||||
|
);
|
||||||
|
|
||||||
|
const webRule = webLabels.find((l) => l.includes(".rule="));
|
||||||
|
const websecureRule = websecureLabels.find((l) => l.includes(".rule="));
|
||||||
|
|
||||||
|
// Both should have correct format
|
||||||
|
expect(webRule).toContain("Host(`example.com`)");
|
||||||
|
expect(websecureRule).toContain("Host(`example.com`)");
|
||||||
|
|
||||||
|
// Neither should have malformed format
|
||||||
|
expect(webRule).not.toMatch(/Host`[^`]+`\)/);
|
||||||
|
expect(websecureRule).not.toMatch(/Host`[^`]+`\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Special characters in paths", () => {
|
||||||
|
const pathCases = [
|
||||||
|
{ name: "simple path", path: "/api" },
|
||||||
|
{ name: "nested path", path: "/api/v1/users" },
|
||||||
|
{ name: "path with hyphen", path: "/api-v1" },
|
||||||
|
{ name: "path with underscore", path: "/api_v1" },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, path } of pathCases) {
|
||||||
|
it(`should generate correct PathPrefix for ${name}: ${path}`, async () => {
|
||||||
|
const labels = await createDomainLabels(
|
||||||
|
"test-app",
|
||||||
|
{ ...baseDomain, path },
|
||||||
|
"web",
|
||||||
|
);
|
||||||
|
const ruleLabel = labels.find((l) => l.includes(".rule="));
|
||||||
|
|
||||||
|
expect(ruleLabel).toBeDefined();
|
||||||
|
expect(ruleLabel).toContain(`PathPrefix(\`${path}\`)`);
|
||||||
|
// Verify parenthesis is present
|
||||||
|
expect(ruleLabel).not.toMatch(/PathPrefix`[^`]+`\)/);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -189,7 +189,7 @@ describe("deployApplication - Command Generation Tests", () => {
|
|||||||
|
|
||||||
it("should verify nixpacks command is called with correct app", async () => {
|
it("should verify nixpacks command is called with correct app", async () => {
|
||||||
const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app";
|
const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app";
|
||||||
vi.mocked(builders.getBuildCommand).mockReturnValue(mockNixpacksCommand);
|
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
|
||||||
|
|
||||||
await deployApplication({
|
await deployApplication({
|
||||||
applicationId: "test-app-id",
|
applicationId: "test-app-id",
|
||||||
@@ -220,7 +220,7 @@ describe("deployApplication - Command Generation Tests", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const mockRailpackCommand = "railpack prepare /path/to/app";
|
const mockRailpackCommand = "railpack prepare /path/to/app";
|
||||||
vi.mocked(builders.getBuildCommand).mockReturnValue(mockRailpackCommand);
|
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockRailpackCommand);
|
||||||
|
|
||||||
await deployApplication({
|
await deployApplication({
|
||||||
applicationId: "test-app-id",
|
applicationId: "test-app-id",
|
||||||
@@ -241,7 +241,7 @@ describe("deployApplication - Command Generation Tests", () => {
|
|||||||
|
|
||||||
it("should execute commands in correct order", async () => {
|
it("should execute commands in correct order", async () => {
|
||||||
const mockNixpacksCommand = "nixpacks build";
|
const mockNixpacksCommand = "nixpacks build";
|
||||||
vi.mocked(builders.getBuildCommand).mockReturnValue(mockNixpacksCommand);
|
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockNixpacksCommand);
|
||||||
|
|
||||||
await deployApplication({
|
await deployApplication({
|
||||||
applicationId: "test-app-id",
|
applicationId: "test-app-id",
|
||||||
@@ -260,7 +260,7 @@ describe("deployApplication - Command Generation Tests", () => {
|
|||||||
|
|
||||||
it("should include log redirection in command", async () => {
|
it("should include log redirection in command", async () => {
|
||||||
const mockCommand = "nixpacks build";
|
const mockCommand = "nixpacks build";
|
||||||
vi.mocked(builders.getBuildCommand).mockReturnValue(mockCommand);
|
vi.mocked(builders.getBuildCommand).mockResolvedValue(mockCommand);
|
||||||
|
|
||||||
await deployApplication({
|
await deployApplication({
|
||||||
applicationId: "test-app-id",
|
applicationId: "test-app-id",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const baseApp: ApplicationNested = {
|
|||||||
railpackVersion: "0.2.2",
|
railpackVersion: "0.2.2",
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
|
createEnvFile: true,
|
||||||
herokuVersion: "",
|
herokuVersion: "",
|
||||||
giteaBranch: "",
|
giteaBranch: "",
|
||||||
buildServerId: "",
|
buildServerId: "",
|
||||||
@@ -41,6 +42,9 @@ const baseApp: ApplicationNested = {
|
|||||||
giteaRepository: "",
|
giteaRepository: "",
|
||||||
cleanCache: false,
|
cleanCache: false,
|
||||||
watchPaths: [],
|
watchPaths: [],
|
||||||
|
rollbackRegistryId: "",
|
||||||
|
rollbackRegistry: null,
|
||||||
|
deployments: [],
|
||||||
enableSubmodules: false,
|
enableSubmodules: false,
|
||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
triggerType: "push",
|
triggerType: "push",
|
||||||
@@ -64,6 +68,7 @@ const baseApp: ApplicationNested = {
|
|||||||
previewWildcard: "",
|
previewWildcard: "",
|
||||||
environment: {
|
environment: {
|
||||||
env: "",
|
env: "",
|
||||||
|
isDefault: false,
|
||||||
environmentId: "",
|
environmentId: "",
|
||||||
name: "",
|
name: "",
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
|
|||||||
@@ -54,4 +54,22 @@ describe("processLogs", () => {
|
|||||||
const result = parseRawConfig(entryWithWhitespace);
|
const result = parseRawConfig(entryWithWhitespace);
|
||||||
expect(result.data).toHaveLength(2);
|
expect(result.data).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should filter out Dokploy dashboard requests", () => {
|
||||||
|
const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`;
|
||||||
|
|
||||||
|
// Test with only Dokploy dashboard entry - should be filtered out
|
||||||
|
const resultOnlyDokploy = parseRawConfig(dokployDashboardEntry);
|
||||||
|
expect(resultOnlyDokploy.data).toHaveLength(0);
|
||||||
|
expect(resultOnlyDokploy.totalCount).toBe(0);
|
||||||
|
|
||||||
|
// Test with mixed entries - Dokploy should be filtered, others should remain
|
||||||
|
const mixedEntries = `${dokployDashboardEntry}\n${sampleLogEntry}`;
|
||||||
|
const resultMixed = parseRawConfig(mixedEntries);
|
||||||
|
expect(resultMixed.data).toHaveLength(1);
|
||||||
|
expect(resultMixed.totalCount).toBe(1);
|
||||||
|
expect(resultMixed.data[0]?.ServiceName).not.toBe(
|
||||||
|
"dokploy-service-app@file",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
||||||
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
type MockCreateServiceOptions = {
|
type MockCreateServiceOptions = {
|
||||||
TaskTemplate?: {
|
TaskTemplate?: {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const baseAdmin: User = {
|
|||||||
enablePaidFeatures: false,
|
enablePaidFeatures: false,
|
||||||
allowImpersonation: false,
|
allowImpersonation: false,
|
||||||
role: "user",
|
role: "user",
|
||||||
|
firstName: "",
|
||||||
|
lastName: "",
|
||||||
metricsConfig: {
|
metricsConfig: {
|
||||||
containers: {
|
containers: {
|
||||||
refreshRate: 20,
|
refreshRate: 20,
|
||||||
@@ -61,7 +63,6 @@ const baseAdmin: User = {
|
|||||||
expirationDate: "",
|
expirationDate: "",
|
||||||
id: "",
|
id: "",
|
||||||
isRegistered: false,
|
isRegistered: false,
|
||||||
name: "",
|
|
||||||
createdAt2: new Date().toISOString(),
|
createdAt2: new Date().toISOString(),
|
||||||
emailVerified: false,
|
emailVerified: false,
|
||||||
image: "",
|
image: "",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const baseApp: ApplicationNested = {
|
|||||||
rollbackActive: false,
|
rollbackActive: false,
|
||||||
applicationId: "",
|
applicationId: "",
|
||||||
previewLabels: [],
|
previewLabels: [],
|
||||||
|
createEnvFile: true,
|
||||||
herokuVersion: "",
|
herokuVersion: "",
|
||||||
giteaRepository: "",
|
giteaRepository: "",
|
||||||
giteaOwner: "",
|
giteaOwner: "",
|
||||||
@@ -17,6 +18,9 @@ const baseApp: ApplicationNested = {
|
|||||||
giteaBuildPath: "",
|
giteaBuildPath: "",
|
||||||
giteaId: "",
|
giteaId: "",
|
||||||
args: [],
|
args: [],
|
||||||
|
rollbackRegistryId: "",
|
||||||
|
rollbackRegistry: null,
|
||||||
|
deployments: [],
|
||||||
cleanCache: false,
|
cleanCache: false,
|
||||||
applicationStatus: "done",
|
applicationStatus: "done",
|
||||||
endpointSpecSwarm: null,
|
endpointSpecSwarm: null,
|
||||||
@@ -46,6 +50,7 @@ const baseApp: ApplicationNested = {
|
|||||||
environmentId: "",
|
environmentId: "",
|
||||||
environment: {
|
environment: {
|
||||||
env: "",
|
env: "",
|
||||||
|
isDefault: false,
|
||||||
environmentId: "",
|
environmentId: "",
|
||||||
name: "",
|
name: "",
|
||||||
createdAt: "",
|
createdAt: "",
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export const ShowDeployments = ({
|
|||||||
See the last 10 deployments for this {type}
|
See the last 10 deployments for this {type}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center gap-2">
|
<div className="flex flex-row items-center flex-wrap gap-2">
|
||||||
{(type === "application" || type === "compose") && (
|
{(type === "application" || type === "compose") && (
|
||||||
<KillBuild id={id} type={type} />
|
<KillBuild id={id} type={type} />
|
||||||
)}
|
)}
|
||||||
@@ -373,7 +373,19 @@ export const ShowDeployments = ({
|
|||||||
type === "application" && (
|
type === "application" && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Rollback to this deployment"
|
title="Rollback to this deployment"
|
||||||
description="Are you sure you want to rollback to this deployment?"
|
description={
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p>
|
||||||
|
Are you sure you want to rollback to this
|
||||||
|
deployment?
|
||||||
|
</p>
|
||||||
|
<AlertBlock type="info" className="text-sm">
|
||||||
|
Please wait a few seconds while the image is
|
||||||
|
pulled from the registry. Your application
|
||||||
|
should be running shortly.
|
||||||
|
</AlertBlock>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
type="default"
|
type="default"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await rollback({
|
await rollback({
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
const certificateType = form.watch("certificateType");
|
const certificateType = form.watch("certificateType");
|
||||||
const https = form.watch("https");
|
const https = form.watch("https");
|
||||||
const domainType = form.watch("domainType");
|
const domainType = form.watch("domainType");
|
||||||
|
const host = form.watch("host");
|
||||||
|
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -502,6 +504,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
to make your traefik.me domain work.
|
to make your traefik.me domain work.
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
|
{isTraefikMeDomain && (
|
||||||
|
<AlertBlock type="info">
|
||||||
|
<strong>Note:</strong> traefik.me is a public HTTP
|
||||||
|
service and does not support SSL/HTTPS. HTTPS and
|
||||||
|
certificate options will not have any effect.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
<FormLabel>Host</FormLabel>
|
<FormLabel>Host</FormLabel>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -5,14 +5,23 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Form } from "@/components/ui/form";
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
} from "@/components/ui/form";
|
||||||
import { Secrets } from "@/components/ui/secrets";
|
import { Secrets } from "@/components/ui/secrets";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const addEnvironmentSchema = z.object({
|
const addEnvironmentSchema = z.object({
|
||||||
env: z.string(),
|
env: z.string(),
|
||||||
buildArgs: z.string(),
|
buildArgs: z.string(),
|
||||||
buildSecrets: z.string(),
|
buildSecrets: z.string(),
|
||||||
|
createEnvFile: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
|
||||||
@@ -39,6 +48,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
env: "",
|
env: "",
|
||||||
buildArgs: "",
|
buildArgs: "",
|
||||||
buildSecrets: "",
|
buildSecrets: "",
|
||||||
|
createEnvFile: true,
|
||||||
},
|
},
|
||||||
resolver: zodResolver(addEnvironmentSchema),
|
resolver: zodResolver(addEnvironmentSchema),
|
||||||
});
|
});
|
||||||
@@ -47,10 +57,12 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
const currentEnv = form.watch("env");
|
const currentEnv = form.watch("env");
|
||||||
const currentBuildArgs = form.watch("buildArgs");
|
const currentBuildArgs = form.watch("buildArgs");
|
||||||
const currentBuildSecrets = form.watch("buildSecrets");
|
const currentBuildSecrets = form.watch("buildSecrets");
|
||||||
|
const currentCreateEnvFile = form.watch("createEnvFile");
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
currentEnv !== (data?.env || "") ||
|
currentEnv !== (data?.env || "") ||
|
||||||
currentBuildArgs !== (data?.buildArgs || "") ||
|
currentBuildArgs !== (data?.buildArgs || "") ||
|
||||||
currentBuildSecrets !== (data?.buildSecrets || "");
|
currentBuildSecrets !== (data?.buildSecrets || "") ||
|
||||||
|
currentCreateEnvFile !== (data?.createEnvFile ?? true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
@@ -58,6 +70,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
env: data.env || "",
|
env: data.env || "",
|
||||||
buildArgs: data.buildArgs || "",
|
buildArgs: data.buildArgs || "",
|
||||||
buildSecrets: data.buildSecrets || "",
|
buildSecrets: data.buildSecrets || "",
|
||||||
|
createEnvFile: data.createEnvFile ?? true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [data, form]);
|
}, [data, form]);
|
||||||
@@ -67,6 +80,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
env: formData.env,
|
env: formData.env,
|
||||||
buildArgs: formData.buildArgs,
|
buildArgs: formData.buildArgs,
|
||||||
buildSecrets: formData.buildSecrets,
|
buildSecrets: formData.buildSecrets,
|
||||||
|
createEnvFile: formData.createEnvFile,
|
||||||
applicationId,
|
applicationId,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
@@ -83,6 +97,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
env: data?.env || "",
|
env: data?.env || "",
|
||||||
buildArgs: data?.buildArgs || "",
|
buildArgs: data?.buildArgs || "",
|
||||||
buildSecrets: data?.buildSecrets || "",
|
buildSecrets: data?.buildSecrets || "",
|
||||||
|
createEnvFile: data?.createEnvFile ?? true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -167,6 +182,30 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
|
|||||||
placeholder="NPM_TOKEN=xyz"
|
placeholder="NPM_TOKEN=xyz"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{data?.buildType === "dockerfile" && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="createEnvFile"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-sm">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Create Environment File</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
When enabled, an .env file will be created during the
|
||||||
|
build process. Disable this if you don't want to generate
|
||||||
|
an environment file.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex flex-row justify-end gap-2">
|
<div className="flex flex-row justify-end gap-2">
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ export const AddPreviewDomain = ({
|
|||||||
resolver: zodResolver(domain),
|
resolver: zodResolver(domain),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const host = form.watch("host");
|
||||||
|
const isTraefikMeDomain = host?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data) {
|
if (data) {
|
||||||
form.reset({
|
form.reset({
|
||||||
@@ -157,6 +160,13 @@ export const AddPreviewDomain = ({
|
|||||||
name="host"
|
name="host"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
|
{isTraefikMeDomain && (
|
||||||
|
<AlertBlock type="info">
|
||||||
|
<strong>Note:</strong> traefik.me is a public HTTP
|
||||||
|
service and does not support SSL/HTTPS. HTTPS and
|
||||||
|
certificate options will not have any effect.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
<FormLabel>Host</FormLabel>
|
<FormLabel>Host</FormLabel>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -100,6 +101,8 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const previewHttps = form.watch("previewHttps");
|
const previewHttps = form.watch("previewHttps");
|
||||||
|
const wildcardDomain = form.watch("wildcardDomain");
|
||||||
|
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
setIsEnabled(data?.isPreviewDeploymentsActive || false);
|
||||||
@@ -168,6 +171,13 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
|
{isTraefikMeDomain && (
|
||||||
|
<AlertBlock type="info">
|
||||||
|
<strong>Note:</strong> traefik.me is a public HTTP service and
|
||||||
|
does not support SSL/HTTPS. HTTPS and certificate options will
|
||||||
|
not have any effect.
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useState } from "react";
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -20,13 +21,37 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
} from "@/components/ui/form";
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z
|
||||||
rollbackActive: z.boolean(),
|
.object({
|
||||||
});
|
rollbackActive: z.boolean(),
|
||||||
|
rollbackRegistryId: z.string().optional(),
|
||||||
|
})
|
||||||
|
.superRefine((values, ctx) => {
|
||||||
|
if (
|
||||||
|
values.rollbackActive &&
|
||||||
|
(!values.rollbackRegistryId || values.rollbackRegistryId === "none")
|
||||||
|
) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["rollbackRegistryId"],
|
||||||
|
message: "Registry is required when rollbacks are enabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
type FormValues = z.infer<typeof formSchema>;
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
@@ -49,17 +74,33 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
|||||||
const { mutateAsync: updateApplication, isLoading } =
|
const { mutateAsync: updateApplication, isLoading } =
|
||||||
api.application.update.useMutation();
|
api.application.update.useMutation();
|
||||||
|
|
||||||
|
const { data: registries } = api.registry.all.useQuery();
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
rollbackActive: application?.rollbackActive ?? false,
|
rollbackActive: application?.rollbackActive ?? false,
|
||||||
|
rollbackRegistryId: application?.rollbackRegistryId || "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (application) {
|
||||||
|
form.reset({
|
||||||
|
rollbackActive: application.rollbackActive ?? false,
|
||||||
|
rollbackRegistryId: application.rollbackRegistryId || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [application, form]);
|
||||||
|
|
||||||
const onSubmit = async (data: FormValues) => {
|
const onSubmit = async (data: FormValues) => {
|
||||||
await updateApplication({
|
await updateApplication({
|
||||||
applicationId,
|
applicationId,
|
||||||
rollbackActive: data.rollbackActive,
|
rollbackActive: data.rollbackActive,
|
||||||
|
rollbackRegistryId:
|
||||||
|
data.rollbackRegistryId === "none" || !data.rollbackRegistryId
|
||||||
|
? null
|
||||||
|
: data.rollbackRegistryId,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success("Rollback settings updated");
|
toast.success("Rollback settings updated");
|
||||||
@@ -112,6 +153,65 @@ export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{form.watch("rollbackActive") && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="rollbackRegistryId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Rollback Registry</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
value={field.value || "none"}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a registry" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="none">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>None</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
{registries?.map((registry) => (
|
||||||
|
<SelectItem
|
||||||
|
key={registry.registryId}
|
||||||
|
value={registry.registryId}
|
||||||
|
>
|
||||||
|
{registry.registryName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectLabel>
|
||||||
|
Registries ({registries?.length || 0})
|
||||||
|
</SelectLabel>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{!registries || registries.length === 0 ? (
|
||||||
|
<FormDescription className="text-amber-600 dark:text-amber-500">
|
||||||
|
No registries available. Please{" "}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings/registry"
|
||||||
|
className="underline font-medium hover:text-amber-700 dark:hover:text-amber-400"
|
||||||
|
>
|
||||||
|
configure a registry
|
||||||
|
</Link>{" "}
|
||||||
|
first to enable rollbacks.
|
||||||
|
</FormDescription>
|
||||||
|
) : (
|
||||||
|
<FormDescription>
|
||||||
|
Select a registry where rollback images will be stored.
|
||||||
|
</FormDescription>
|
||||||
|
)}
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button type="submit" className="w-full" isLoading={isLoading}>
|
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||||
Save Settings
|
Save Settings
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -60,6 +60,30 @@ export const commonCronExpressions = [
|
|||||||
{ label: "Custom", value: "custom" },
|
{ label: "Custom", value: "custom" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const commonTimezones = [
|
||||||
|
{ label: "UTC (Coordinated Universal Time)", value: "UTC" },
|
||||||
|
{ label: "America/New_York (Eastern Time)", value: "America/New_York" },
|
||||||
|
{ label: "America/Chicago (Central Time)", value: "America/Chicago" },
|
||||||
|
{ label: "America/Denver (Mountain Time)", value: "America/Denver" },
|
||||||
|
{ label: "America/Los_Angeles (Pacific Time)", value: "America/Los_Angeles" },
|
||||||
|
{
|
||||||
|
label: "America/Mexico_City (Central Mexico)",
|
||||||
|
value: "America/Mexico_City",
|
||||||
|
},
|
||||||
|
{ label: "America/Sao_Paulo (Brasilia Time)", value: "America/Sao_Paulo" },
|
||||||
|
{ label: "Europe/London (Greenwich Mean Time)", value: "Europe/London" },
|
||||||
|
{ label: "Europe/Paris (Central European Time)", value: "Europe/Paris" },
|
||||||
|
{ label: "Europe/Berlin (Central European Time)", value: "Europe/Berlin" },
|
||||||
|
{ label: "Asia/Tokyo (Japan Standard Time)", value: "Asia/Tokyo" },
|
||||||
|
{ label: "Asia/Shanghai (China Standard Time)", value: "Asia/Shanghai" },
|
||||||
|
{ label: "Asia/Dubai (Gulf Standard Time)", value: "Asia/Dubai" },
|
||||||
|
{ label: "Asia/Kolkata (India Standard Time)", value: "Asia/Kolkata" },
|
||||||
|
{
|
||||||
|
label: "Australia/Sydney (Australian Eastern Time)",
|
||||||
|
value: "Australia/Sydney",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
@@ -75,6 +99,7 @@ const formSchema = z
|
|||||||
"dokploy-server",
|
"dokploy-server",
|
||||||
]),
|
]),
|
||||||
script: z.string(),
|
script: z.string(),
|
||||||
|
timezone: z.string().optional(),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.superRefine((data, ctx) => {
|
||||||
if (data.scheduleType === "compose" && !data.serviceName) {
|
if (data.scheduleType === "compose" && !data.serviceName) {
|
||||||
@@ -213,6 +238,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
serviceName: "",
|
serviceName: "",
|
||||||
scheduleType: scheduleType || "application",
|
scheduleType: scheduleType || "application",
|
||||||
script: "",
|
script: "",
|
||||||
|
timezone: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -251,6 +277,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
serviceName: schedule.serviceName || "",
|
serviceName: schedule.serviceName || "",
|
||||||
scheduleType: schedule.scheduleType,
|
scheduleType: schedule.scheduleType,
|
||||||
script: schedule.script || "",
|
script: schedule.script || "",
|
||||||
|
timezone: schedule.timezone || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form, schedule, scheduleId]);
|
}, [form, schedule, scheduleId]);
|
||||||
@@ -464,6 +491,54 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
formControl={form.control}
|
formControl={form.control}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="timezone"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="flex items-center gap-2">
|
||||||
|
Timezone
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>
|
||||||
|
Select a timezone for the schedule. If not
|
||||||
|
specified, UTC will be used.
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => {
|
||||||
|
field.onChange(value);
|
||||||
|
}}
|
||||||
|
value={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="UTC (default)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{commonTimezones.map((tz) => (
|
||||||
|
<SelectItem key={tz.value} value={tz.value}>
|
||||||
|
{tz.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
Optional: Choose a timezone for the schedule execution time
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{(scheduleTypeForm === "application" ||
|
{(scheduleTypeForm === "application" ||
|
||||||
scheduleTypeForm === "compose") && (
|
scheduleTypeForm === "compose") && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function parseLogs(logString: string): LogLine[] {
|
|||||||
// { timestamp: new Date("2024-12-10T10:00:00.000Z"),
|
// { timestamp: new Date("2024-12-10T10:00:00.000Z"),
|
||||||
// message: "The server is running on port 8080" }
|
// message: "The server is running on port 8080" }
|
||||||
const logRegex =
|
const logRegex =
|
||||||
/^(?:(\d+)\s+)?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC)?\s*(.*)$/;
|
/^(?:(?<lineNumber>\d+)\s+)?(?<timestamp>(?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} UTC))?\s*(?<message>[\s\S]*)$/;
|
||||||
|
|
||||||
return logString
|
return logString
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -59,7 +59,7 @@ export function parseLogs(logString: string): LogLine[] {
|
|||||||
const match = line.match(logRegex);
|
const match = line.match(logRegex);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
const [, , timestamp, message] = match;
|
const { timestamp, message } = match.groups ?? {};
|
||||||
|
|
||||||
if (!message?.trim()) return null;
|
if (!message?.trim()) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export const ImpersonationBar = () => {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|
||||||
toast.success("Successfully impersonating user", {
|
toast.success("Successfully impersonating user", {
|
||||||
description: `You are now viewing as ${selectedUser.name || selectedUser.email}`,
|
description: `You are now viewing as ${`${selectedUser.name} ${selectedUser.lastName}`.trim() || selectedUser.email}`,
|
||||||
});
|
});
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -195,7 +195,8 @@ export const ImpersonationBar = () => {
|
|||||||
<UserIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
<UserIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||||
<span className="truncate flex flex-col items-start">
|
<span className="truncate flex flex-col items-start">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{selectedUser.name || ""}
|
{`${selectedUser.name} ${selectedUser.lastName}`.trim() ||
|
||||||
|
""}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{selectedUser.email}
|
{selectedUser.email}
|
||||||
@@ -242,7 +243,8 @@ export const ImpersonationBar = () => {
|
|||||||
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
<UserIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
<span className="flex flex-col items-start">
|
<span className="flex flex-col items-start">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-sm font-medium">
|
||||||
{user.name || ""}
|
{`${user.name} ${user.lastName}`.trim() ||
|
||||||
|
""}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{user.email} • {user.role}
|
{user.email} • {user.role}
|
||||||
@@ -283,10 +285,14 @@ export const ImpersonationBar = () => {
|
|||||||
<AvatarImage
|
<AvatarImage
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
src={data?.user?.image || ""}
|
src={data?.user?.image || ""}
|
||||||
alt={data?.user?.name || ""}
|
alt={
|
||||||
|
`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
|
||||||
|
""
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{data?.user?.name?.slice(0, 2).toUpperCase() || "U"}
|
{`${data?.user?.firstName?.[0] || ""}${data?.user?.lastName?.[0] || ""}`.toUpperCase() ||
|
||||||
|
"U"}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -299,7 +305,8 @@ export const ImpersonationBar = () => {
|
|||||||
Impersonating
|
Impersonating
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{data?.user?.name || ""}
|
{`${data?.user?.firstName} ${data?.user?.lastName}`.trim() ||
|
||||||
|
""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
|
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { DockerNetworkChart } from "./docker-network-chart";
|
|||||||
|
|
||||||
const defaultData = {
|
const defaultData = {
|
||||||
cpu: {
|
cpu: {
|
||||||
value: 0,
|
value: "0%",
|
||||||
time: "",
|
time: "",
|
||||||
},
|
},
|
||||||
memory: {
|
memory: {
|
||||||
@@ -46,7 +46,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
export interface DockerStats {
|
export interface DockerStats {
|
||||||
cpu: {
|
cpu: {
|
||||||
value: number;
|
value: string;
|
||||||
time: string;
|
time: string;
|
||||||
};
|
};
|
||||||
memory: {
|
memory: {
|
||||||
@@ -220,7 +220,13 @@ export const ContainerFreeMonitoring = ({
|
|||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Used: {currentData.cpu.value}
|
Used: {currentData.cpu.value}
|
||||||
</span>
|
</span>
|
||||||
<Progress value={currentData.cpu.value} className="w-[100%]" />
|
<Progress
|
||||||
|
value={Number.parseInt(
|
||||||
|
currentData.cpu.value.replace("%", ""),
|
||||||
|
10,
|
||||||
|
)}
|
||||||
|
className="w-[100%]"
|
||||||
|
/>
|
||||||
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
|
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import type { findEnvironmentsByProjectId } from "@dokploy/server";
|
import type { findEnvironmentsByProjectId } from "@dokploy/server";
|
||||||
import {
|
import { ChevronDownIcon, PencilIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||||
ChevronDownIcon,
|
|
||||||
PencilIcon,
|
|
||||||
PlusIcon,
|
|
||||||
Terminal,
|
|
||||||
TrashIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
|
|
||||||
import { AlertBlock } from "@/components/shared/alert-block";
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -109,7 +102,9 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to create environment");
|
toast.error(
|
||||||
|
`Failed to create environment: ${error instanceof Error ? error.message : error}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,7 +125,9 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Failed to update environment");
|
toast.error(
|
||||||
|
`Failed to update environment: ${error instanceof Error ? error.message : error}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,15 +144,18 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
setIsDeleteDialogOpen(false);
|
setIsDeleteDialogOpen(false);
|
||||||
setSelectedEnvironment(null);
|
setSelectedEnvironment(null);
|
||||||
|
|
||||||
// Redirect to production if we deleted the current environment
|
// Redirect to first available environment if we deleted the current environment
|
||||||
if (selectedEnvironment.environmentId === currentEnvironmentId) {
|
if (selectedEnvironment.environmentId === currentEnvironmentId) {
|
||||||
const productionEnv = environments?.find(
|
const firstEnv = environments?.find(
|
||||||
(env) => env.name === "production",
|
(env) => env.environmentId !== selectedEnvironment.environmentId,
|
||||||
);
|
);
|
||||||
if (productionEnv) {
|
if (firstEnv) {
|
||||||
router.push(
|
router.push(
|
||||||
`/dashboard/project/${projectId}/environment/${productionEnv.environmentId}`,
|
`/dashboard/project/${projectId}/environment/${firstEnv.environmentId}`,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// No other environments, redirect to project page
|
||||||
|
router.push(`/dashboard/project/${projectId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -246,22 +246,8 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<div className="flex items-center gap-1 px-2">
|
||||||
{/* Action buttons for non-production environments */}
|
{!environment.isDefault && (
|
||||||
{/* <EnvironmentVariables environmentId={environment.environmentId}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Terminal className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</EnvironmentVariables> */}
|
|
||||||
{environment.name !== "production" && (
|
|
||||||
<div className="flex items-center gap-1 px-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -273,22 +259,21 @@ export const AdvancedEnvironmentSelector = ({
|
|||||||
>
|
>
|
||||||
<PencilIcon className="h-3 w-3" />
|
<PencilIcon className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
{canDeleteEnvironments && (
|
{canDeleteEnvironments && !environment.isDefault && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openDeleteDialog(environment);
|
openDeleteDialog(environment);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TrashIcon className="h-3 w-3" />
|
<TrashIcon className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||||
@@ -54,16 +55,23 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { TimeBadge } from "@/components/ui/time-badge";
|
import { TimeBadge } from "@/components/ui/time-badge";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { useDebounce } from "@/utils/hooks/use-debounce";
|
||||||
import { HandleProject } from "./handle-project";
|
import { HandleProject } from "./handle-project";
|
||||||
import { ProjectEnvironment } from "./project-environment";
|
import { ProjectEnvironment } from "./project-environment";
|
||||||
|
|
||||||
export const ShowProjects = () => {
|
export const ShowProjects = () => {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
const router = useRouter();
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data, isLoading } = api.project.all.useQuery();
|
const { data, isLoading } = api.project.all.useQuery();
|
||||||
const { data: auth } = api.user.get.useQuery();
|
const { data: auth } = api.user.get.useQuery();
|
||||||
const { mutateAsync } = api.project.remove.useMutation();
|
const { mutateAsync } = api.project.remove.useMutation();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState(
|
||||||
|
router.isReady && typeof router.query.q === "string" ? router.query.q : "",
|
||||||
|
);
|
||||||
|
const debouncedSearchQuery = useDebounce(searchQuery, 500);
|
||||||
|
|
||||||
const [sortBy, setSortBy] = useState<string>(() => {
|
const [sortBy, setSortBy] = useState<string>(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
return localStorage.getItem("projectsSort") || "createdAt-desc";
|
return localStorage.getItem("projectsSort") || "createdAt-desc";
|
||||||
@@ -75,14 +83,41 @@ export const ShowProjects = () => {
|
|||||||
localStorage.setItem("projectsSort", sortBy);
|
localStorage.setItem("projectsSort", sortBy);
|
||||||
}, [sortBy]);
|
}, [sortBy]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!router.isReady) return;
|
||||||
|
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
|
||||||
|
if (urlQuery !== searchQuery) {
|
||||||
|
setSearchQuery(urlQuery);
|
||||||
|
}
|
||||||
|
}, [router.isReady, router.query.q]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!router.isReady) return;
|
||||||
|
const urlQuery = typeof router.query.q === "string" ? router.query.q : "";
|
||||||
|
if (debouncedSearchQuery === urlQuery) return;
|
||||||
|
|
||||||
|
const newQuery = { ...router.query };
|
||||||
|
if (debouncedSearchQuery) {
|
||||||
|
newQuery.q = debouncedSearchQuery;
|
||||||
|
} else {
|
||||||
|
delete newQuery.q;
|
||||||
|
}
|
||||||
|
router.replace({ pathname: router.pathname, query: newQuery }, undefined, {
|
||||||
|
shallow: true,
|
||||||
|
});
|
||||||
|
}, [debouncedSearchQuery]);
|
||||||
|
|
||||||
const filteredProjects = useMemo(() => {
|
const filteredProjects = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
||||||
// First filter by search query
|
|
||||||
const filtered = data.filter(
|
const filtered = data.filter(
|
||||||
(project) =>
|
(project) =>
|
||||||
project.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
project.name
|
||||||
project.description?.toLowerCase().includes(searchQuery.toLowerCase()),
|
.toLowerCase()
|
||||||
|
.includes(debouncedSearchQuery.toLowerCase()) ||
|
||||||
|
project.description
|
||||||
|
?.toLowerCase()
|
||||||
|
.includes(debouncedSearchQuery.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Then sort the filtered results
|
// Then sort the filtered results
|
||||||
@@ -130,7 +165,7 @@ export const ShowProjects = () => {
|
|||||||
}
|
}
|
||||||
return direction === "asc" ? comparison : -comparison;
|
return direction === "asc" ? comparison : -comparison;
|
||||||
});
|
});
|
||||||
}, [data, searchQuery, sortBy]);
|
}, [data, debouncedSearchQuery, sortBy]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -65,6 +65,25 @@ export const ShowRequests = () => {
|
|||||||
to: Date | undefined;
|
to: Date | undefined;
|
||||||
}>(getDefaultDateRange());
|
}>(getDefaultDateRange());
|
||||||
|
|
||||||
|
// Check if logs exist to determine if traefik has been reloaded
|
||||||
|
// Only fetch when active to minimize network calls
|
||||||
|
const { data: statsLogsCheck } = api.settings.readStatsLogs.useQuery(
|
||||||
|
{
|
||||||
|
page: {
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!isActive,
|
||||||
|
refetchInterval: 5000, // Check every 5 seconds when active
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine if warning should be shown
|
||||||
|
// Show warning only if active but no logs exist yet
|
||||||
|
const shouldShowWarning = isActive && (statsLogsCheck?.totalCount ?? 0) === 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (logCleanupStatus) {
|
if (logCleanupStatus) {
|
||||||
setCronExpression(logCleanupStatus.cronExpression || "0 0 * * *");
|
setCronExpression(logCleanupStatus.cronExpression || "0 0 * * *");
|
||||||
@@ -85,16 +104,18 @@ export const ShowRequests = () => {
|
|||||||
See all the incoming requests that pass trough Traefik
|
See all the incoming requests that pass trough Traefik
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
|
||||||
<AlertBlock type="warning">
|
{shouldShowWarning && (
|
||||||
When you activate, you need to reload traefik to apply the
|
<AlertBlock type="warning">
|
||||||
changes, you can reload traefik in{" "}
|
When you activate, you need to reload traefik to apply the
|
||||||
<Link
|
changes, you can reload traefik in{" "}
|
||||||
href="/dashboard/settings/server"
|
<Link
|
||||||
className="text-primary"
|
href="/dashboard/settings/server"
|
||||||
>
|
className="text-primary"
|
||||||
Settings
|
>
|
||||||
</Link>
|
Settings
|
||||||
</AlertBlock>
|
</Link>
|
||||||
|
</AlertBlock>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 py-8 border-t">
|
<CardContent className="space-y-2 py-8 border-t">
|
||||||
<div className="flex w-full gap-4 justify-end items-center">
|
<div className="flex w-full gap-4 justify-end items-center">
|
||||||
|
|||||||
@@ -89,24 +89,26 @@ export const SearchCommand = () => {
|
|||||||
<CommandGroup heading={"Projects"}>
|
<CommandGroup heading={"Projects"}>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
{data?.map((project) => {
|
{data?.map((project) => {
|
||||||
const productionEnvironment = project.environments.find(
|
// Find default environment, or fall back to first environment
|
||||||
(environment) => environment.name === "production",
|
const defaultEnvironment =
|
||||||
);
|
project.environments.find(
|
||||||
|
(environment) => environment.isDefault,
|
||||||
|
) || project?.environments?.[0];
|
||||||
|
|
||||||
if (!productionEnvironment) return null;
|
if (!defaultEnvironment) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={project.projectId}
|
key={project.projectId}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
router.push(
|
router.push(
|
||||||
`/dashboard/project/${project.projectId}/environment/${productionEnvironment!.environmentId}`,
|
`/dashboard/project/${project.projectId}/environment/${defaultEnvironment.environmentId}`,
|
||||||
);
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BookIcon className="size-4 text-muted-foreground mr-2" />
|
<BookIcon className="size-4 text-muted-foreground mr-2" />
|
||||||
{project.name} / {productionEnvironment!.name}
|
{project.name} / {defaultEnvironment.name}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -45,7 +45,34 @@ const AddRegistrySchema = z.object({
|
|||||||
password: z.string().min(1, {
|
password: z.string().min(1, {
|
||||||
message: "Password is required",
|
message: "Password is required",
|
||||||
}),
|
}),
|
||||||
registryUrl: z.string(),
|
registryUrl: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine(
|
||||||
|
(val) => {
|
||||||
|
// If empty or undefined, skip validation (field is optional)
|
||||||
|
if (!val || val.trim().length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Validate that it's a valid hostname (no protocol, no path, optional port)
|
||||||
|
// Valid formats: example.com, registry.example.com, [::1], example.com:5000
|
||||||
|
// Invalid: https://example.com, example.com/path
|
||||||
|
const trimmed = val.trim();
|
||||||
|
// Check for protocol or path - these are not allowed
|
||||||
|
if (/^https?:\/\//i.test(trimmed) || trimmed.includes("/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Basic hostname validation: allow alphanumeric, dots, hyphens, underscores, and IPv6 in brackets
|
||||||
|
// Allow optional port at the end
|
||||||
|
const hostnameRegex =
|
||||||
|
/^(?:\[[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,253}[a-zA-Z0-9])?)(?::\d+)?$/;
|
||||||
|
return hostnameRegex.test(trimmed);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"Invalid registry URL. Please enter only the hostname (e.g., example.com or registry.example.com). Do not include protocol (https://) or paths.",
|
||||||
|
},
|
||||||
|
),
|
||||||
imagePrefix: z.string(),
|
imagePrefix: z.string(),
|
||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
});
|
});
|
||||||
@@ -99,6 +126,9 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
const registryName = form.watch("registryName");
|
const registryName = form.watch("registryName");
|
||||||
const imagePrefix = form.watch("imagePrefix");
|
const imagePrefix = form.watch("imagePrefix");
|
||||||
const serverId = form.watch("serverId");
|
const serverId = form.watch("serverId");
|
||||||
|
const selectedServer = servers?.find(
|
||||||
|
(server) => server.serverId === serverId,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (registry) {
|
if (registry) {
|
||||||
@@ -125,7 +155,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
password: data.password,
|
password: data.password,
|
||||||
registryName: data.registryName,
|
registryName: data.registryName,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
registryUrl: data.registryUrl,
|
registryUrl: data.registryUrl || "",
|
||||||
registryType: "cloud",
|
registryType: "cloud",
|
||||||
imagePrefix: data.imagePrefix,
|
imagePrefix: data.imagePrefix,
|
||||||
serverId: data.serverId,
|
serverId: data.serverId,
|
||||||
@@ -261,6 +291,10 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Registry URL</FormLabel>
|
<FormLabel>Registry URL</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Enter only the hostname (e.g.,
|
||||||
|
aws_account_id.dkr.ecr.us-west-2.amazonaws.com).
|
||||||
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="aws_account_id.dkr.ecr.us-west-2.amazonaws.com"
|
placeholder="aws_account_id.dkr.ecr.us-west-2.amazonaws.com"
|
||||||
@@ -282,8 +316,40 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Server {!isCloud && "(Optional)"}</FormLabel>
|
<FormLabel>Server {!isCloud && "(Optional)"}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Select a server to test the registry. this will run the
|
{!isCloud ? (
|
||||||
following command on the server
|
<>
|
||||||
|
{serverId && serverId !== "none" && selectedServer ? (
|
||||||
|
<>
|
||||||
|
Authentication will be performed on{" "}
|
||||||
|
<strong>{selectedServer.name}</strong>. This
|
||||||
|
registry will be available on this server.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Choose where to authenticate with the registry. By
|
||||||
|
default, authentication occurs on the Dokploy
|
||||||
|
server. Select a specific server to authenticate
|
||||||
|
from that server instead.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{serverId && serverId !== "none" && selectedServer ? (
|
||||||
|
<>
|
||||||
|
Authentication will be performed on{" "}
|
||||||
|
<strong>{selectedServer.name}</strong>. This
|
||||||
|
registry will be available on this server.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Select a server to authenticate with the registry.
|
||||||
|
The authentication will be performed from the
|
||||||
|
selected server.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Select
|
<Select
|
||||||
@@ -345,7 +411,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
|
|||||||
await testRegistry({
|
await testRegistry({
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
registryUrl: registryUrl,
|
registryUrl: registryUrl || "",
|
||||||
registryName: registryName,
|
registryName: registryName,
|
||||||
registryType: "cloud",
|
registryType: "cloud",
|
||||||
imagePrefix: imagePrefix,
|
imagePrefix: imagePrefix,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Mail,
|
||||||
|
PenBoxIcon,
|
||||||
|
PlusIcon,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useFieldArray, useForm } from "react-hook-form";
|
import { useFieldArray, useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -44,6 +50,7 @@ const notificationBaseSchema = z.object({
|
|||||||
appDeploy: z.boolean().default(false),
|
appDeploy: z.boolean().default(false),
|
||||||
appBuildError: z.boolean().default(false),
|
appBuildError: z.boolean().default(false),
|
||||||
databaseBackup: z.boolean().default(false),
|
databaseBackup: z.boolean().default(false),
|
||||||
|
volumeBackup: z.boolean().default(false),
|
||||||
dokployRestart: z.boolean().default(false),
|
dokployRestart: z.boolean().default(false),
|
||||||
dockerCleanup: z.boolean().default(false),
|
dockerCleanup: z.boolean().default(false),
|
||||||
serverThreshold: z.boolean().default(false),
|
serverThreshold: z.boolean().default(false),
|
||||||
@@ -107,6 +114,21 @@ export const notificationSchema = z.discriminatedUnion("type", [
|
|||||||
priority: z.number().min(1).max(5).default(3),
|
priority: z.number().min(1).max(5).default(3),
|
||||||
})
|
})
|
||||||
.merge(notificationBaseSchema),
|
.merge(notificationBaseSchema),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.literal("custom"),
|
||||||
|
endpoint: z.string().min(1, { message: "Endpoint URL is required" }),
|
||||||
|
headers: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
key: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.default([]),
|
||||||
|
})
|
||||||
|
.merge(notificationBaseSchema),
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
type: z.literal("lark"),
|
type: z.literal("lark"),
|
||||||
@@ -144,6 +166,10 @@ export const notificationsMap = {
|
|||||||
icon: <NtfyIcon />,
|
icon: <NtfyIcon />,
|
||||||
label: "ntfy",
|
label: "ntfy",
|
||||||
},
|
},
|
||||||
|
custom: {
|
||||||
|
icon: <PenBoxIcon size={29} className="text-muted-foreground" />,
|
||||||
|
label: "Custom",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NotificationSchema = z.infer<typeof notificationSchema>;
|
export type NotificationSchema = z.infer<typeof notificationSchema>;
|
||||||
@@ -179,6 +205,13 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
api.notification.testNtfyConnection.useMutation();
|
api.notification.testNtfyConnection.useMutation();
|
||||||
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
|
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
|
||||||
api.notification.testLarkConnection.useMutation();
|
api.notification.testLarkConnection.useMutation();
|
||||||
|
|
||||||
|
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
|
||||||
|
api.notification.testCustomConnection.useMutation();
|
||||||
|
|
||||||
|
const customMutation = notificationId
|
||||||
|
? api.notification.updateCustom.useMutation()
|
||||||
|
: api.notification.createCustom.useMutation();
|
||||||
const slackMutation = notificationId
|
const slackMutation = notificationId
|
||||||
? api.notification.updateSlack.useMutation()
|
? api.notification.updateSlack.useMutation()
|
||||||
: api.notification.createSlack.useMutation();
|
: api.notification.createSlack.useMutation();
|
||||||
@@ -217,6 +250,15 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
name: "toAddresses" as never,
|
name: "toAddresses" as never,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
fields: headerFields,
|
||||||
|
append: appendHeader,
|
||||||
|
remove: removeHeader,
|
||||||
|
} = useFieldArray({
|
||||||
|
control: form.control,
|
||||||
|
name: "headers" as never,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (type === "email" && fields.length === 0) {
|
if (type === "email" && fields.length === 0) {
|
||||||
append("");
|
append("");
|
||||||
@@ -231,6 +273,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
dockerCleanup: notification.dockerCleanup,
|
dockerCleanup: notification.dockerCleanup,
|
||||||
webhookUrl: notification.slack?.webhookUrl,
|
webhookUrl: notification.slack?.webhookUrl,
|
||||||
channel: notification.slack?.channel || "",
|
channel: notification.slack?.channel || "",
|
||||||
@@ -244,6 +287,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
botToken: notification.telegram?.botToken,
|
botToken: notification.telegram?.botToken,
|
||||||
messageThreadId: notification.telegram?.messageThreadId || "",
|
messageThreadId: notification.telegram?.messageThreadId || "",
|
||||||
chatId: notification.telegram?.chatId,
|
chatId: notification.telegram?.chatId,
|
||||||
@@ -258,6 +302,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
webhookUrl: notification.discord?.webhookUrl,
|
webhookUrl: notification.discord?.webhookUrl,
|
||||||
decoration: notification.discord?.decoration || undefined,
|
decoration: notification.discord?.decoration || undefined,
|
||||||
@@ -271,6 +316,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
smtpServer: notification.email?.smtpServer,
|
smtpServer: notification.email?.smtpServer,
|
||||||
smtpPort: notification.email?.smtpPort,
|
smtpPort: notification.email?.smtpPort,
|
||||||
@@ -288,6 +334,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
appToken: notification.gotify?.appToken,
|
appToken: notification.gotify?.appToken,
|
||||||
decoration: notification.gotify?.decoration || undefined,
|
decoration: notification.gotify?.decoration || undefined,
|
||||||
@@ -302,6 +349,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: notification.appDeploy,
|
appDeploy: notification.appDeploy,
|
||||||
dokployRestart: notification.dokployRestart,
|
dokployRestart: notification.dokployRestart,
|
||||||
databaseBackup: notification.databaseBackup,
|
databaseBackup: notification.databaseBackup,
|
||||||
|
volumeBackup: notification.volumeBackup,
|
||||||
type: notification.notificationType,
|
type: notification.notificationType,
|
||||||
accessToken: notification.ntfy?.accessToken || "",
|
accessToken: notification.ntfy?.accessToken || "",
|
||||||
topic: notification.ntfy?.topic,
|
topic: notification.ntfy?.topic,
|
||||||
@@ -323,6 +371,26 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
dockerCleanup: notification.dockerCleanup,
|
dockerCleanup: notification.dockerCleanup,
|
||||||
serverThreshold: notification.serverThreshold,
|
serverThreshold: notification.serverThreshold,
|
||||||
});
|
});
|
||||||
|
} else if (notification.notificationType === "custom") {
|
||||||
|
form.reset({
|
||||||
|
appBuildError: notification.appBuildError,
|
||||||
|
appDeploy: notification.appDeploy,
|
||||||
|
dokployRestart: notification.dokployRestart,
|
||||||
|
databaseBackup: notification.databaseBackup,
|
||||||
|
type: notification.notificationType,
|
||||||
|
endpoint: notification.custom?.endpoint || "",
|
||||||
|
headers: notification.custom?.headers
|
||||||
|
? Object.entries(notification.custom.headers).map(
|
||||||
|
([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
name: notification.name,
|
||||||
|
dockerCleanup: notification.dockerCleanup,
|
||||||
|
serverThreshold: notification.serverThreshold,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
form.reset();
|
form.reset();
|
||||||
@@ -337,6 +405,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
gotify: gotifyMutation,
|
gotify: gotifyMutation,
|
||||||
ntfy: ntfyMutation,
|
ntfy: ntfyMutation,
|
||||||
lark: larkMutation,
|
lark: larkMutation,
|
||||||
|
custom: customMutation,
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (data: NotificationSchema) => {
|
const onSubmit = async (data: NotificationSchema) => {
|
||||||
@@ -345,6 +414,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy,
|
appDeploy,
|
||||||
dokployRestart,
|
dokployRestart,
|
||||||
databaseBackup,
|
databaseBackup,
|
||||||
|
volumeBackup,
|
||||||
dockerCleanup,
|
dockerCleanup,
|
||||||
serverThreshold,
|
serverThreshold,
|
||||||
} = data;
|
} = data;
|
||||||
@@ -355,6 +425,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
channel: data.channel,
|
channel: data.channel,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -369,6 +440,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
botToken: data.botToken,
|
botToken: data.botToken,
|
||||||
messageThreadId: data.messageThreadId || "",
|
messageThreadId: data.messageThreadId || "",
|
||||||
chatId: data.chatId,
|
chatId: data.chatId,
|
||||||
@@ -384,6 +456,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
decoration: data.decoration,
|
decoration: data.decoration,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -398,6 +471,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
smtpServer: data.smtpServer,
|
smtpServer: data.smtpServer,
|
||||||
smtpPort: data.smtpPort,
|
smtpPort: data.smtpPort,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
@@ -416,6 +490,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
serverUrl: data.serverUrl,
|
serverUrl: data.serverUrl,
|
||||||
appToken: data.appToken,
|
appToken: data.appToken,
|
||||||
priority: data.priority,
|
priority: data.priority,
|
||||||
@@ -431,6 +506,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
appDeploy: appDeploy,
|
appDeploy: appDeploy,
|
||||||
dokployRestart: dokployRestart,
|
dokployRestart: dokployRestart,
|
||||||
databaseBackup: databaseBackup,
|
databaseBackup: databaseBackup,
|
||||||
|
volumeBackup: volumeBackup,
|
||||||
serverUrl: data.serverUrl,
|
serverUrl: data.serverUrl,
|
||||||
accessToken: data.accessToken || "",
|
accessToken: data.accessToken || "",
|
||||||
topic: data.topic,
|
topic: data.topic,
|
||||||
@@ -453,6 +529,32 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
larkId: notification?.larkId || "",
|
larkId: notification?.larkId || "",
|
||||||
serverThreshold: serverThreshold,
|
serverThreshold: serverThreshold,
|
||||||
});
|
});
|
||||||
|
} else if (data.type === "custom") {
|
||||||
|
// Convert headers array to object
|
||||||
|
const headersRecord =
|
||||||
|
data.headers && data.headers.length > 0
|
||||||
|
? data.headers.reduce(
|
||||||
|
(acc, { key, value }) => {
|
||||||
|
if (key.trim()) acc[key] = value;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
promise = customMutation.mutateAsync({
|
||||||
|
appBuildError: appBuildError,
|
||||||
|
appDeploy: appDeploy,
|
||||||
|
dokployRestart: dokployRestart,
|
||||||
|
databaseBackup: databaseBackup,
|
||||||
|
endpoint: data.endpoint,
|
||||||
|
headers: headersRecord,
|
||||||
|
name: data.name,
|
||||||
|
dockerCleanup: dockerCleanup,
|
||||||
|
serverThreshold: serverThreshold,
|
||||||
|
notificationId: notificationId || "",
|
||||||
|
customId: notification?.customId || "",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (promise) {
|
if (promise) {
|
||||||
@@ -1043,7 +1145,92 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{type === "custom" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="endpoint"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Webhook URL</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="https://api.example.com/webhook"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
The URL where POST requests will be sent with
|
||||||
|
notification data.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<FormLabel>Headers</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Optional. Custom headers for your POST request (e.g.,
|
||||||
|
Authorization, Content-Type).
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{headerFields.map((field, index) => (
|
||||||
|
<div
|
||||||
|
key={field.id}
|
||||||
|
className="flex items-center gap-2 p-2 border rounded-md bg-muted/50"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`headers.${index}.key` as never}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-1">
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Key" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name={`headers.${index}.value` as never}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex-[2]">
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Value" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeHeader(index)}
|
||||||
|
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => appendHeader({ key: "", value: "" })}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4 mr-2" />
|
||||||
|
Add header
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{type === "lark" && (
|
{type === "lark" && (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -1134,6 +1321,27 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="volumeBackup"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm gap-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>Volume Backup</FormLabel>
|
||||||
|
<FormDescription>
|
||||||
|
Trigger the action when a volume backup is created.
|
||||||
|
</FormDescription>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="dockerCleanup"
|
name="dockerCleanup"
|
||||||
@@ -1215,7 +1423,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
isLoadingEmail ||
|
isLoadingEmail ||
|
||||||
isLoadingGotify ||
|
isLoadingGotify ||
|
||||||
isLoadingNtfy ||
|
isLoadingNtfy ||
|
||||||
isLoadingLark
|
isLoadingLark ||
|
||||||
|
isLoadingCustom
|
||||||
}
|
}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1269,6 +1478,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
|
|||||||
await testLarkConnection({
|
await testLarkConnection({
|
||||||
webhookUrl: data.webhookUrl,
|
webhookUrl: data.webhookUrl,
|
||||||
});
|
});
|
||||||
|
} else if (data.type === "custom") {
|
||||||
|
const headersRecord =
|
||||||
|
data.headers && data.headers.length > 0
|
||||||
|
? data.headers.reduce(
|
||||||
|
(acc, { key, value }) => {
|
||||||
|
if (key.trim()) acc[key] = value;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string>,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
await testCustomConnection({
|
||||||
|
endpoint: data.endpoint,
|
||||||
|
headers: headersRecord,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
toast.success("Connection Success");
|
toast.success("Connection Success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Bell, Loader2, Mail, Trash2 } from "lucide-react";
|
import { Bell, Loader2, Mail, PenBoxIcon, Trash2 } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
DiscordIcon,
|
DiscordIcon,
|
||||||
@@ -96,6 +96,11 @@ export const ShowNotifications = () => {
|
|||||||
<NtfyIcon className="size-6" />
|
<NtfyIcon className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{notification.notificationType === "custom" && (
|
||||||
|
<div className="flex items-center justify-center rounded-lg ">
|
||||||
|
<PenBoxIcon className="size-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{notification.notificationType === "lark" && (
|
{notification.notificationType === "lark" && (
|
||||||
<div className="flex items-center justify-center rounded-lg">
|
<div className="flex items-center justify-center rounded-lg">
|
||||||
<LarkIcon className="size-7 text-muted-foreground" />
|
<LarkIcon className="size-7 text-muted-foreground" />
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const profileSchema = z.object({
|
|||||||
currentPassword: z.string().nullable(),
|
currentPassword: z.string().nullable(),
|
||||||
image: z.string().optional(),
|
image: z.string().optional(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
lastName: z.string().optional(),
|
||||||
allowImpersonation: z.boolean().optional().default(false),
|
allowImpersonation: z.boolean().optional().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,7 +89,8 @@ export const ProfileForm = () => {
|
|||||||
image: data?.user?.image || "",
|
image: data?.user?.image || "",
|
||||||
currentPassword: "",
|
currentPassword: "",
|
||||||
allowImpersonation: data?.user?.allowImpersonation || false,
|
allowImpersonation: data?.user?.allowImpersonation || false,
|
||||||
name: data?.user?.name || "",
|
name: data?.user?.firstName || "",
|
||||||
|
lastName: data?.user?.lastName || "",
|
||||||
},
|
},
|
||||||
resolver: zodResolver(profileSchema),
|
resolver: zodResolver(profileSchema),
|
||||||
});
|
});
|
||||||
@@ -102,7 +104,8 @@ export const ProfileForm = () => {
|
|||||||
image: data?.user?.image || "",
|
image: data?.user?.image || "",
|
||||||
currentPassword: form.getValues("currentPassword") || "",
|
currentPassword: form.getValues("currentPassword") || "",
|
||||||
allowImpersonation: data?.user?.allowImpersonation,
|
allowImpersonation: data?.user?.allowImpersonation,
|
||||||
name: data?.user?.name || "",
|
name: data?.user?.firstName || "",
|
||||||
|
lastName: data?.user?.lastName || "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keepValues: true,
|
keepValues: true,
|
||||||
@@ -127,6 +130,7 @@ export const ProfileForm = () => {
|
|||||||
currentPassword: values.currentPassword || undefined,
|
currentPassword: values.currentPassword || undefined,
|
||||||
allowImpersonation: values.allowImpersonation,
|
allowImpersonation: values.allowImpersonation,
|
||||||
name: values.name || undefined,
|
name: values.name || undefined,
|
||||||
|
lastName: values.lastName || undefined,
|
||||||
});
|
});
|
||||||
await refetch();
|
await refetch();
|
||||||
toast.success("Profile Updated");
|
toast.success("Profile Updated");
|
||||||
@@ -136,6 +140,7 @@ export const ProfileForm = () => {
|
|||||||
image: values.image,
|
image: values.image,
|
||||||
currentPassword: "",
|
currentPassword: "",
|
||||||
name: values.name || "",
|
name: values.name || "",
|
||||||
|
lastName: values.lastName || "",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error("Error updating the profile");
|
toast.error("Error updating the profile");
|
||||||
@@ -180,9 +185,22 @@ export const ProfileForm = () => {
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>First Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Name" {...field} />
|
<Input placeholder="John" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="lastName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Last Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Doe" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -280,7 +298,7 @@ export const ProfileForm = () => {
|
|||||||
<Avatar className="default-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-transform">
|
<Avatar className="default-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-transform">
|
||||||
<AvatarFallback className="rounded-lg">
|
<AvatarFallback className="rounded-lg">
|
||||||
{getFallbackAvatarInitials(
|
{getFallbackAvatarInitials(
|
||||||
data?.user?.name,
|
`${data?.user?.firstName} ${data?.user?.lastName}`.trim(),
|
||||||
)}
|
)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -85,7 +87,26 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</EditTraefikEnv>
|
</EditTraefikEnv>
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DialogAction
|
||||||
|
title={
|
||||||
|
haveTraefikDashboardPortEnabled
|
||||||
|
? "Disable Traefik Dashboard"
|
||||||
|
: "Enable Traefik Dashboard"
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<div className="space-y-4">
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
The Traefik container will be recreated from scratch. This
|
||||||
|
means the container will be deleted and created again, which
|
||||||
|
may cause downtime in your applications.
|
||||||
|
</AlertBlock>
|
||||||
|
<p>
|
||||||
|
Are you sure you want to{" "}
|
||||||
|
{haveTraefikDashboardPortEnabled ? "disable" : "enable"} the
|
||||||
|
Traefik dashboard?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await toggleDashboard({
|
await toggleDashboard({
|
||||||
enableDashboard: !haveTraefikDashboardPortEnabled,
|
enableDashboard: !haveTraefikDashboardPortEnabled,
|
||||||
@@ -97,14 +118,26 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
|||||||
);
|
);
|
||||||
refetchDashboard();
|
refetchDashboard();
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch((error) => {
|
||||||
|
const errorMessage =
|
||||||
|
error?.message ||
|
||||||
|
"Failed to toggle dashboard. Please check if port 8080 is available.";
|
||||||
|
toast.error(errorMessage);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
className="w-full cursor-pointer space-x-3"
|
disabled={toggleDashboardIsLoading}
|
||||||
|
type="default"
|
||||||
>
|
>
|
||||||
<span>
|
<DropdownMenuItem
|
||||||
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"} Dashboard
|
onSelect={(e) => e.preventDefault()}
|
||||||
</span>
|
className="w-full cursor-pointer space-x-3"
|
||||||
</DropdownMenuItem>
|
>
|
||||||
|
<span>
|
||||||
|
{haveTraefikDashboardPortEnabled ? "Disable" : "Enable"}{" "}
|
||||||
|
Dashboard
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogAction>
|
||||||
<ManageTraefikPorts serverId={serverId}>
|
<ManageTraefikPorts serverId={serverId}>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export const AddInvitation = () => {
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="member">Member</SelectItem>
|
<SelectItem value="member">Member</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
|
|||||||
159
apps/dokploy/components/dashboard/settings/users/change-role.tsx
Normal file
159
apps/dokploy/components/dashboard/settings/users/change-role.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
|
||||||
|
const changeRoleSchema = z.object({
|
||||||
|
role: z.enum(["admin", "member"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ChangeRoleSchema = z.infer<typeof changeRoleSchema>;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
memberId: string;
|
||||||
|
currentRole: "admin" | "member";
|
||||||
|
userEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChangeRole = ({ memberId, currentRole, userEmail }: Props) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const utils = api.useUtils();
|
||||||
|
|
||||||
|
const { mutateAsync, isError, error, isLoading } =
|
||||||
|
api.organization.updateMemberRole.useMutation();
|
||||||
|
|
||||||
|
const form = useForm<ChangeRoleSchema>({
|
||||||
|
defaultValues: {
|
||||||
|
role: currentRole,
|
||||||
|
},
|
||||||
|
resolver: zodResolver(changeRoleSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
form.reset({
|
||||||
|
role: currentRole,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [form, currentRole, isOpen]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: ChangeRoleSchema) => {
|
||||||
|
await mutateAsync({
|
||||||
|
memberId,
|
||||||
|
role: data.role,
|
||||||
|
})
|
||||||
|
.then(async () => {
|
||||||
|
toast.success("Role updated successfully");
|
||||||
|
await utils.user.all.invalidate();
|
||||||
|
setIsOpen(false);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
toast.error(error?.message || "Error updating role");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<DialogTrigger className="" asChild>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="w-full cursor-pointer"
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
Change Role
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-[85vh] sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Change User Role</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Change the role for <strong>{userEmail}</strong>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
id="hook-form-change-role"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="w-full space-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="role"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Role</FormLabel>
|
||||||
|
<Select
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
<SelectItem value="member">Member</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormDescription>
|
||||||
|
<strong>Admin:</strong> Can manage users and settings.
|
||||||
|
<br />
|
||||||
|
<strong>Member:</strong> Limited permissions, can be
|
||||||
|
customized.
|
||||||
|
<br />
|
||||||
|
<em className="text-muted-foreground text-xs">
|
||||||
|
Note: Owner role is intransferible.
|
||||||
|
</em>
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
isLoading={isLoading}
|
||||||
|
form="hook-form-change-role"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Update Role
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -29,12 +29,15 @@ import {
|
|||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { AddUserPermissions } from "./add-permissions";
|
import { AddUserPermissions } from "./add-permissions";
|
||||||
|
import { ChangeRole } from "./change-role";
|
||||||
|
|
||||||
export const ShowUsers = () => {
|
export const ShowUsers = () => {
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data, isLoading, refetch } = api.user.all.useQuery();
|
const { data, isLoading, refetch } = api.user.all.useQuery();
|
||||||
const { mutateAsync } = api.user.remove.useMutation();
|
const { mutateAsync } = api.user.remove.useMutation();
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -81,6 +84,52 @@ export const ShowUsers = () => {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.map((member) => {
|
{data?.map((member) => {
|
||||||
|
const currentUserRole = data?.find(
|
||||||
|
(m) => m.user.id === session?.user?.id,
|
||||||
|
)?.role;
|
||||||
|
|
||||||
|
// Owner never has "Edit Permissions" (they're absolute owner)
|
||||||
|
// Other users can edit permissions if target is not themselves and target is a member
|
||||||
|
const canEditPermissions =
|
||||||
|
member.role !== "owner" &&
|
||||||
|
member.role === "member" &&
|
||||||
|
member.user.id !== session?.user?.id;
|
||||||
|
|
||||||
|
// Can change role based on hierarchy:
|
||||||
|
// - Owner: Can change anyone's role (except themselves and other owners)
|
||||||
|
// - Admin: Can only change member roles (not other admins or owners)
|
||||||
|
// - Owner role is intransferible
|
||||||
|
const canChangeRole =
|
||||||
|
member.role !== "owner" &&
|
||||||
|
member.user.id !== session?.user?.id &&
|
||||||
|
(currentUserRole === "owner" ||
|
||||||
|
(currentUserRole === "admin" &&
|
||||||
|
member.role === "member"));
|
||||||
|
|
||||||
|
// Delete/Unlink follow same hierarchy as role changes
|
||||||
|
// - Owner: Can delete/unlink anyone (except themselves and owner can't be deleted)
|
||||||
|
// - Admin: Can only delete/unlink members (not other admins or owner)
|
||||||
|
const canDelete =
|
||||||
|
member.role !== "owner" &&
|
||||||
|
!isCloud &&
|
||||||
|
member.user.id !== session?.user?.id &&
|
||||||
|
(currentUserRole === "owner" ||
|
||||||
|
(currentUserRole === "admin" &&
|
||||||
|
member.role === "member"));
|
||||||
|
|
||||||
|
const canUnlink =
|
||||||
|
member.role !== "owner" &&
|
||||||
|
member.user.id !== session?.user?.id &&
|
||||||
|
(currentUserRole === "owner" ||
|
||||||
|
(currentUserRole === "admin" &&
|
||||||
|
member.role === "member"));
|
||||||
|
|
||||||
|
const hasAnyAction =
|
||||||
|
canEditPermissions ||
|
||||||
|
canChangeRole ||
|
||||||
|
canDelete ||
|
||||||
|
canUnlink;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={member.id}>
|
<TableRow key={member.id}>
|
||||||
<TableCell className="w-[100px]">
|
<TableCell className="w-[100px]">
|
||||||
@@ -109,7 +158,7 @@ export const ShowUsers = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="text-right flex justify-end">
|
<TableCell className="text-right flex justify-end">
|
||||||
{member.role !== "owner" && (
|
{hasAnyAction ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -127,11 +176,23 @@ export const ShowUsers = () => {
|
|||||||
Actions
|
Actions
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|
||||||
<AddUserPermissions
|
{canChangeRole && (
|
||||||
userId={member.user.id}
|
<ChangeRole
|
||||||
/>
|
memberId={member.id}
|
||||||
|
currentRole={
|
||||||
|
member.role as "admin" | "member"
|
||||||
|
}
|
||||||
|
userEmail={member.user.email}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!isCloud && (
|
{canEditPermissions && (
|
||||||
|
<AddUserPermissions
|
||||||
|
userId={member.user.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canDelete && (
|
||||||
<DialogAction
|
<DialogAction
|
||||||
title="Delete User"
|
title="Delete User"
|
||||||
description="Are you sure you want to delete this user?"
|
description="Are you sure you want to delete this user?"
|
||||||
@@ -146,9 +207,10 @@ export const ShowUsers = () => {
|
|||||||
);
|
);
|
||||||
refetch();
|
refetch();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err) => {
|
||||||
toast.error(
|
toast.error(
|
||||||
"Error deleting destination",
|
err?.message ||
|
||||||
|
"Error deleting user",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -162,66 +224,79 @@ export const ShowUsers = () => {
|
|||||||
</DialogAction>
|
</DialogAction>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogAction
|
{canUnlink && (
|
||||||
title="Unlink User"
|
<DialogAction
|
||||||
description="Are you sure you want to unlink this user?"
|
title="Unlink User"
|
||||||
type="destructive"
|
description="Are you sure you want to unlink this user?"
|
||||||
onClick={async () => {
|
type="destructive"
|
||||||
if (!isCloud) {
|
onClick={async () => {
|
||||||
const orgCount =
|
if (!isCloud) {
|
||||||
await utils.user.checkUserOrganizations.fetch(
|
const orgCount =
|
||||||
{
|
await utils.user.checkUserOrganizations.fetch(
|
||||||
|
{
|
||||||
|
userId: member.user.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orgCount === 1) {
|
||||||
|
await mutateAsync({
|
||||||
userId: member.user.id,
|
userId: member.user.id,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
toast.success(
|
||||||
|
"User deleted successfully",
|
||||||
|
);
|
||||||
|
refetch();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error(
|
||||||
|
"Error deleting user",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } =
|
||||||
|
await authClient.organization.removeMember(
|
||||||
|
{
|
||||||
|
memberIdOrEmail: member.id,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(orgCount);
|
if (!error) {
|
||||||
|
toast.success(
|
||||||
if (orgCount === 1) {
|
"User unlinked successfully",
|
||||||
await mutateAsync({
|
);
|
||||||
userId: member.user.id,
|
refetch();
|
||||||
})
|
} else {
|
||||||
.then(() => {
|
toast.error(
|
||||||
toast.success(
|
"Error unlinking user",
|
||||||
"User deleted successfully",
|
);
|
||||||
);
|
|
||||||
refetch();
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error(
|
|
||||||
"Error deleting user",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
|
|
||||||
const { error } =
|
|
||||||
await authClient.organization.removeMember(
|
|
||||||
{
|
|
||||||
memberIdOrEmail: member.id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!error) {
|
|
||||||
toast.success(
|
|
||||||
"User unlinked successfully",
|
|
||||||
);
|
|
||||||
refetch();
|
|
||||||
} else {
|
|
||||||
toast.error("Error unlinking user");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
|
||||||
onSelect={(e) => e.preventDefault()}
|
|
||||||
>
|
>
|
||||||
Unlink User
|
<DropdownMenuItem
|
||||||
</DropdownMenuItem>
|
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
|
||||||
</DialogAction>
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
Unlink User
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogAction>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<span className="sr-only">
|
||||||
|
No actions available
|
||||||
|
</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { api } from "@/utils/api";
|
|||||||
|
|
||||||
const addServerDomain = z
|
const addServerDomain = z
|
||||||
.object({
|
.object({
|
||||||
domain: z.string(),
|
domain: z.string().trim().toLowerCase(),
|
||||||
letsEncryptEmail: z.string(),
|
letsEncryptEmail: z.string(),
|
||||||
https: z.boolean().optional(),
|
https: z.boolean().optional(),
|
||||||
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
||||||
@@ -49,7 +49,11 @@ const addServerDomain = z
|
|||||||
message: "Required",
|
message: "Required",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
|
if (
|
||||||
|
data.https &&
|
||||||
|
data.certificateType === "letsencrypt" &&
|
||||||
|
!data.letsEncryptEmail
|
||||||
|
) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -105,7 +105,9 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
});
|
});
|
||||||
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
toast.success(t("settings.server.webServer.traefik.portsUpdated"));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch {}
|
} catch (error) {
|
||||||
|
toast.error((error as Error).message || "Error updating Traefik ports");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -156,11 +158,11 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="h-[400px] pr-4">
|
<ScrollArea className="pr-4">
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{fields.map((field, index) => (
|
{fields.map((field, index) => (
|
||||||
<Card key={field.id} className="bg-transparent">
|
<Card key={field.id} className="bg-transparent">
|
||||||
<CardContent className="grid grid-cols-[1fr_1fr_1fr_auto] gap-4 p-4 transparent">
|
<CardContent className="grid grid-cols-4 gap-4 p-4 transparent">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name={`ports.${index}.targetPort`}
|
name={`ports.${index}.targetPort`}
|
||||||
@@ -303,6 +305,12 @@ export const ManageTraefikPorts = ({ children, serverId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</AlertBlock>
|
</AlertBlock>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
The Traefik container will be recreated from scratch. This
|
||||||
|
means the container will be deleted and created again, which
|
||||||
|
may cause downtime in your applications.
|
||||||
|
</AlertBlock>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
||||||
import { ChatwootWidget } from "../shared/ChatwootWidget";
|
import { HubSpotWidget } from "../shared/HubSpotWidget";
|
||||||
import Page from "./side";
|
import Page from "./side";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -25,7 +25,9 @@ export const DashboardLayout = ({ children }: Props) => {
|
|||||||
<>
|
<>
|
||||||
<Page>{children}</Page>
|
<Page>{children}</Page>
|
||||||
{isCloud === true && isUserSubscribed === true && (
|
{isCloud === true && isUserSubscribed === true && (
|
||||||
<ChatwootWidget websiteToken="USCpQRKzHvFMssf3p6Eacae5" />
|
<>
|
||||||
|
<HubSpotWidget />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{haveRootAccess === true && <ImpersonationBar />}
|
{haveRootAccess === true && <ImpersonationBar />}
|
||||||
|
|||||||
@@ -158,7 +158,8 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/schedules",
|
url: "/dashboard/schedules",
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
// Only enabled in non-cloud environments
|
// Only enabled in non-cloud environments
|
||||||
isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner",
|
isEnabled: ({ isCloud, auth }) =>
|
||||||
|
!isCloud && (auth?.role === "owner" || auth?.role === "admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -168,7 +169,9 @@ const MENU: Menu = {
|
|||||||
// Only enabled for admins and users with access to Traefik files in non-cloud environments
|
// Only enabled for admins and users with access to Traefik files in non-cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
!!(
|
!!(
|
||||||
(auth?.role === "owner" || auth?.canAccessToTraefikFiles) &&
|
(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canAccessToTraefikFiles) &&
|
||||||
!isCloud
|
!isCloud
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -179,7 +182,12 @@ const MENU: Menu = {
|
|||||||
icon: BlocksIcon,
|
icon: BlocksIcon,
|
||||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
|
!!(
|
||||||
|
(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canAccessToDocker) &&
|
||||||
|
!isCloud
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -188,7 +196,12 @@ const MENU: Menu = {
|
|||||||
icon: PieChart,
|
icon: PieChart,
|
||||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
|
!!(
|
||||||
|
(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canAccessToDocker) &&
|
||||||
|
!isCloud
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -197,7 +210,12 @@ const MENU: Menu = {
|
|||||||
icon: Forward,
|
icon: Forward,
|
||||||
// Only enabled for admins and users with access to Docker in non-cloud environments
|
// Only enabled for admins and users with access to Docker in non-cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) =>
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
!!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud),
|
!!(
|
||||||
|
(auth?.role === "owner" ||
|
||||||
|
auth?.role === "admin" ||
|
||||||
|
auth?.canAccessToDocker) &&
|
||||||
|
!isCloud
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Legacy unused menu, adjusted to the new structure
|
// Legacy unused menu, adjusted to the new structure
|
||||||
@@ -264,7 +282,8 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/server",
|
url: "/dashboard/settings/server",
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
// Only enabled for admins in non-cloud environments
|
// Only enabled for admins in non-cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
|
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -278,7 +297,8 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/servers",
|
url: "/dashboard/settings/servers",
|
||||||
icon: Server,
|
icon: Server,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) =>
|
||||||
|
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -286,7 +306,8 @@ const MENU: Menu = {
|
|||||||
icon: Users,
|
icon: Users,
|
||||||
url: "/dashboard/settings/users",
|
url: "/dashboard/settings/users",
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) =>
|
||||||
|
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -295,14 +316,19 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/ssh-keys",
|
url: "/dashboard/settings/ssh-keys",
|
||||||
// Only enabled for admins and users with access to SSH keys
|
// Only enabled for admins and users with access to SSH keys
|
||||||
isEnabled: ({ auth }) =>
|
isEnabled: ({ auth }) =>
|
||||||
!!(auth?.role === "owner" || auth?.canAccessToSSHKeys),
|
!!(
|
||||||
|
auth?.role === "owner" ||
|
||||||
|
auth?.canAccessToSSHKeys ||
|
||||||
|
auth?.role === "admin"
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "AI",
|
title: "AI",
|
||||||
icon: BotIcon,
|
icon: BotIcon,
|
||||||
url: "/dashboard/settings/ai",
|
url: "/dashboard/settings/ai",
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) =>
|
||||||
|
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -311,7 +337,11 @@ const MENU: Menu = {
|
|||||||
icon: GitBranch,
|
icon: GitBranch,
|
||||||
// Only enabled for admins and users with access to Git providers
|
// Only enabled for admins and users with access to Git providers
|
||||||
isEnabled: ({ auth }) =>
|
isEnabled: ({ auth }) =>
|
||||||
!!(auth?.role === "owner" || auth?.canAccessToGitProviders),
|
!!(
|
||||||
|
auth?.role === "owner" ||
|
||||||
|
auth?.canAccessToGitProviders ||
|
||||||
|
auth?.role === "admin"
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -319,7 +349,8 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/registry",
|
url: "/dashboard/settings/registry",
|
||||||
icon: Package,
|
icon: Package,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) =>
|
||||||
|
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -327,7 +358,8 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/destinations",
|
url: "/dashboard/settings/destinations",
|
||||||
icon: Database,
|
icon: Database,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) =>
|
||||||
|
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -336,7 +368,8 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/certificates",
|
url: "/dashboard/settings/certificates",
|
||||||
icon: ShieldCheck,
|
icon: ShieldCheck,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) =>
|
||||||
|
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -344,7 +377,8 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/cluster",
|
url: "/dashboard/settings/cluster",
|
||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
// Only enabled for admins in non-cloud environments
|
// Only enabled for admins in non-cloud environments
|
||||||
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
|
isEnabled: ({ auth, isCloud }) =>
|
||||||
|
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -352,7 +386,8 @@ const MENU: Menu = {
|
|||||||
url: "/dashboard/settings/notifications",
|
url: "/dashboard/settings/notifications",
|
||||||
icon: Bell,
|
icon: Bell,
|
||||||
// Only enabled for admins
|
// Only enabled for admins
|
||||||
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
|
isEnabled: ({ auth }) =>
|
||||||
|
!!(auth?.role === "owner" || auth?.role === "admin"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
isSingle: true,
|
isSingle: true,
|
||||||
@@ -718,7 +753,9 @@ function SidebarLogo() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(user?.role === "owner" || isCloud) && (
|
{(user?.role === "owner" ||
|
||||||
|
user?.role === "admin" ||
|
||||||
|
isCloud) && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<AddOrganization />
|
<AddOrganization />
|
||||||
@@ -1082,7 +1119,7 @@ export default function Page({ children }: Props) {
|
|||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<SidebarMenu className="flex flex-col gap-2">
|
<SidebarMenu className="flex flex-col gap-2">
|
||||||
{!isCloud && auth?.role === "owner" && (
|
{!isCloud && (auth?.role === "owner" || auth?.role === "admin") && (
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<UpdateServerButton />
|
<UpdateServerButton />
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ export const UserNav = () => {
|
|||||||
alt={data?.user?.image || ""}
|
alt={data?.user?.image || ""}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback className="rounded-lg">
|
<AvatarFallback className="rounded-lg">
|
||||||
{getFallbackAvatarInitials(data?.user?.name)}
|
{getFallbackAvatarInitials(
|
||||||
|
`${data?.user?.firstName} ${data?.user?.lastName}`.trim(),
|
||||||
|
)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
@@ -102,7 +104,9 @@ export const UserNav = () => {
|
|||||||
>
|
>
|
||||||
Monitoring
|
Monitoring
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{(data?.role === "owner" || data?.canAccessToTraefikFiles) && (
|
{(data?.role === "owner" ||
|
||||||
|
data?.role === "admin" ||
|
||||||
|
data?.canAccessToTraefikFiles) && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -112,7 +116,9 @@ export const UserNav = () => {
|
|||||||
Traefik
|
Traefik
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{(data?.role === "owner" || data?.canAccessToDocker) && (
|
{(data?.role === "owner" ||
|
||||||
|
data?.role === "admin" ||
|
||||||
|
data?.canAccessToDocker) && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -126,7 +132,7 @@ export const UserNav = () => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
data?.role === "owner" && (
|
(data?.role === "owner" || data?.role === "admin") && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
14
apps/dokploy/components/shared/HubSpotWidget.tsx
Normal file
14
apps/dokploy/components/shared/HubSpotWidget.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Script from "next/script";
|
||||||
|
|
||||||
|
export const HubSpotWidget = () => {
|
||||||
|
return (
|
||||||
|
<Script
|
||||||
|
id="hs-script-loader"
|
||||||
|
type="text/javascript"
|
||||||
|
src="//js-eu1.hs-scripts.com/147033433.js"
|
||||||
|
strategy="lazyOnload"
|
||||||
|
async
|
||||||
|
defer
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
2
apps/dokploy/drizzle/0125_neat_the_phantom.sql
Normal file
2
apps/dokploy/drizzle/0125_neat_the_phantom.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "application" ADD COLUMN "rollbackRegistryId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "application" ADD CONSTRAINT "application_rollbackRegistryId_registry_registryId_fk" FOREIGN KEY ("rollbackRegistryId") REFERENCES "public"."registry"("registryId") ON DELETE set null ON UPDATE no action;
|
||||||
1
apps/dokploy/drizzle/0126_nifty_monster_badoon.sql
Normal file
1
apps/dokploy/drizzle/0126_nifty_monster_badoon.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "user" ALTER COLUMN "enableDockerCleanup" SET DEFAULT true;
|
||||||
1
apps/dokploy/drizzle/0127_superb_alice.sql
Normal file
1
apps/dokploy/drizzle/0127_superb_alice.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "notification" ADD COLUMN "volumeBackup" boolean DEFAULT false NOT NULL;
|
||||||
2
apps/dokploy/drizzle/0128_hard_falcon.sql
Normal file
2
apps/dokploy/drizzle/0128_hard_falcon.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "user" RENAME COLUMN "name" TO "firstName";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" ADD COLUMN "lastName" text DEFAULT '' NOT NULL;
|
||||||
9
apps/dokploy/drizzle/0129_pale_roughhouse.sql
Normal file
9
apps/dokploy/drizzle/0129_pale_roughhouse.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TYPE "public"."notificationType" ADD VALUE 'custom' BEFORE 'lark';--> statement-breakpoint
|
||||||
|
CREATE TABLE "custom" (
|
||||||
|
"customId" text PRIMARY KEY NOT NULL,
|
||||||
|
"endpoint" text NOT NULL,
|
||||||
|
"headers" jsonb
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification" ADD COLUMN "customId" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notification" ADD CONSTRAINT "notification_customId_custom_customId_fk" FOREIGN KEY ("customId") REFERENCES "public"."custom"("customId") ON DELETE cascade ON UPDATE no action;
|
||||||
1
apps/dokploy/drizzle/0130_perpetual_screwball.sql
Normal file
1
apps/dokploy/drizzle/0130_perpetual_screwball.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "schedule" ADD COLUMN "timezone" text;
|
||||||
1
apps/dokploy/drizzle/0131_volatile_beast.sql
Normal file
1
apps/dokploy/drizzle/0131_volatile_beast.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "application" ADD COLUMN "createEnvFile" boolean DEFAULT true NOT NULL;
|
||||||
4
apps/dokploy/drizzle/0132_clean_layla_miller.sql
Normal file
4
apps/dokploy/drizzle/0132_clean_layla_miller.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE "environment" ADD COLUMN "isDefault" boolean DEFAULT false NOT NULL;
|
||||||
|
|
||||||
|
-- Set isDefault to true for existing production environments
|
||||||
|
UPDATE "environment" SET "isDefault" = true WHERE "name" = 'production';
|
||||||
6850
apps/dokploy/drizzle/meta/0125_snapshot.json
Normal file
6850
apps/dokploy/drizzle/meta/0125_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6850
apps/dokploy/drizzle/meta/0126_snapshot.json
Normal file
6850
apps/dokploy/drizzle/meta/0126_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6857
apps/dokploy/drizzle/meta/0127_snapshot.json
Normal file
6857
apps/dokploy/drizzle/meta/0127_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6864
apps/dokploy/drizzle/meta/0128_snapshot.json
Normal file
6864
apps/dokploy/drizzle/meta/0128_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6915
apps/dokploy/drizzle/meta/0129_snapshot.json
Normal file
6915
apps/dokploy/drizzle/meta/0129_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6921
apps/dokploy/drizzle/meta/0130_snapshot.json
Normal file
6921
apps/dokploy/drizzle/meta/0130_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6928
apps/dokploy/drizzle/meta/0131_snapshot.json
Normal file
6928
apps/dokploy/drizzle/meta/0131_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
6935
apps/dokploy/drizzle/meta/0132_snapshot.json
Normal file
6935
apps/dokploy/drizzle/meta/0132_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -876,6 +876,62 @@
|
|||||||
"when": 1764571454170,
|
"when": 1764571454170,
|
||||||
"tag": "0124_certain_cloak",
|
"tag": "0124_certain_cloak",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 125,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1764573207555,
|
||||||
|
"tag": "0125_neat_the_phantom",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 126,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1765065295708,
|
||||||
|
"tag": "0126_nifty_monster_badoon",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 127,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1765095189368,
|
||||||
|
"tag": "0127_superb_alice",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 128,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1765101709413,
|
||||||
|
"tag": "0128_hard_falcon",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 129,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1765136384035,
|
||||||
|
"tag": "0129_pale_roughhouse",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 130,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1765167657813,
|
||||||
|
"tag": "0130_perpetual_screwball",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 131,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1765342621312,
|
||||||
|
"tag": "0131_volatile_beast",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 132,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1765346573500,
|
||||||
|
"tag": "0132_clean_layla_miller",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
adminClient,
|
adminClient,
|
||||||
apiKeyClient,
|
apiKeyClient,
|
||||||
|
inferAdditionalFields,
|
||||||
organizationClient,
|
organizationClient,
|
||||||
twoFactorClient,
|
twoFactorClient,
|
||||||
} from "better-auth/client/plugins";
|
} from "better-auth/client/plugins";
|
||||||
@@ -13,5 +14,12 @@ export const authClient = createAuthClient({
|
|||||||
twoFactorClient(),
|
twoFactorClient(),
|
||||||
apiKeyClient(),
|
apiKeyClient(),
|
||||||
adminClient(),
|
adminClient(),
|
||||||
|
inferAdditionalFields({
|
||||||
|
user: {
|
||||||
|
lastName: {
|
||||||
|
type: "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,9 +6,6 @@
|
|||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
eslint: {
|
|
||||||
ignoreDuringBuilds: true,
|
|
||||||
},
|
|
||||||
typescript: {
|
typescript: {
|
||||||
ignoreBuildErrors: true,
|
ignoreBuildErrors: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.25.11",
|
"version": "v0.26.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
"nanoid": "3.3.11",
|
"nanoid": "3.3.11",
|
||||||
"next": "^15.3.2",
|
"next": "^16.0.7",
|
||||||
"next-i18next": "^15.4.2",
|
"next-i18next": "^15.4.2",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nextjs-toploader": "^3.9.17",
|
"nextjs-toploader": "^3.9.17",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { NextPage } from "next";
|
|||||||
import type { AppProps } from "next/app";
|
import type { AppProps } from "next/app";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import Script from "next/script";
|
|
||||||
import { appWithTranslation } from "next-i18next";
|
import { appWithTranslation } from "next-i18next";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import NextTopLoader from "nextjs-toploader";
|
import NextTopLoader from "nextjs-toploader";
|
||||||
@@ -43,14 +42,6 @@ const MyApp = ({
|
|||||||
<Head>
|
<Head>
|
||||||
<title>Dokploy</title>
|
<title>Dokploy</title>
|
||||||
</Head>
|
</Head>
|
||||||
{process.env.NEXT_PUBLIC_UMAMI_HOST &&
|
|
||||||
process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && (
|
|
||||||
<Script
|
|
||||||
src={process.env.NEXT_PUBLIC_UMAMI_HOST}
|
|
||||||
data-website-id={process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="system"
|
defaultTheme="system"
|
||||||
|
|||||||
@@ -81,41 +81,34 @@ export default async function handler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!webhookImageName) {
|
// If webhook provides image information, validate it matches the configured image
|
||||||
res.status(301).json({
|
// If webhook doesn't provide image information, fall back to using the configured image (backward compatibility)
|
||||||
message: "Webhook Docker Image Name Not Found",
|
if (webhookImageName) {
|
||||||
});
|
// Validate image name matches
|
||||||
return;
|
if (webhookImageName !== applicationImageName) {
|
||||||
}
|
res.status(301).json({
|
||||||
|
message: `Application Image Name (${applicationImageName}) doesn't match request event payload Image Name (${webhookImageName}).`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate image name matches
|
if (!applicationDockerTag) {
|
||||||
if (webhookImageName !== applicationImageName) {
|
res.status(301).json({
|
||||||
res.status(301).json({
|
message: "Application Docker Tag Not Found",
|
||||||
message: `Application Image Name (${applicationImageName}) doesn't match request event payload Image Name (${webhookImageName}).`,
|
});
|
||||||
});
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!applicationDockerTag) {
|
if (webhookDockerTag) {
|
||||||
res.status(301).json({
|
if (webhookDockerTag !== applicationDockerTag) {
|
||||||
message: "Application Docker Tag Not Found",
|
res.status(301).json({
|
||||||
});
|
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
|
||||||
return;
|
});
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
if (!webhookDockerTag) {
|
}
|
||||||
res.status(301).json({
|
|
||||||
message: "Webhook Docker Tag Not Found",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (webhookDockerTag !== applicationDockerTag) {
|
|
||||||
res.status(301).json({
|
|
||||||
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
// If webhook doesn't provide image info, we'll use the configured image (old behavior)
|
||||||
} else if (sourceType === "github") {
|
} else if (sourceType === "github") {
|
||||||
const normalizedCommits = req.body?.commits?.flatMap(
|
const normalizedCommits = req.body?.commits?.flatMap(
|
||||||
(commit: any) => commit.modified,
|
(commit: any) => commit.modified,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export async function getServerSideProps(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const { user } = await validateRequest(ctx.req);
|
const { user } = await validateRequest(ctx.req);
|
||||||
if (!user || user.role !== "owner") {
|
if (!user || (user.role !== "owner" && user.role !== "admin")) {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export async function getServerSideProps(
|
|||||||
}
|
}
|
||||||
const { req, res } = ctx;
|
const { req, res } = ctx;
|
||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
if (!user || user.role === "member") {
|
if (!user || user.role !== "owner") {
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
permanent: true,
|
permanent: true,
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ const Page = () => {
|
|||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
|
||||||
<ProfileForm />
|
<ProfileForm />
|
||||||
{(data?.canAccessToAPI || data?.role === "owner") && <ShowApiKeys />}
|
{(data?.canAccessToAPI ||
|
||||||
|
data?.role === "owner" ||
|
||||||
|
data?.role === "admin") && <ShowApiKeys />}
|
||||||
|
|
||||||
{/* {isCloud && <RemoveSelfAccount />} */}
|
{/* {isCloud && <RemoveSelfAccount />} */}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import { api } from "@/utils/api";
|
|||||||
const registerSchema = z
|
const registerSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1, {
|
name: z.string().min(1, {
|
||||||
message: "Name is required",
|
message: "First name is required",
|
||||||
|
}),
|
||||||
|
lastName: z.string().min(1, {
|
||||||
|
message: "Last name is required",
|
||||||
}),
|
}),
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.string()
|
||||||
@@ -92,6 +95,7 @@ const Invitation = ({
|
|||||||
const form = useForm<Register>({
|
const form = useForm<Register>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
|
lastName: "",
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
@@ -115,6 +119,7 @@ const Invitation = ({
|
|||||||
email: values.email,
|
email: values.email,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
|
lastName: values.lastName,
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
headers: {
|
headers: {
|
||||||
"x-dokploy-token": token,
|
"x-dokploy-token": token,
|
||||||
@@ -197,12 +202,22 @@ const Invitation = ({
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>First Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input placeholder="John" {...field} />
|
||||||
placeholder="Enter your name"
|
</FormControl>
|
||||||
{...field}
|
<FormMessage />
|
||||||
/>
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="lastName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Last Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Doe" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import { authClient } from "@/lib/auth-client";
|
|||||||
const registerSchema = z
|
const registerSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1, {
|
name: z.string().min(1, {
|
||||||
message: "Name is required",
|
message: "First name is required",
|
||||||
|
}),
|
||||||
|
lastName: z.string().min(1, {
|
||||||
|
message: "Last name is required",
|
||||||
}),
|
}),
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.string()
|
||||||
@@ -79,6 +82,7 @@ const Register = ({ isCloud }: Props) => {
|
|||||||
const form = useForm<Register>({
|
const form = useForm<Register>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
|
lastName: "",
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
@@ -95,6 +99,7 @@ const Register = ({ isCloud }: Props) => {
|
|||||||
email: values.email,
|
email: values.email,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
|
lastName: values.lastName,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -158,9 +163,22 @@ const Register = ({ isCloud }: Props) => {
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel>First Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="name" {...field} />
|
<Input placeholder="John" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="lastName"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Last Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Doe" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { findAdmin } from "@dokploy/server";
|
import { findOwner } from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { user } from "@dokploy/server/db/schema";
|
import { user } from "@dokploy/server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await findAdmin();
|
const result = await findOwner();
|
||||||
|
|
||||||
const update = await db
|
const update = await db
|
||||||
.update(user)
|
.update(user)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { findAdmin, generateRandomPassword } from "@dokploy/server";
|
import { findOwner, generateRandomPassword } from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { account } from "@dokploy/server/db/schema";
|
import { account } from "@dokploy/server/db/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -7,7 +7,7 @@ import { eq } from "drizzle-orm";
|
|||||||
try {
|
try {
|
||||||
const randomPassword = await generateRandomPassword();
|
const randomPassword = await generateRandomPassword();
|
||||||
|
|
||||||
const result = await findAdmin();
|
const result = await findOwner();
|
||||||
|
|
||||||
const update = await db
|
const update = await db
|
||||||
.update(account)
|
.update(account)
|
||||||
|
|||||||
@@ -365,6 +365,7 @@ export const applicationRouter = createTRPCRouter({
|
|||||||
env: input.env,
|
env: input.env,
|
||||||
buildArgs: input.buildArgs,
|
buildArgs: input.buildArgs,
|
||||||
buildSecrets: input.buildSecrets,
|
buildSecrets: input.buildSecrets,
|
||||||
|
createEnvFile: input.createEnvFile,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -66,10 +66,12 @@ export const environmentRouter = createTRPCRouter({
|
|||||||
if (input.name === "production") {
|
if (input.name === "production") {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Environment name cannot be production",
|
message:
|
||||||
|
"You cannot create a environment with the name 'production'",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow users to create environments with any name, including "production"
|
||||||
const environment = await createEnvironment(input);
|
const environment = await createEnvironment(input);
|
||||||
|
|
||||||
if (ctx.user.role === "member") {
|
if (ctx.user.role === "member") {
|
||||||
@@ -243,13 +245,7 @@ export const environmentRouter = createTRPCRouter({
|
|||||||
try {
|
try {
|
||||||
const { environmentId, ...updateData } = input;
|
const { environmentId, ...updateData } = input;
|
||||||
|
|
||||||
if (updateData.name === "production") {
|
// Allow users to rename environments to any name, including "production"
|
||||||
throw new TRPCError({
|
|
||||||
code: "BAD_REQUEST",
|
|
||||||
message: "Environment name cannot be production",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.user.role === "member") {
|
if (ctx.user.role === "member") {
|
||||||
await checkEnvironmentAccess(
|
await checkEnvironmentAccess(
|
||||||
ctx.user.id,
|
ctx.user.id,
|
||||||
@@ -259,6 +255,13 @@ export const environmentRouter = createTRPCRouter({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const currentEnvironment = await findEnvironmentById(environmentId);
|
const currentEnvironment = await findEnvironmentById(environmentId);
|
||||||
|
|
||||||
|
if (currentEnvironment.isDefault) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "You cannot update the default environment",
|
||||||
|
});
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
currentEnvironment.project.organizationId !==
|
currentEnvironment.project.organizationId !==
|
||||||
ctx.session.activeOrganizationId
|
ctx.session.activeOrganizationId
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
createMount,
|
createMount,
|
||||||
deployMariadb,
|
deployMariadb,
|
||||||
findBackupsByDbId,
|
findBackupsByDbId,
|
||||||
findMariadbById,
|
|
||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
|
findMariadbById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
createMount,
|
createMount,
|
||||||
deployMongo,
|
deployMongo,
|
||||||
findBackupsByDbId,
|
findBackupsByDbId,
|
||||||
findMongoById,
|
|
||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
|
findMongoById,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
rebuildDatabase,
|
rebuildDatabase,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
createCustomNotification,
|
||||||
createDiscordNotification,
|
createDiscordNotification,
|
||||||
createEmailNotification,
|
createEmailNotification,
|
||||||
createGotifyNotification,
|
createGotifyNotification,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
findNotificationById,
|
findNotificationById,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
removeNotificationById,
|
removeNotificationById,
|
||||||
|
sendCustomNotification,
|
||||||
sendDiscordNotification,
|
sendDiscordNotification,
|
||||||
sendEmailNotification,
|
sendEmailNotification,
|
||||||
sendGotifyNotification,
|
sendGotifyNotification,
|
||||||
@@ -17,6 +19,7 @@ import {
|
|||||||
sendServerThresholdNotifications,
|
sendServerThresholdNotifications,
|
||||||
sendSlackNotification,
|
sendSlackNotification,
|
||||||
sendTelegramNotification,
|
sendTelegramNotification,
|
||||||
|
updateCustomNotification,
|
||||||
updateDiscordNotification,
|
updateDiscordNotification,
|
||||||
updateEmailNotification,
|
updateEmailNotification,
|
||||||
updateGotifyNotification,
|
updateGotifyNotification,
|
||||||
@@ -36,6 +39,7 @@ import {
|
|||||||
} from "@/server/api/trpc";
|
} from "@/server/api/trpc";
|
||||||
import { db } from "@/server/db";
|
import { db } from "@/server/db";
|
||||||
import {
|
import {
|
||||||
|
apiCreateCustom,
|
||||||
apiCreateDiscord,
|
apiCreateDiscord,
|
||||||
apiCreateEmail,
|
apiCreateEmail,
|
||||||
apiCreateGotify,
|
apiCreateGotify,
|
||||||
@@ -44,6 +48,7 @@ import {
|
|||||||
apiCreateSlack,
|
apiCreateSlack,
|
||||||
apiCreateTelegram,
|
apiCreateTelegram,
|
||||||
apiFindOneNotification,
|
apiFindOneNotification,
|
||||||
|
apiTestCustomConnection,
|
||||||
apiTestDiscordConnection,
|
apiTestDiscordConnection,
|
||||||
apiTestEmailConnection,
|
apiTestEmailConnection,
|
||||||
apiTestGotifyConnection,
|
apiTestGotifyConnection,
|
||||||
@@ -51,6 +56,7 @@ import {
|
|||||||
apiTestNtfyConnection,
|
apiTestNtfyConnection,
|
||||||
apiTestSlackConnection,
|
apiTestSlackConnection,
|
||||||
apiTestTelegramConnection,
|
apiTestTelegramConnection,
|
||||||
|
apiUpdateCustom,
|
||||||
apiUpdateDiscord,
|
apiUpdateDiscord,
|
||||||
apiUpdateEmail,
|
apiUpdateEmail,
|
||||||
apiUpdateGotify,
|
apiUpdateGotify,
|
||||||
@@ -334,6 +340,7 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
email: true,
|
email: true,
|
||||||
gotify: true,
|
gotify: true,
|
||||||
ntfy: true,
|
ntfy: true,
|
||||||
|
custom: true,
|
||||||
lark: true,
|
lark: true,
|
||||||
},
|
},
|
||||||
orderBy: desc(notifications.createdAt),
|
orderBy: desc(notifications.createdAt),
|
||||||
@@ -518,6 +525,59 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
createCustom: adminProcedure
|
||||||
|
.input(apiCreateCustom)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
return await createCustomNotification(
|
||||||
|
input,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Error creating the notification",
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
updateCustom: adminProcedure
|
||||||
|
.input(apiUpdateCustom)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const notification = await findNotificationById(input.notificationId);
|
||||||
|
if (notification.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to update this notification",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return await updateCustomNotification({
|
||||||
|
...input,
|
||||||
|
organizationId: ctx.session.activeOrganizationId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
testCustomConnection: adminProcedure
|
||||||
|
.input(apiTestCustomConnection)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
await sendCustomNotification(input, {
|
||||||
|
title: "Test Notification",
|
||||||
|
message: "Hi, From Dokploy 👋",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `${error instanceof Error ? error.message : "Unknown error"}`,
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
createLark: adminProcedure
|
createLark: adminProcedure
|
||||||
.input(apiCreateLark)
|
.input(apiCreateLark)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
if (ctx.user.role !== "owner" && !IS_CLOUD) {
|
if (ctx.user.role !== "owner" && ctx.user.role !== "admin" && !IS_CLOUD) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Only the organization owner can create an organization",
|
message: "Only the organization owner can create an organization",
|
||||||
@@ -32,8 +32,6 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
.returning()
|
.returning()
|
||||||
.then((res) => res[0]);
|
.then((res) => res[0]);
|
||||||
|
|
||||||
console.log("result", result);
|
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
@@ -82,7 +80,22 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
organizationId: z.string(),
|
organizationId: z.string(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
|
// Verify user is a member of this organization
|
||||||
|
const userMember = await db.query.member.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(member.organizationId, input.organizationId),
|
||||||
|
eq(member.userId, ctx.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userMember) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You are not a member of this organization",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return await db.query.organization.findFirst({
|
return await db.query.organization.findFirst({
|
||||||
where: eq(organization.id, input.organizationId),
|
where: eq(organization.id, input.organizationId),
|
||||||
});
|
});
|
||||||
@@ -96,12 +109,45 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
if (ctx.user.role !== "owner" && !IS_CLOUD) {
|
// First, verify the organization exists
|
||||||
|
const org = await db.query.organization.findFirst({
|
||||||
|
where: eq(organization.id, input.organizationId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Organization not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user is a member of this organization
|
||||||
|
const userMember = await db.query.member.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(member.organizationId, input.organizationId),
|
||||||
|
eq(member.userId, ctx.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userMember) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You are not a member of this organization",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only owners can update the organization
|
||||||
|
// Verify the user is either the organization owner or has the owner role
|
||||||
|
const isOwner =
|
||||||
|
org.ownerId === ctx.user.id || userMember.role === "owner";
|
||||||
|
|
||||||
|
if (!isOwner) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Only the organization owner can update it",
|
message: "Only the organization owner can update it",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.update(organization)
|
.update(organization)
|
||||||
.set({
|
.set({
|
||||||
@@ -119,12 +165,7 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
if (ctx.user.role !== "owner" && !IS_CLOUD) {
|
// First, verify the organization exists
|
||||||
throw new TRPCError({
|
|
||||||
code: "FORBIDDEN",
|
|
||||||
message: "Only the organization owner can delete it",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const org = await db.query.organization.findFirst({
|
const org = await db.query.organization.findFirst({
|
||||||
where: eq(organization.id, input.organizationId),
|
where: eq(organization.id, input.organizationId),
|
||||||
});
|
});
|
||||||
@@ -136,7 +177,27 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (org.ownerId !== ctx.user.id) {
|
// Verify user is a member of this organization
|
||||||
|
const userMember = await db.query.member.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(member.organizationId, input.organizationId),
|
||||||
|
eq(member.userId, ctx.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userMember) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You are not a member of this organization",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only owners can delete the organization
|
||||||
|
// Verify the user is either the organization owner or has the owner role
|
||||||
|
const isOwner =
|
||||||
|
org.ownerId === ctx.user.id || userMember.role === "owner";
|
||||||
|
|
||||||
|
if (!isOwner) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "FORBIDDEN",
|
code: "FORBIDDEN",
|
||||||
message: "Only the organization owner can delete it",
|
message: "Only the organization owner can delete it",
|
||||||
@@ -194,6 +255,65 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
.delete(invitation)
|
.delete(invitation)
|
||||||
.where(eq(invitation.id, input.invitationId));
|
.where(eq(invitation.id, input.invitationId));
|
||||||
}),
|
}),
|
||||||
|
updateMemberRole: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
memberId: z.string(),
|
||||||
|
role: z.enum(["admin", "member"]),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
// Fetch the target member
|
||||||
|
const target = await db.query.member.findFirst({
|
||||||
|
where: eq(member.id, input.memberId),
|
||||||
|
with: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.organizationId !== ctx.session.activeOrganizationId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You are not allowed to update this member's role",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent users from changing their own role
|
||||||
|
if (target.userId === ctx.user.id) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You cannot change your own role",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner role is intransferible - cannot change to or from owner
|
||||||
|
if (target.role === "owner") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "The owner role is intransferible",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only owners can change admin roles
|
||||||
|
// Admins can only change member roles
|
||||||
|
if (ctx.user.role === "admin" && target.role === "admin") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message:
|
||||||
|
"Only the organization owner can change admin roles. Admins can only modify member roles.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the target member's role
|
||||||
|
await db
|
||||||
|
.update(member)
|
||||||
|
.set({ role: input.role })
|
||||||
|
.where(eq(member.id, input.memberId));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
setDefault: protectedProcedure
|
setDefault: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
scheduleId: newSchedule.scheduleId,
|
scheduleId: newSchedule.scheduleId,
|
||||||
type: "schedule",
|
type: "schedule",
|
||||||
cronSchedule: newSchedule.cronExpression,
|
cronSchedule: newSchedule.cronExpression,
|
||||||
|
timezone: newSchedule.timezone,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
scheduleJob(newSchedule);
|
scheduleJob(newSchedule);
|
||||||
@@ -49,6 +50,7 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
scheduleId: updatedSchedule.scheduleId,
|
scheduleId: updatedSchedule.scheduleId,
|
||||||
type: "schedule",
|
type: "schedule",
|
||||||
cronSchedule: updatedSchedule.cronExpression,
|
cronSchedule: updatedSchedule.cronExpression,
|
||||||
|
timezone: updatedSchedule.timezone,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await removeJob({
|
await removeJob({
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
canAccessToTraefikFiles,
|
canAccessToTraefikFiles,
|
||||||
checkGPUStatus,
|
checkGPUStatus,
|
||||||
cleanStoppedContainers,
|
checkPortInUse,
|
||||||
cleanUpDockerBuilder,
|
cleanupAll,
|
||||||
cleanUpSystemPrune,
|
cleanupBuilders,
|
||||||
cleanUpUnusedImages,
|
cleanupContainers,
|
||||||
cleanUpUnusedVolumes,
|
cleanupImages,
|
||||||
|
cleanupSystem,
|
||||||
|
cleanupVolumes,
|
||||||
DEFAULT_UPDATE_DATA,
|
DEFAULT_UPDATE_DATA,
|
||||||
execAsync,
|
execAsync,
|
||||||
findServerById,
|
findServerById,
|
||||||
@@ -130,6 +132,17 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
let newPorts = ports;
|
let newPorts = ports;
|
||||||
// If receive true, add 8080 to ports
|
// If receive true, add 8080 to ports
|
||||||
if (input.enableDashboard) {
|
if (input.enableDashboard) {
|
||||||
|
// Check if port 8080 is already in use before enabling dashboard
|
||||||
|
const portCheck = await checkPortInUse(8080, input.serverId);
|
||||||
|
if (portCheck.isInUse) {
|
||||||
|
const conflictingContainer = portCheck.conflictingContainer
|
||||||
|
? ` by container "${portCheck.conflictingContainer}"`
|
||||||
|
: "";
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: `Port 8080 is already in use${conflictingContainer}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
newPorts.push({
|
newPorts.push({
|
||||||
targetPort: 8080,
|
targetPort: 8080,
|
||||||
publishedPort: 8080,
|
publishedPort: 8080,
|
||||||
@@ -149,41 +162,38 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
cleanUnusedImages: adminProcedure
|
cleanUnusedImages: adminProcedure
|
||||||
.input(apiServerSchema)
|
.input(apiServerSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await cleanUpUnusedImages(input?.serverId);
|
await cleanupImages(input?.serverId);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
cleanUnusedVolumes: adminProcedure
|
cleanUnusedVolumes: adminProcedure
|
||||||
.input(apiServerSchema)
|
.input(apiServerSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await cleanUpUnusedVolumes(input?.serverId);
|
await cleanupVolumes(input?.serverId);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
cleanStoppedContainers: adminProcedure
|
cleanStoppedContainers: adminProcedure
|
||||||
.input(apiServerSchema)
|
.input(apiServerSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await cleanStoppedContainers(input?.serverId);
|
await cleanupContainers(input?.serverId);
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
cleanDockerBuilder: adminProcedure
|
cleanDockerBuilder: adminProcedure
|
||||||
.input(apiServerSchema)
|
.input(apiServerSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await cleanUpDockerBuilder(input?.serverId);
|
await cleanupBuilders(input?.serverId);
|
||||||
}),
|
}),
|
||||||
cleanDockerPrune: adminProcedure
|
cleanDockerPrune: adminProcedure
|
||||||
.input(apiServerSchema)
|
.input(apiServerSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await cleanUpSystemPrune(input?.serverId);
|
await cleanupSystem(input?.serverId);
|
||||||
await cleanUpDockerBuilder(input?.serverId);
|
await cleanupBuilders(input?.serverId);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
cleanAll: adminProcedure
|
cleanAll: adminProcedure
|
||||||
.input(apiServerSchema)
|
.input(apiServerSchema)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
await cleanUpUnusedImages(input?.serverId);
|
await cleanupAll(input?.serverId);
|
||||||
await cleanStoppedContainers(input?.serverId);
|
|
||||||
await cleanUpDockerBuilder(input?.serverId);
|
|
||||||
await cleanUpSystemPrune(input?.serverId);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}),
|
}),
|
||||||
@@ -201,7 +211,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await updateUser(ctx.user.id, {
|
await updateUser(ctx.user.ownerId, {
|
||||||
sshPrivateKey: input.sshPrivateKey,
|
sshPrivateKey: input.sshPrivateKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,11 +223,9 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const user = await updateUser(ctx.user.id, {
|
const user = await updateUser(ctx.user.ownerId, {
|
||||||
host: input.host,
|
host: input.host,
|
||||||
...(input.letsEncryptEmail && {
|
letsEncryptEmail: input.letsEncryptEmail,
|
||||||
letsEncryptEmail: input.letsEncryptEmail,
|
|
||||||
}),
|
|
||||||
certificateType: input.certificateType,
|
certificateType: input.certificateType,
|
||||||
https: input.https,
|
https: input.https,
|
||||||
});
|
});
|
||||||
@@ -240,7 +248,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await updateUser(ctx.user.id, {
|
await updateUser(ctx.user.ownerId, {
|
||||||
sshPrivateKey: null,
|
sshPrivateKey: null,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -281,9 +289,9 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
console.log(
|
console.log(
|
||||||
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
||||||
);
|
);
|
||||||
await cleanUpUnusedImages(server.serverId);
|
|
||||||
await cleanUpDockerBuilder(server.serverId);
|
await cleanupAll(server.serverId);
|
||||||
await cleanUpSystemPrune(server.serverId);
|
|
||||||
await sendDockerCleanupNotifications(server.organizationId);
|
await sendDockerCleanupNotifications(server.organizationId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -300,7 +308,7 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!IS_CLOUD) {
|
} else if (!IS_CLOUD) {
|
||||||
const userUpdated = await updateUser(ctx.user.id, {
|
const userUpdated = await updateUser(ctx.user.ownerId, {
|
||||||
enableDockerCleanup: input.enableDockerCleanup,
|
enableDockerCleanup: input.enableDockerCleanup,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,9 +317,9 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
console.log(
|
console.log(
|
||||||
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
|
||||||
);
|
);
|
||||||
await cleanUpUnusedImages();
|
|
||||||
await cleanUpDockerBuilder();
|
await cleanupAll();
|
||||||
await cleanUpSystemPrune();
|
|
||||||
await sendDockerCleanupNotifications(
|
await sendDockerCleanupNotifications(
|
||||||
ctx.session.activeOrganizationId,
|
ctx.session.activeOrganizationId,
|
||||||
);
|
);
|
||||||
@@ -810,6 +818,19 @@ export const settingsRouter = createTRPCRouter({
|
|||||||
"dokploy-traefik",
|
"dokploy-traefik",
|
||||||
input?.serverId,
|
input?.serverId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (const port of input.additionalPorts) {
|
||||||
|
const portCheck = await checkPortInUse(
|
||||||
|
port.publishedPort,
|
||||||
|
input.serverId,
|
||||||
|
);
|
||||||
|
if (portCheck.isInUse) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: `Port ${port.targetPort} is already in use by ${portCheck.conflictingContainer}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
const preparedEnv = prepareEnvironmentVariables(env);
|
const preparedEnv = prepareEnvironmentVariables(env);
|
||||||
|
|
||||||
await writeTraefikSetup({
|
await writeTraefikSetup({
|
||||||
|
|||||||
@@ -56,15 +56,16 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const items = getStripeItems(input.serverQuantity, input.isAnnual);
|
const items = getStripeItems(input.serverQuantity, input.isAnnual);
|
||||||
const user = await findUserById(ctx.user.id);
|
// Always operate on the organization owner's Stripe customer
|
||||||
|
const owner = await findUserById(ctx.user.ownerId);
|
||||||
|
|
||||||
let stripeCustomerId = user.stripeCustomerId;
|
let stripeCustomerId = owner.stripeCustomerId;
|
||||||
|
|
||||||
if (stripeCustomerId) {
|
if (stripeCustomerId) {
|
||||||
const customer = await stripe.customers.retrieve(stripeCustomerId);
|
const customer = await stripe.customers.retrieve(stripeCustomerId);
|
||||||
|
|
||||||
if (customer.deleted) {
|
if (customer.deleted) {
|
||||||
await updateUser(user.id, {
|
await updateUser(owner.id, {
|
||||||
stripeCustomerId: null,
|
stripeCustomerId: null,
|
||||||
});
|
});
|
||||||
stripeCustomerId = null;
|
stripeCustomerId = null;
|
||||||
@@ -78,7 +79,7 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
customer: stripeCustomerId,
|
customer: stripeCustomerId,
|
||||||
}),
|
}),
|
||||||
metadata: {
|
metadata: {
|
||||||
adminId: user.id,
|
adminId: owner.id,
|
||||||
},
|
},
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
|
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
|
||||||
@@ -88,15 +89,16 @@ export const stripeRouter = createTRPCRouter({
|
|||||||
return { sessionId: session.id };
|
return { sessionId: session.id };
|
||||||
}),
|
}),
|
||||||
createCustomerPortalSession: adminProcedure.mutation(async ({ ctx }) => {
|
createCustomerPortalSession: adminProcedure.mutation(async ({ ctx }) => {
|
||||||
const user = await findUserById(ctx.user.id);
|
// Use the organization's owner account for billing portal
|
||||||
|
const owner = await findUserById(ctx.user.ownerId);
|
||||||
|
|
||||||
if (!user.stripeCustomerId) {
|
if (!owner.stripeCustomerId) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Stripe Customer ID not found",
|
message: "Stripe Customer ID not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const stripeCustomerId = user.stripeCustomerId;
|
const stripeCustomerId = owner.stripeCustomerId;
|
||||||
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||||
apiVersion: "2024-09-30.acacia",
|
apiVersion: "2024-09-30.acacia",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
createApiKey,
|
createApiKey,
|
||||||
findAdmin,
|
|
||||||
findNotificationById,
|
findNotificationById,
|
||||||
findOrganizationById,
|
findOrganizationById,
|
||||||
findUserById,
|
findUserById,
|
||||||
@@ -87,7 +86,11 @@ export const userRouter = createTRPCRouter({
|
|||||||
// Allow access if:
|
// Allow access if:
|
||||||
// 1. User is requesting their own information
|
// 1. User is requesting their own information
|
||||||
// 2. User has owner role (admin permissions) AND user is in the same organization
|
// 2. User has owner role (admin permissions) AND user is in the same organization
|
||||||
if (memberResult.userId !== ctx.user.id && ctx.user.role !== "owner") {
|
if (
|
||||||
|
memberResult.userId !== ctx.user.id &&
|
||||||
|
ctx.user.role !== "owner" &&
|
||||||
|
ctx.user.role !== "admin"
|
||||||
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "You are not authorized to access this user",
|
message: "You are not authorized to access this user",
|
||||||
@@ -223,10 +226,61 @@ export const userRouter = createTRPCRouter({
|
|||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure the acting user has admin privileges in the active organization
|
||||||
|
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Only owners or admins can delete users",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch target member within the active organization
|
||||||
|
const targetMember = await db.query.member.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(member.userId, input.userId),
|
||||||
|
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetMember) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "Target user is not a member of this organization",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Never allow deleting the organization owner via this endpoint
|
||||||
|
if (targetMember.role === "owner") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You cannot delete the organization owner",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin self-protection: an admin cannot delete themselves
|
||||||
|
if (targetMember.role === "admin" && input.userId === ctx.user.id) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message:
|
||||||
|
"Admins cannot delete themselves. Ask the owner or another admin.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only owners can delete admins
|
||||||
|
// Admins can only delete members
|
||||||
|
if (ctx.user.role === "admin" && targetMember.role === "admin") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message:
|
||||||
|
"Only the organization owner can delete admins. Admins can only delete members.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return await removeUserById(input.userId);
|
return await removeUserById(input.userId);
|
||||||
}),
|
}),
|
||||||
assignPermissions: adminProcedure
|
assignPermissions: adminProcedure
|
||||||
@@ -376,6 +430,23 @@ export const userRouter = createTRPCRouter({
|
|||||||
createApiKey: protectedProcedure
|
createApiKey: protectedProcedure
|
||||||
.input(apiCreateApiKey)
|
.input(apiCreateApiKey)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
// Verify user is a member of the organization specified in metadata
|
||||||
|
if (input.metadata?.organizationId) {
|
||||||
|
const userMember = await db.query.member.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(member.organizationId, input.metadata.organizationId),
|
||||||
|
eq(member.userId, ctx.user.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userMember) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "You are not a member of this organization",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const apiKey = await createApiKey(ctx.user.id, input);
|
const apiKey = await createApiKey(ctx.user.id, input);
|
||||||
return apiKey;
|
return apiKey;
|
||||||
}),
|
}),
|
||||||
@@ -386,7 +457,35 @@ export const userRouter = createTRPCRouter({
|
|||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
|
// Users can check their own organizations
|
||||||
|
// Admins and owners can check organizations of members in their active organization
|
||||||
|
if (input.userId !== ctx.user.id) {
|
||||||
|
// Verify the target user is a member of the active organization
|
||||||
|
const targetMember = await db.query.member.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(member.userId, input.userId),
|
||||||
|
eq(member.organizationId, ctx.session?.activeOrganizationId || ""),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetMember) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "User is not a member of your active organization",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only admins and owners can check other users' organizations
|
||||||
|
if (ctx.user.role !== "owner" && ctx.user.role !== "admin") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message:
|
||||||
|
"Only admins and owners can check other users' organizations",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const organizations = await db.query.member.findMany({
|
const organizations = await db.query.member.findMany({
|
||||||
where: eq(member.userId, input.userId),
|
where: eq(member.userId, input.userId),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -183,7 +183,11 @@ export const uploadProcedure = async (opts: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const cliProcedure = t.procedure.use(({ ctx, next }) => {
|
export const cliProcedure = t.procedure.use(({ ctx, next }) => {
|
||||||
if (!ctx.session || !ctx.user || ctx.user.role !== "owner") {
|
if (
|
||||||
|
!ctx.session ||
|
||||||
|
!ctx.user ||
|
||||||
|
(ctx.user.role !== "owner" && ctx.user.role !== "admin")
|
||||||
|
) {
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
}
|
}
|
||||||
return next({
|
return next({
|
||||||
@@ -197,7 +201,11 @@ export const cliProcedure = t.procedure.use(({ ctx, next }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const adminProcedure = t.procedure.use(({ ctx, next }) => {
|
export const adminProcedure = t.procedure.use(({ ctx, next }) => {
|
||||||
if (!ctx.session || !ctx.user || ctx.user.role !== "owner") {
|
if (
|
||||||
|
!ctx.session ||
|
||||||
|
!ctx.user ||
|
||||||
|
(ctx.user.role !== "owner" && ctx.user.role !== "admin")
|
||||||
|
) {
|
||||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
}
|
}
|
||||||
return next({
|
return next({
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import {
|
|||||||
createDefaultServerTraefikConfig,
|
createDefaultServerTraefikConfig,
|
||||||
createDefaultTraefikConfig,
|
createDefaultTraefikConfig,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
|
initCancelDeployments,
|
||||||
initCronJobs,
|
initCronJobs,
|
||||||
initializeNetwork,
|
initializeNetwork,
|
||||||
initSchedules,
|
initSchedules,
|
||||||
initVolumeBackupsCronJobs,
|
initVolumeBackupsCronJobs,
|
||||||
initCancelDeployments,
|
|
||||||
sendDokployRestartNotifications,
|
sendDokployRestartNotifications,
|
||||||
setupDirectories,
|
setupDirectories,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
@@ -26,6 +26,16 @@ 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 HOST = process.env.HOST || "0.0.0.0";
|
||||||
const dev = process.env.NODE_ENV !== "production";
|
const dev = process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
|
// Initialize critical directories and Traefik config BEFORE Next.js starts
|
||||||
|
// This prevents race conditions with the install script
|
||||||
|
if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
|
||||||
|
setupDirectories();
|
||||||
|
createDefaultTraefikConfig();
|
||||||
|
createDefaultServerTraefikConfig();
|
||||||
|
console.log("✅ Critical initialization complete");
|
||||||
|
}
|
||||||
|
|
||||||
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();
|
||||||
void app.prepare().then(async () => {
|
void app.prepare().then(async () => {
|
||||||
@@ -45,11 +55,8 @@ void app.prepare().then(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
|
if (process.env.NODE_ENV === "production" && !IS_CLOUD) {
|
||||||
setupDirectories();
|
|
||||||
createDefaultMiddlewares();
|
createDefaultMiddlewares();
|
||||||
await initializeNetwork();
|
await initializeNetwork();
|
||||||
createDefaultTraefikConfig();
|
|
||||||
createDefaultServerTraefikConfig();
|
|
||||||
await migration();
|
await migration();
|
||||||
await initCronJobs();
|
await initCronJobs();
|
||||||
await initSchedules();
|
await initSchedules();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type QueueJob =
|
|||||||
type: "schedule";
|
type: "schedule";
|
||||||
cronSchedule: string;
|
cronSchedule: string;
|
||||||
scheduleId: string;
|
scheduleId: string;
|
||||||
|
timezone?: string | null;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "volume-backup";
|
type: "volume-backup";
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ export const setupDockerContainerLogsWebSocketServer = (
|
|||||||
const command = search
|
const command = search
|
||||||
? `${baseCommand} 2>&1 | grep --line-buffered -iF "${escapedSearch}"`
|
? `${baseCommand} 2>&1 | grep --line-buffered -iF "${escapedSearch}"`
|
||||||
: baseCommand;
|
: baseCommand;
|
||||||
client.exec(command, (err, stream) => {
|
// Use pty: true to ensure the remote process receives SIGHUP when SSH connection closes
|
||||||
|
// This is crucial for terminating docker logs processes when the connection is closed
|
||||||
|
client.exec(command, { pty: true }, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Execution error:", err);
|
console.error("Execution error:", err);
|
||||||
ws.close();
|
ws.close();
|
||||||
|
|||||||
@@ -58,7 +58,12 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
|||||||
`docker exec -it -w / ${containerId} ${activeWay}`,
|
`docker exec -it -w / ${containerId} ${activeWay}`,
|
||||||
{ pty: true },
|
{ pty: true },
|
||||||
(err, stream) => {
|
(err, stream) => {
|
||||||
if (err) throw err;
|
if (err) {
|
||||||
|
console.error("SSH exec error:", err);
|
||||||
|
ws.close();
|
||||||
|
conn.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
stream
|
stream
|
||||||
.on("close", (code: number, _signal: string) => {
|
.on("close", (code: number, _signal: string) => {
|
||||||
@@ -93,10 +98,20 @@ export const setupDockerContainerTerminalWebSocketServer = (
|
|||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
stream.end();
|
stream.end();
|
||||||
|
// Ensure SSH connection is closed when WebSocket closes
|
||||||
|
conn.end();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
.on("error", (err) => {
|
||||||
|
console.error("SSH connection error:", err);
|
||||||
|
if (ws.readyState === ws.OPEN) {
|
||||||
|
ws.send(`SSH error: ${err.message}`);
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
conn.end();
|
||||||
|
})
|
||||||
.connect({
|
.connect({
|
||||||
host: server.ipAddress,
|
host: server.ipAddress,
|
||||||
port: server.port,
|
port: server.port,
|
||||||
|
|||||||
@@ -31,8 +31,11 @@ export const setupDeploymentLogsWebSocketServer = (
|
|||||||
const serverId = url.searchParams.get("serverId");
|
const serverId = url.searchParams.get("serverId");
|
||||||
const { user, session } = await validateRequest(req);
|
const { user, session } = await validateRequest(req);
|
||||||
|
|
||||||
|
// Generate unique connection ID for tracking
|
||||||
|
const connectionId = `deployment-logs-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||||
|
|
||||||
if (!logPath) {
|
if (!logPath) {
|
||||||
console.log("logPath no provided");
|
console.log(`[${connectionId}] logPath no provided`);
|
||||||
ws.close(4000, "logPath no provided");
|
ws.close(4000, "logPath no provided");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -42,40 +45,55 @@ export const setupDeploymentLogsWebSocketServer = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tailProcess: ReturnType<typeof spawn> | null = null;
|
||||||
|
let sshClient: Client | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (serverId) {
|
if (serverId) {
|
||||||
const server = await findServerById(serverId);
|
const server = await findServerById(serverId);
|
||||||
|
|
||||||
if (!server.sshKeyId) return;
|
if (!server.sshKeyId) {
|
||||||
const client = new Client();
|
ws.close();
|
||||||
client
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sshClient = new Client();
|
||||||
|
sshClient
|
||||||
.on("ready", () => {
|
.on("ready", () => {
|
||||||
const command = `
|
const command = `
|
||||||
tail -n +1 -f ${logPath};
|
tail -n +1 -f ${logPath};
|
||||||
`;
|
`;
|
||||||
client.exec(command, (err, stream) => {
|
sshClient!.exec(command, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("Execution error:", err);
|
sshClient!.end();
|
||||||
ws.close();
|
ws.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stream
|
stream
|
||||||
.on("close", () => {
|
.on("close", () => {
|
||||||
client.end();
|
sshClient!.end();
|
||||||
ws.close();
|
ws.close();
|
||||||
})
|
})
|
||||||
.on("data", (data: string) => {
|
.on("data", (data: string) => {
|
||||||
ws.send(data.toString());
|
if (ws.readyState === ws.OPEN) {
|
||||||
|
ws.send(data.toString());
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.stderr.on("data", (data) => {
|
.stderr.on("data", (data) => {
|
||||||
ws.send(data.toString());
|
if (ws.readyState === ws.OPEN) {
|
||||||
|
ws.send(data.toString());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.on("error", (err) => {
|
.on("error", (err) => {
|
||||||
console.error("SSH connection error:", err);
|
if (ws.readyState === ws.OPEN) {
|
||||||
ws.send(`SSH error: ${err.message}`);
|
ws.send(`SSH error: ${err.message}`);
|
||||||
ws.close(); // Cierra el WebSocket si hay un error con SSH
|
ws.close();
|
||||||
|
}
|
||||||
|
if (sshClient) {
|
||||||
|
sshClient.end();
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.connect({
|
.connect({
|
||||||
host: server.ipAddress,
|
host: server.ipAddress,
|
||||||
@@ -85,26 +103,75 @@ export const setupDeploymentLogsWebSocketServer = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on("close", () => {
|
ws.on("close", () => {
|
||||||
client.end();
|
if (sshClient) {
|
||||||
|
sshClient.end();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const tail = spawn("tail", ["-n", "+1", "-f", logPath]);
|
tailProcess = spawn("tail", ["-n", "+1", "-f", logPath]);
|
||||||
|
|
||||||
tail.stdout.on("data", (data) => {
|
const stdout = tailProcess.stdout;
|
||||||
ws.send(data.toString());
|
const stderr = tailProcess.stderr;
|
||||||
});
|
|
||||||
|
|
||||||
tail.stderr.on("data", (data) => {
|
if (stdout) {
|
||||||
ws.send(new Error(`tail error: ${data.toString()}`).message);
|
stdout.on("data", (data) => {
|
||||||
});
|
if (ws.readyState === ws.OPEN) {
|
||||||
tail.on("close", () => {
|
ws.send(data.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stderr) {
|
||||||
|
stderr.on("data", (data) => {
|
||||||
|
if (ws.readyState === ws.OPEN) {
|
||||||
|
ws.send(new Error(`tail error: ${data.toString()}`).message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tailProcess.on("close", () => {
|
||||||
ws.close();
|
ws.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tailProcess.on("error", () => {
|
||||||
|
if (ws.readyState === ws.OPEN) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
if (tailProcess && !tailProcess.killed) {
|
||||||
|
tailProcess.kill("SIGTERM");
|
||||||
|
// Force kill after a timeout if it doesn't terminate
|
||||||
|
setTimeout(() => {
|
||||||
|
if (tailProcess && !tailProcess.killed) {
|
||||||
|
tailProcess.kill("SIGKILL");
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Clean up resources on error
|
||||||
|
if (tailProcess && !tailProcess.killed) {
|
||||||
|
tailProcess.kill("SIGTERM");
|
||||||
|
setTimeout(() => {
|
||||||
|
if (tailProcess && !tailProcess.killed) {
|
||||||
|
tailProcess.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
if (sshClient) {
|
||||||
|
sshClient.end();
|
||||||
|
}
|
||||||
|
if (ws.readyState === ws.OPEN) {
|
||||||
|
// @ts-ignore
|
||||||
|
const errorMessage = error?.message as unknown as string;
|
||||||
|
ws.send(errorMessage || "An error occurred");
|
||||||
|
ws.close();
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// @ts-ignore
|
|
||||||
// const errorMessage = error?.message as unknown as string;
|
|
||||||
// ws.send(errorMessage);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -247,3 +247,18 @@
|
|||||||
.cm-lineWrapping {
|
.cm-lineWrapping {
|
||||||
@apply font-mono;
|
@apply font-mono;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* HubSpot Widget - Force light color-scheme to prevent white background */
|
||||||
|
#hubspot-messages-iframe-container,
|
||||||
|
#hubspot-messages-iframe-container * {
|
||||||
|
background-color: transparent !important;
|
||||||
|
color-scheme: light !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#hubspot-messages-iframe-container .hs-shadow-container {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#hubspot-conversations-iframe {
|
||||||
|
color-scheme: light !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,21 +8,22 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
|
||||||
/* Strictness */
|
/* Strictness */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"checkJs": true,
|
"checkJs": true,
|
||||||
|
|
||||||
/* Bundled projects */
|
/* Bundled projects */
|
||||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"plugins": [{ "name": "next" }],
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
|
||||||
/* Path Aliases */
|
/* Path Aliases */
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
@@ -30,7 +31,6 @@
|
|||||||
"@dokploy/server/*": ["../../packages/server/src/*"]
|
"@dokploy/server/*": ["../../packages/server/src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export const scheduleJob = (job: QueueJob) => {
|
|||||||
jobQueue.add(job.scheduleId, job, {
|
jobQueue.add(job.scheduleId, job, {
|
||||||
repeat: {
|
repeat: {
|
||||||
pattern: job.cronSchedule,
|
pattern: job.cronSchedule,
|
||||||
|
tz: job.timezone || "UTC",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (job.type === "volume-backup") {
|
} else if (job.type === "volume-backup") {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const jobQueueSchema = z.discriminatedUnion("type", [
|
|||||||
cronSchedule: z.string(),
|
cronSchedule: z.string(),
|
||||||
type: z.literal("schedule"),
|
type: z.literal("schedule"),
|
||||||
scheduleId: z.string(),
|
scheduleId: z.string(),
|
||||||
|
timezone: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
cronSchedule: z.string(),
|
cronSchedule: z.string(),
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
cleanUpDockerBuilder,
|
cleanupAll,
|
||||||
cleanUpSystemPrune,
|
|
||||||
cleanUpUnusedImages,
|
|
||||||
findBackupById,
|
findBackupById,
|
||||||
findScheduleById,
|
findScheduleById,
|
||||||
findServerById,
|
findServerById,
|
||||||
@@ -91,9 +89,7 @@ export const runJobs = async (job: QueueJob) => {
|
|||||||
logger.info("Server is inactive");
|
logger.info("Server is inactive");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await cleanUpUnusedImages(serverId);
|
await cleanupAll(serverId);
|
||||||
await cleanUpDockerBuilder(serverId);
|
|
||||||
await cleanUpSystemPrune(serverId);
|
|
||||||
} else if (job.type === "schedule") {
|
} else if (job.type === "schedule") {
|
||||||
const { scheduleId } = job;
|
const { scheduleId } = job;
|
||||||
const schedule = await findScheduleById(scheduleId);
|
const schedule = await findScheduleById(scheduleId);
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
"switch:prod": "node scripts/switchToDist.js",
|
"switch:prod": "node scripts/switchToDist.js",
|
||||||
"dev": "rm -rf ./dist && pnpm esbuild && tsc --emitDeclarationOnly --outDir dist -p tsconfig.server.json",
|
"dev": "rm -rf ./dist && pnpm esbuild && tsc --emitDeclarationOnly --outDir dist -p tsconfig.server.json",
|
||||||
"esbuild": "tsx ./esbuild.config.ts && tsc --project tsconfig.server.json --emitDeclarationOnly ",
|
"esbuild": "tsx ./esbuild.config.ts && tsc --project tsconfig.server.json --emitDeclarationOnly ",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"dbml:generate": "npx tsx src/db/schema/dbml.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^2.0.5",
|
"@ai-sdk/anthropic": "^2.0.5",
|
||||||
|
|||||||
1200
packages/server/schema.dbml
Normal file
1200
packages/server/schema.dbml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -181,12 +181,19 @@ export const applications = pgTable("application", {
|
|||||||
herokuVersion: text("herokuVersion").default("24"),
|
herokuVersion: text("herokuVersion").default("24"),
|
||||||
publishDirectory: text("publishDirectory"),
|
publishDirectory: text("publishDirectory"),
|
||||||
isStaticSpa: boolean("isStaticSpa"),
|
isStaticSpa: boolean("isStaticSpa"),
|
||||||
|
createEnvFile: boolean("createEnvFile").notNull().default(true),
|
||||||
createdAt: text("createdAt")
|
createdAt: text("createdAt")
|
||||||
.notNull()
|
.notNull()
|
||||||
.$defaultFn(() => new Date().toISOString()),
|
.$defaultFn(() => new Date().toISOString()),
|
||||||
registryId: text("registryId").references(() => registry.registryId, {
|
registryId: text("registryId").references(() => registry.registryId, {
|
||||||
onDelete: "set null",
|
onDelete: "set null",
|
||||||
}),
|
}),
|
||||||
|
rollbackRegistryId: text("rollbackRegistryId").references(
|
||||||
|
() => registry.registryId,
|
||||||
|
{
|
||||||
|
onDelete: "set null",
|
||||||
|
},
|
||||||
|
),
|
||||||
environmentId: text("environmentId")
|
environmentId: text("environmentId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => environments.environmentId, { onDelete: "cascade" }),
|
.references(() => environments.environmentId, { onDelete: "cascade" }),
|
||||||
@@ -270,6 +277,11 @@ export const applicationsRelations = relations(
|
|||||||
relationName: "applicationBuildRegistry",
|
relationName: "applicationBuildRegistry",
|
||||||
}),
|
}),
|
||||||
previewDeployments: many(previewDeployments),
|
previewDeployments: many(previewDeployments),
|
||||||
|
rollbackRegistry: one(registry, {
|
||||||
|
fields: [applications.rollbackRegistryId],
|
||||||
|
references: [registry.registryId],
|
||||||
|
relationName: "applicationRollbackRegistry",
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -321,6 +333,7 @@ const createSchema = createInsertSchema(applications, {
|
|||||||
herokuVersion: z.string().optional(),
|
herokuVersion: z.string().optional(),
|
||||||
publishDirectory: z.string().optional(),
|
publishDirectory: z.string().optional(),
|
||||||
isStaticSpa: z.boolean().optional(),
|
isStaticSpa: z.boolean().optional(),
|
||||||
|
createEnvFile: z.boolean().optional(),
|
||||||
owner: z.string(),
|
owner: z.string(),
|
||||||
healthCheckSwarm: HealthCheckSwarmSchema.nullable(),
|
healthCheckSwarm: HealthCheckSwarmSchema.nullable(),
|
||||||
restartPolicySwarm: RestartPolicySwarmSchema.nullable(),
|
restartPolicySwarm: RestartPolicySwarmSchema.nullable(),
|
||||||
@@ -490,6 +503,7 @@ export const apiSaveEnvironmentVariables = createSchema
|
|||||||
env: true,
|
env: true,
|
||||||
buildArgs: true,
|
buildArgs: true,
|
||||||
buildSecrets: true,
|
buildSecrets: true,
|
||||||
|
createEnvFile: true,
|
||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { pgTable, text } from "drizzle-orm/pg-core";
|
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
|
||||||
import { createInsertSchema } from "drizzle-zod";
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -26,6 +26,7 @@ export const environments = pgTable("environment", {
|
|||||||
projectId: text("projectId")
|
projectId: text("projectId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => projects.projectId, { onDelete: "cascade" }),
|
.references(() => projects.projectId, { onDelete: "cascade" }),
|
||||||
|
isDefault: boolean("isDefault").notNull().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const environmentRelations = relations(
|
export const environmentRelations = relations(
|
||||||
@@ -69,9 +70,14 @@ export const apiRemoveEnvironment = createSchema
|
|||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|
||||||
export const apiUpdateEnvironment = createSchema.partial().extend({
|
export const apiUpdateEnvironment = createSchema
|
||||||
environmentId: z.string().min(1),
|
.partial()
|
||||||
});
|
.extend({
|
||||||
|
environmentId: z.string().min(1),
|
||||||
|
})
|
||||||
|
.omit({
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
|
||||||
export const apiDuplicateEnvironment = createSchema
|
export const apiDuplicateEnvironment = createSchema
|
||||||
.pick({
|
.pick({
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { boolean, integer, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
import {
|
||||||
|
boolean,
|
||||||
|
integer,
|
||||||
|
jsonb,
|
||||||
|
pgEnum,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
import { createInsertSchema } from "drizzle-zod";
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -12,6 +19,7 @@ export const notificationType = pgEnum("notificationType", [
|
|||||||
"email",
|
"email",
|
||||||
"gotify",
|
"gotify",
|
||||||
"ntfy",
|
"ntfy",
|
||||||
|
"custom",
|
||||||
"lark",
|
"lark",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -24,6 +32,7 @@ export const notifications = pgTable("notification", {
|
|||||||
appDeploy: boolean("appDeploy").notNull().default(false),
|
appDeploy: boolean("appDeploy").notNull().default(false),
|
||||||
appBuildError: boolean("appBuildError").notNull().default(false),
|
appBuildError: boolean("appBuildError").notNull().default(false),
|
||||||
databaseBackup: boolean("databaseBackup").notNull().default(false),
|
databaseBackup: boolean("databaseBackup").notNull().default(false),
|
||||||
|
volumeBackup: boolean("volumeBackup").notNull().default(false),
|
||||||
dokployRestart: boolean("dokployRestart").notNull().default(false),
|
dokployRestart: boolean("dokployRestart").notNull().default(false),
|
||||||
dockerCleanup: boolean("dockerCleanup").notNull().default(false),
|
dockerCleanup: boolean("dockerCleanup").notNull().default(false),
|
||||||
serverThreshold: boolean("serverThreshold").notNull().default(false),
|
serverThreshold: boolean("serverThreshold").notNull().default(false),
|
||||||
@@ -49,6 +58,9 @@ export const notifications = pgTable("notification", {
|
|||||||
ntfyId: text("ntfyId").references(() => ntfy.ntfyId, {
|
ntfyId: text("ntfyId").references(() => ntfy.ntfyId, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
|
customId: text("customId").references(() => custom.customId, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
larkId: text("larkId").references(() => lark.larkId, {
|
larkId: text("larkId").references(() => lark.larkId, {
|
||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
@@ -120,6 +132,15 @@ export const ntfy = pgTable("ntfy", {
|
|||||||
priority: integer("priority").notNull().default(3),
|
priority: integer("priority").notNull().default(3),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const custom = pgTable("custom", {
|
||||||
|
customId: text("customId")
|
||||||
|
.notNull()
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
endpoint: text("endpoint").notNull(),
|
||||||
|
headers: jsonb("headers").$type<Record<string, string>>(),
|
||||||
|
});
|
||||||
|
|
||||||
export const lark = pgTable("lark", {
|
export const lark = pgTable("lark", {
|
||||||
larkId: text("larkId")
|
larkId: text("larkId")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -153,6 +174,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
|
|||||||
fields: [notifications.ntfyId],
|
fields: [notifications.ntfyId],
|
||||||
references: [ntfy.ntfyId],
|
references: [ntfy.ntfyId],
|
||||||
}),
|
}),
|
||||||
|
custom: one(custom, {
|
||||||
|
fields: [notifications.customId],
|
||||||
|
references: [custom.customId],
|
||||||
|
}),
|
||||||
lark: one(lark, {
|
lark: one(lark, {
|
||||||
fields: [notifications.larkId],
|
fields: [notifications.larkId],
|
||||||
references: [lark.larkId],
|
references: [lark.larkId],
|
||||||
@@ -169,6 +194,7 @@ export const apiCreateSlack = notificationsSchema
|
|||||||
.pick({
|
.pick({
|
||||||
appBuildError: true,
|
appBuildError: true,
|
||||||
databaseBackup: true,
|
databaseBackup: true,
|
||||||
|
volumeBackup: true,
|
||||||
dokployRestart: true,
|
dokployRestart: true,
|
||||||
name: true,
|
name: true,
|
||||||
appDeploy: true,
|
appDeploy: true,
|
||||||
@@ -196,6 +222,7 @@ export const apiCreateTelegram = notificationsSchema
|
|||||||
.pick({
|
.pick({
|
||||||
appBuildError: true,
|
appBuildError: true,
|
||||||
databaseBackup: true,
|
databaseBackup: true,
|
||||||
|
volumeBackup: true,
|
||||||
dokployRestart: true,
|
dokployRestart: true,
|
||||||
name: true,
|
name: true,
|
||||||
appDeploy: true,
|
appDeploy: true,
|
||||||
@@ -225,6 +252,7 @@ export const apiCreateDiscord = notificationsSchema
|
|||||||
.pick({
|
.pick({
|
||||||
appBuildError: true,
|
appBuildError: true,
|
||||||
databaseBackup: true,
|
databaseBackup: true,
|
||||||
|
volumeBackup: true,
|
||||||
dokployRestart: true,
|
dokployRestart: true,
|
||||||
name: true,
|
name: true,
|
||||||
appDeploy: true,
|
appDeploy: true,
|
||||||
@@ -255,6 +283,7 @@ export const apiCreateEmail = notificationsSchema
|
|||||||
.pick({
|
.pick({
|
||||||
appBuildError: true,
|
appBuildError: true,
|
||||||
databaseBackup: true,
|
databaseBackup: true,
|
||||||
|
volumeBackup: true,
|
||||||
dokployRestart: true,
|
dokployRestart: true,
|
||||||
name: true,
|
name: true,
|
||||||
appDeploy: true,
|
appDeploy: true,
|
||||||
@@ -290,6 +319,7 @@ export const apiCreateGotify = notificationsSchema
|
|||||||
.pick({
|
.pick({
|
||||||
appBuildError: true,
|
appBuildError: true,
|
||||||
databaseBackup: true,
|
databaseBackup: true,
|
||||||
|
volumeBackup: true,
|
||||||
dokployRestart: true,
|
dokployRestart: true,
|
||||||
name: true,
|
name: true,
|
||||||
appDeploy: true,
|
appDeploy: true,
|
||||||
@@ -323,6 +353,7 @@ export const apiCreateNtfy = notificationsSchema
|
|||||||
.pick({
|
.pick({
|
||||||
appBuildError: true,
|
appBuildError: true,
|
||||||
databaseBackup: true,
|
databaseBackup: true,
|
||||||
|
volumeBackup: true,
|
||||||
dokployRestart: true,
|
dokployRestart: true,
|
||||||
name: true,
|
name: true,
|
||||||
appDeploy: true,
|
appDeploy: true,
|
||||||
@@ -355,6 +386,32 @@ export const apiFindOneNotification = notificationsSchema
|
|||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|
||||||
|
export const apiCreateCustom = notificationsSchema
|
||||||
|
.pick({
|
||||||
|
appBuildError: true,
|
||||||
|
databaseBackup: true,
|
||||||
|
dokployRestart: true,
|
||||||
|
name: true,
|
||||||
|
appDeploy: true,
|
||||||
|
dockerCleanup: true,
|
||||||
|
serverThreshold: true,
|
||||||
|
})
|
||||||
|
.extend({
|
||||||
|
endpoint: z.string().min(1),
|
||||||
|
headers: z.record(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiUpdateCustom = apiCreateCustom.partial().extend({
|
||||||
|
notificationId: z.string().min(1),
|
||||||
|
customId: z.string().min(1),
|
||||||
|
organizationId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiTestCustomConnection = z.object({
|
||||||
|
endpoint: z.string().min(1),
|
||||||
|
headers: z.record(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const apiCreateLark = notificationsSchema
|
export const apiCreateLark = notificationsSchema
|
||||||
.pick({
|
.pick({
|
||||||
appBuildError: true,
|
appBuildError: true,
|
||||||
@@ -397,5 +454,7 @@ export const apiSendTest = notificationsSchema
|
|||||||
appToken: z.string(),
|
appToken: z.string(),
|
||||||
accessToken: z.string().optional(),
|
accessToken: z.string().optional(),
|
||||||
priority: z.number(),
|
priority: z.number(),
|
||||||
|
endpoint: z.string(),
|
||||||
|
headers: z.string(),
|
||||||
})
|
})
|
||||||
.partial();
|
.partial();
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export const registryRelations = relations(registry, ({ many }) => ({
|
|||||||
buildApplications: many(applications, {
|
buildApplications: many(applications, {
|
||||||
relationName: "applicationBuildRegistry",
|
relationName: "applicationBuildRegistry",
|
||||||
}),
|
}),
|
||||||
|
rollbackApplications: many(applications, {
|
||||||
|
relationName: "applicationRollbackRegistry",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const createSchema = createInsertSchema(registry, {
|
const createSchema = createInsertSchema(registry, {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const schedules = pgTable("schedule", {
|
|||||||
onDelete: "cascade",
|
onDelete: "cascade",
|
||||||
}),
|
}),
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
|
timezone: text("timezone"),
|
||||||
createdAt: text("createdAt")
|
createdAt: text("createdAt")
|
||||||
.notNull()
|
.notNull()
|
||||||
.$defaultFn(() => new Date().toISOString()),
|
.$defaultFn(() => new Date().toISOString()),
|
||||||
|
|||||||
@@ -5,17 +5,24 @@ enum applicationStatus {
|
|||||||
error
|
error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum backupType {
|
||||||
|
database
|
||||||
|
compose
|
||||||
|
}
|
||||||
|
|
||||||
enum buildType {
|
enum buildType {
|
||||||
dockerfile
|
dockerfile
|
||||||
heroku_buildpacks
|
heroku_buildpacks
|
||||||
paketo_buildpacks
|
paketo_buildpacks
|
||||||
nixpacks
|
nixpacks
|
||||||
static
|
static
|
||||||
|
railpack
|
||||||
}
|
}
|
||||||
|
|
||||||
enum certificateType {
|
enum certificateType {
|
||||||
letsencrypt
|
letsencrypt
|
||||||
none
|
none
|
||||||
|
custom
|
||||||
}
|
}
|
||||||
|
|
||||||
enum composeType {
|
enum composeType {
|
||||||
@@ -28,6 +35,7 @@ enum databaseType {
|
|||||||
mariadb
|
mariadb
|
||||||
mysql
|
mysql
|
||||||
mongo
|
mongo
|
||||||
|
"web-server"
|
||||||
}
|
}
|
||||||
|
|
||||||
enum deploymentStatus {
|
enum deploymentStatus {
|
||||||
@@ -61,6 +69,8 @@ enum notificationType {
|
|||||||
discord
|
discord
|
||||||
email
|
email
|
||||||
gotify
|
gotify
|
||||||
|
ntfy
|
||||||
|
custom
|
||||||
}
|
}
|
||||||
|
|
||||||
enum protocolType {
|
enum protocolType {
|
||||||
@@ -68,14 +78,21 @@ enum protocolType {
|
|||||||
udp
|
udp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum publishModeType {
|
||||||
|
ingress
|
||||||
|
host
|
||||||
|
}
|
||||||
|
|
||||||
enum RegistryType {
|
enum RegistryType {
|
||||||
selfHosted
|
selfHosted
|
||||||
cloud
|
cloud
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Roles {
|
enum scheduleType {
|
||||||
admin
|
application
|
||||||
user
|
compose
|
||||||
|
server
|
||||||
|
"dokploy-server"
|
||||||
}
|
}
|
||||||
|
|
||||||
enum serverStatus {
|
enum serverStatus {
|
||||||
@@ -93,6 +110,11 @@ enum serviceType {
|
|||||||
compose
|
compose
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum shellType {
|
||||||
|
bash
|
||||||
|
sh
|
||||||
|
}
|
||||||
|
|
||||||
enum sourceType {
|
enum sourceType {
|
||||||
docker
|
docker
|
||||||
git
|
git
|
||||||
@@ -112,6 +134,11 @@ enum sourceTypeCompose {
|
|||||||
raw
|
raw
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum triggerType {
|
||||||
|
push
|
||||||
|
tag
|
||||||
|
}
|
||||||
|
|
||||||
table account {
|
table account {
|
||||||
id text [pk, not null]
|
id text [pk, not null]
|
||||||
account_id text [not null]
|
account_id text [not null]
|
||||||
@@ -133,7 +160,39 @@ table account {
|
|||||||
confirmationExpiresAt text
|
confirmationExpiresAt text
|
||||||
}
|
}
|
||||||
|
|
||||||
table admin {
|
table ai {
|
||||||
|
aiId text [pk, not null]
|
||||||
|
name text [not null]
|
||||||
|
apiUrl text [not null]
|
||||||
|
apiKey text [not null]
|
||||||
|
model text [not null]
|
||||||
|
isEnabled boolean [not null, default: true]
|
||||||
|
organizationId text [not null]
|
||||||
|
createdAt text [not null]
|
||||||
|
}
|
||||||
|
|
||||||
|
table apikey {
|
||||||
|
id text [pk, not null]
|
||||||
|
name text
|
||||||
|
start text
|
||||||
|
prefix text
|
||||||
|
key text [not null]
|
||||||
|
user_id text [not null]
|
||||||
|
refill_interval integer
|
||||||
|
refill_amount integer
|
||||||
|
last_refill_at timestamp
|
||||||
|
enabled boolean
|
||||||
|
rate_limit_enabled boolean
|
||||||
|
rate_limit_time_window integer
|
||||||
|
rate_limit_max integer
|
||||||
|
request_count integer
|
||||||
|
remaining integer
|
||||||
|
last_request timestamp
|
||||||
|
expires_at timestamp
|
||||||
|
created_at timestamp [not null]
|
||||||
|
updated_at timestamp [not null]
|
||||||
|
permissions text
|
||||||
|
metadata text
|
||||||
}
|
}
|
||||||
|
|
||||||
table application {
|
table application {
|
||||||
@@ -143,14 +202,19 @@ table application {
|
|||||||
description text
|
description text
|
||||||
env text
|
env text
|
||||||
previewEnv text
|
previewEnv text
|
||||||
|
watchPaths text[]
|
||||||
previewBuildArgs text
|
previewBuildArgs text
|
||||||
|
previewLabels text[]
|
||||||
previewWildcard text
|
previewWildcard text
|
||||||
previewPort integer [default: 3000]
|
previewPort integer [default: 3000]
|
||||||
previewHttps boolean [not null, default: false]
|
previewHttps boolean [not null, default: false]
|
||||||
previewPath text [default: '/']
|
previewPath text [default: '/']
|
||||||
certificateType certificateType [not null, default: 'none']
|
certificateType certificateType [not null, default: 'none']
|
||||||
|
previewCustomCertResolver text
|
||||||
previewLimit integer [default: 3]
|
previewLimit integer [default: 3]
|
||||||
isPreviewDeploymentsActive boolean [default: false]
|
isPreviewDeploymentsActive boolean [default: false]
|
||||||
|
previewRequireCollaboratorPermissions boolean [default: true]
|
||||||
|
rollbackActive boolean [default: false]
|
||||||
buildArgs text
|
buildArgs text
|
||||||
memoryReservation text
|
memoryReservation text
|
||||||
memoryLimit text
|
memoryLimit text
|
||||||
@@ -167,6 +231,7 @@ table application {
|
|||||||
owner text
|
owner text
|
||||||
branch text
|
branch text
|
||||||
buildPath text [default: '/']
|
buildPath text [default: '/']
|
||||||
|
triggerType triggerType [default: 'push']
|
||||||
autoDeploy boolean
|
autoDeploy boolean
|
||||||
gitlabProjectId integer
|
gitlabProjectId integer
|
||||||
gitlabRepository text
|
gitlabRepository text
|
||||||
@@ -174,6 +239,10 @@ table application {
|
|||||||
gitlabBranch text
|
gitlabBranch text
|
||||||
gitlabBuildPath text [default: '/']
|
gitlabBuildPath text [default: '/']
|
||||||
gitlabPathNamespace text
|
gitlabPathNamespace text
|
||||||
|
giteaRepository text
|
||||||
|
giteaOwner text
|
||||||
|
giteaBranch text
|
||||||
|
giteaBuildPath text [default: '/']
|
||||||
bitbucketRepository text
|
bitbucketRepository text
|
||||||
bitbucketOwner text
|
bitbucketOwner text
|
||||||
bitbucketBranch text
|
bitbucketBranch text
|
||||||
@@ -186,6 +255,7 @@ table application {
|
|||||||
customGitBranch text
|
customGitBranch text
|
||||||
customGitBuildPath text
|
customGitBuildPath text
|
||||||
customGitSSHKeyId text
|
customGitSSHKeyId text
|
||||||
|
enableSubmodules boolean [not null, default: false]
|
||||||
dockerfile text
|
dockerfile text
|
||||||
dockerContextPath text
|
dockerContextPath text
|
||||||
dockerBuildStage text
|
dockerBuildStage text
|
||||||
@@ -201,52 +271,47 @@ table application {
|
|||||||
replicas integer [not null, default: 1]
|
replicas integer [not null, default: 1]
|
||||||
applicationStatus applicationStatus [not null, default: 'idle']
|
applicationStatus applicationStatus [not null, default: 'idle']
|
||||||
buildType buildType [not null, default: 'nixpacks']
|
buildType buildType [not null, default: 'nixpacks']
|
||||||
|
railpackVersion text [default: '0.2.2']
|
||||||
herokuVersion text [default: '24']
|
herokuVersion text [default: '24']
|
||||||
publishDirectory text
|
publishDirectory text
|
||||||
|
isStaticSpa boolean
|
||||||
createdAt text [not null]
|
createdAt text [not null]
|
||||||
registryId text
|
registryId text
|
||||||
projectId text [not null]
|
environmentId text [not null]
|
||||||
githubId text
|
githubId text
|
||||||
gitlabId text
|
gitlabId text
|
||||||
bitbucketId text
|
|
||||||
giteaId text
|
giteaId text
|
||||||
|
bitbucketId text
|
||||||
serverId text
|
serverId text
|
||||||
}
|
}
|
||||||
|
|
||||||
table auth {
|
|
||||||
id text [pk, not null]
|
|
||||||
email text [not null, unique]
|
|
||||||
password text [not null]
|
|
||||||
rol Roles [not null]
|
|
||||||
image text
|
|
||||||
secret text
|
|
||||||
token text
|
|
||||||
is2FAEnabled boolean [not null, default: false]
|
|
||||||
createdAt text [not null]
|
|
||||||
resetPasswordToken text
|
|
||||||
resetPasswordExpiresAt text
|
|
||||||
confirmationToken text
|
|
||||||
confirmationExpiresAt text
|
|
||||||
}
|
|
||||||
|
|
||||||
table backup {
|
table backup {
|
||||||
backupId text [pk, not null]
|
backupId text [pk, not null]
|
||||||
|
appName text [not null, unique]
|
||||||
schedule text [not null]
|
schedule text [not null]
|
||||||
enabled boolean
|
enabled boolean
|
||||||
database text [not null]
|
database text [not null]
|
||||||
prefix text [not null]
|
prefix text [not null]
|
||||||
|
serviceName text
|
||||||
destinationId text [not null]
|
destinationId text [not null]
|
||||||
|
keepLatestCount integer
|
||||||
|
backupType backupType [not null, default: 'database']
|
||||||
databaseType databaseType [not null]
|
databaseType databaseType [not null]
|
||||||
|
composeId text
|
||||||
postgresId text
|
postgresId text
|
||||||
mariadbId text
|
mariadbId text
|
||||||
mysqlId text
|
mysqlId text
|
||||||
mongoId text
|
mongoId text
|
||||||
|
userId text
|
||||||
|
metadata jsonb
|
||||||
}
|
}
|
||||||
|
|
||||||
table bitbucket {
|
table bitbucket {
|
||||||
bitbucketId text [pk, not null]
|
bitbucketId text [pk, not null]
|
||||||
bitbucketUsername text
|
bitbucketUsername text
|
||||||
|
bitbucketEmail text
|
||||||
appPassword text
|
appPassword text
|
||||||
|
apiToken text
|
||||||
bitbucketWorkspaceName text
|
bitbucketWorkspaceName text
|
||||||
gitProviderId text [not null]
|
gitProviderId text [not null]
|
||||||
}
|
}
|
||||||
@@ -258,7 +323,7 @@ table certificate {
|
|||||||
privateKey text [not null]
|
privateKey text [not null]
|
||||||
certificatePath text [not null, unique]
|
certificatePath text [not null, unique]
|
||||||
autoRenew boolean
|
autoRenew boolean
|
||||||
userId text
|
organizationId text [not null]
|
||||||
serverId text
|
serverId text
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,13 +356,17 @@ table compose {
|
|||||||
customGitBranch text
|
customGitBranch text
|
||||||
customGitSSHKeyId text
|
customGitSSHKeyId text
|
||||||
command text [not null, default: '']
|
command text [not null, default: '']
|
||||||
|
enableSubmodules boolean [not null, default: false]
|
||||||
composePath text [not null, default: './docker-compose.yml']
|
composePath text [not null, default: './docker-compose.yml']
|
||||||
suffix text [not null, default: '']
|
suffix text [not null, default: '']
|
||||||
randomize boolean [not null, default: false]
|
randomize boolean [not null, default: false]
|
||||||
isolatedDeployment boolean [not null, default: false]
|
isolatedDeployment boolean [not null, default: false]
|
||||||
|
isolatedDeploymentsVolume boolean [not null, default: false]
|
||||||
|
triggerType triggerType [default: 'push']
|
||||||
composeStatus applicationStatus [not null, default: 'idle']
|
composeStatus applicationStatus [not null, default: 'idle']
|
||||||
projectId text [not null]
|
environmentId text [not null]
|
||||||
createdAt text [not null]
|
createdAt text [not null]
|
||||||
|
watchPaths text[]
|
||||||
githubId text
|
githubId text
|
||||||
gitlabId text
|
gitlabId text
|
||||||
bitbucketId text
|
bitbucketId text
|
||||||
@@ -305,19 +374,32 @@ table compose {
|
|||||||
serverId text
|
serverId text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table custom {
|
||||||
|
customId text [pk, not null]
|
||||||
|
endpoint text [not null]
|
||||||
|
headers text
|
||||||
|
}
|
||||||
|
|
||||||
table deployment {
|
table deployment {
|
||||||
deploymentId text [pk, not null]
|
deploymentId text [pk, not null]
|
||||||
title text [not null]
|
title text [not null]
|
||||||
description text
|
description text
|
||||||
status deploymentStatus [default: 'running']
|
status deploymentStatus [default: 'running']
|
||||||
logPath text [not null]
|
logPath text [not null]
|
||||||
|
pid text
|
||||||
applicationId text
|
applicationId text
|
||||||
composeId text
|
composeId text
|
||||||
serverId text
|
serverId text
|
||||||
isPreviewDeployment boolean [default: false]
|
isPreviewDeployment boolean [default: false]
|
||||||
previewDeploymentId text
|
previewDeploymentId text
|
||||||
createdAt text [not null]
|
createdAt text [not null]
|
||||||
|
startedAt text
|
||||||
|
finishedAt text
|
||||||
errorMessage text
|
errorMessage text
|
||||||
|
scheduleId text
|
||||||
|
backupId text
|
||||||
|
rollbackId text
|
||||||
|
volumeBackupId text
|
||||||
}
|
}
|
||||||
|
|
||||||
table destination {
|
table destination {
|
||||||
@@ -329,7 +411,8 @@ table destination {
|
|||||||
bucket text [not null]
|
bucket text [not null]
|
||||||
region text [not null]
|
region text [not null]
|
||||||
endpoint text [not null]
|
endpoint text [not null]
|
||||||
userId text [not null]
|
organizationId text [not null]
|
||||||
|
createdAt timestamp [not null, default: `now()`]
|
||||||
}
|
}
|
||||||
|
|
||||||
table discord {
|
table discord {
|
||||||
@@ -349,9 +432,12 @@ table domain {
|
|||||||
uniqueConfigKey serial [not null, increment]
|
uniqueConfigKey serial [not null, increment]
|
||||||
createdAt text [not null]
|
createdAt text [not null]
|
||||||
composeId text
|
composeId text
|
||||||
|
customCertResolver text
|
||||||
applicationId text
|
applicationId text
|
||||||
previewDeploymentId text
|
previewDeploymentId text
|
||||||
certificateType certificateType [not null, default: 'none']
|
certificateType certificateType [not null, default: 'none']
|
||||||
|
internalPath text [default: '/']
|
||||||
|
stripPath boolean [not null, default: false]
|
||||||
}
|
}
|
||||||
|
|
||||||
table email {
|
table email {
|
||||||
@@ -364,12 +450,36 @@ table email {
|
|||||||
toAddress text[] [not null]
|
toAddress text[] [not null]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table environment {
|
||||||
|
environmentId text [pk, not null]
|
||||||
|
name text [not null]
|
||||||
|
description text
|
||||||
|
createdAt text [not null]
|
||||||
|
env text [not null, default: '']
|
||||||
|
projectId text [not null]
|
||||||
|
}
|
||||||
|
|
||||||
table git_provider {
|
table git_provider {
|
||||||
gitProviderId text [pk, not null]
|
gitProviderId text [pk, not null]
|
||||||
name text [not null]
|
name text [not null]
|
||||||
providerType gitProviderType [not null, default: 'github']
|
providerType gitProviderType [not null, default: 'github']
|
||||||
createdAt text [not null]
|
createdAt text [not null]
|
||||||
userId text
|
organizationId text [not null]
|
||||||
|
userId text [not null]
|
||||||
|
}
|
||||||
|
|
||||||
|
table gitea {
|
||||||
|
giteaId text [pk, not null]
|
||||||
|
giteaUrl text [not null, default: 'https://gitea.com']
|
||||||
|
redirect_uri text
|
||||||
|
client_id text
|
||||||
|
client_secret text
|
||||||
|
gitProviderId text [not null]
|
||||||
|
access_token text
|
||||||
|
refresh_token text
|
||||||
|
expires_at integer
|
||||||
|
scopes text [default: 'repo,repo:status,read:user,read:org']
|
||||||
|
last_authenticated_at integer
|
||||||
}
|
}
|
||||||
|
|
||||||
table github {
|
table github {
|
||||||
@@ -397,20 +507,6 @@ table gitlab {
|
|||||||
gitProviderId text [not null]
|
gitProviderId text [not null]
|
||||||
}
|
}
|
||||||
|
|
||||||
table gitea {
|
|
||||||
giteaId text [pk, not null]
|
|
||||||
giteaUrl text [not null, default: 'https://gitea.com']
|
|
||||||
redirect_uri text
|
|
||||||
client_id text [not null]
|
|
||||||
client_secret text [not null]
|
|
||||||
access_token text
|
|
||||||
refresh_token text
|
|
||||||
expires_at integer
|
|
||||||
gitProviderId text [not null]
|
|
||||||
scopes text [default: 'repo,repo:status,read:user,read:org']
|
|
||||||
last_authenticated_at integer
|
|
||||||
}
|
|
||||||
|
|
||||||
table gotify {
|
table gotify {
|
||||||
gotifyId text [pk, not null]
|
gotifyId text [pk, not null]
|
||||||
serverUrl text [not null]
|
serverUrl text [not null]
|
||||||
@@ -427,6 +523,7 @@ table invitation {
|
|||||||
status text [not null]
|
status text [not null]
|
||||||
expires_at timestamp [not null]
|
expires_at timestamp [not null]
|
||||||
inviter_id text [not null]
|
inviter_id text [not null]
|
||||||
|
team_id text
|
||||||
}
|
}
|
||||||
|
|
||||||
table mariadb {
|
table mariadb {
|
||||||
@@ -447,8 +544,17 @@ table mariadb {
|
|||||||
cpuLimit text
|
cpuLimit text
|
||||||
externalPort integer
|
externalPort integer
|
||||||
applicationStatus applicationStatus [not null, default: 'idle']
|
applicationStatus applicationStatus [not null, default: 'idle']
|
||||||
|
healthCheckSwarm json
|
||||||
|
restartPolicySwarm json
|
||||||
|
placementSwarm json
|
||||||
|
updateConfigSwarm json
|
||||||
|
rollbackConfigSwarm json
|
||||||
|
modeSwarm json
|
||||||
|
labelsSwarm json
|
||||||
|
networkSwarm json
|
||||||
|
replicas integer [not null, default: 1]
|
||||||
createdAt text [not null]
|
createdAt text [not null]
|
||||||
projectId text [not null]
|
environmentId text [not null]
|
||||||
serverId text
|
serverId text
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,6 +564,19 @@ table member {
|
|||||||
user_id text [not null]
|
user_id text [not null]
|
||||||
role text [not null]
|
role text [not null]
|
||||||
created_at timestamp [not null]
|
created_at timestamp [not null]
|
||||||
|
team_id text
|
||||||
|
canCreateProjects boolean [not null, default: false]
|
||||||
|
canAccessToSSHKeys boolean [not null, default: false]
|
||||||
|
canCreateServices boolean [not null, default: false]
|
||||||
|
canDeleteProjects boolean [not null, default: false]
|
||||||
|
canDeleteServices boolean [not null, default: false]
|
||||||
|
canAccessToDocker boolean [not null, default: false]
|
||||||
|
canAccessToAPI boolean [not null, default: false]
|
||||||
|
canAccessToGitProviders boolean [not null, default: false]
|
||||||
|
canAccessToTraefikFiles boolean [not null, default: false]
|
||||||
|
accesedProjects text[] [not null, default: `ARRAY[]::text[]`]
|
||||||
|
accessedEnvironments text[] [not null, default: `ARRAY[]::text[]`]
|
||||||
|
accesedServices text[] [not null, default: `ARRAY[]::text[]`]
|
||||||
}
|
}
|
||||||
|
|
||||||
table mongo {
|
table mongo {
|
||||||
@@ -476,8 +595,17 @@ table mongo {
|
|||||||
cpuLimit text
|
cpuLimit text
|
||||||
externalPort integer
|
externalPort integer
|
||||||
applicationStatus applicationStatus [not null, default: 'idle']
|
applicationStatus applicationStatus [not null, default: 'idle']
|
||||||
|
healthCheckSwarm json
|
||||||
|
restartPolicySwarm json
|
||||||
|
placementSwarm json
|
||||||
|
updateConfigSwarm json
|
||||||
|
rollbackConfigSwarm json
|
||||||
|
modeSwarm json
|
||||||
|
labelsSwarm json
|
||||||
|
networkSwarm json
|
||||||
|
replicas integer [not null, default: 1]
|
||||||
createdAt text [not null]
|
createdAt text [not null]
|
||||||
projectId text [not null]
|
environmentId text [not null]
|
||||||
serverId text
|
serverId text
|
||||||
replicaSets boolean [default: false]
|
replicaSets boolean [default: false]
|
||||||
}
|
}
|
||||||
@@ -518,8 +646,17 @@ table mysql {
|
|||||||
cpuLimit text
|
cpuLimit text
|
||||||
externalPort integer
|
externalPort integer
|
||||||
applicationStatus applicationStatus [not null, default: 'idle']
|
applicationStatus applicationStatus [not null, default: 'idle']
|
||||||
|
healthCheckSwarm json
|
||||||
|
restartPolicySwarm json
|
||||||
|
placementSwarm json
|
||||||
|
updateConfigSwarm json
|
||||||
|
rollbackConfigSwarm json
|
||||||
|
modeSwarm json
|
||||||
|
labelsSwarm json
|
||||||
|
networkSwarm json
|
||||||
|
replicas integer [not null, default: 1]
|
||||||
createdAt text [not null]
|
createdAt text [not null]
|
||||||
projectId text [not null]
|
environmentId text [not null]
|
||||||
serverId text
|
serverId text
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,7 +676,17 @@ table notification {
|
|||||||
discordId text
|
discordId text
|
||||||
emailId text
|
emailId text
|
||||||
gotifyId text
|
gotifyId text
|
||||||
userId text
|
ntfyId text
|
||||||
|
customId text
|
||||||
|
organizationId text [not null]
|
||||||
|
}
|
||||||
|
|
||||||
|
table ntfy {
|
||||||
|
ntfyId text [pk, not null]
|
||||||
|
serverUrl text [not null]
|
||||||
|
topic text [not null]
|
||||||
|
accessToken text [not null]
|
||||||
|
priority integer [not null, default: 3]
|
||||||
}
|
}
|
||||||
|
|
||||||
table organization {
|
table organization {
|
||||||
@@ -555,6 +702,7 @@ table organization {
|
|||||||
table port {
|
table port {
|
||||||
portId text [pk, not null]
|
portId text [pk, not null]
|
||||||
publishedPort integer [not null]
|
publishedPort integer [not null]
|
||||||
|
publishMode publishModeType [not null, default: 'host']
|
||||||
targetPort integer [not null]
|
targetPort integer [not null]
|
||||||
protocol protocolType [not null]
|
protocol protocolType [not null]
|
||||||
applicationId text [not null]
|
applicationId text [not null]
|
||||||
@@ -577,8 +725,17 @@ table postgres {
|
|||||||
cpuReservation text
|
cpuReservation text
|
||||||
cpuLimit text
|
cpuLimit text
|
||||||
applicationStatus applicationStatus [not null, default: 'idle']
|
applicationStatus applicationStatus [not null, default: 'idle']
|
||||||
|
healthCheckSwarm json
|
||||||
|
restartPolicySwarm json
|
||||||
|
placementSwarm json
|
||||||
|
updateConfigSwarm json
|
||||||
|
rollbackConfigSwarm json
|
||||||
|
modeSwarm json
|
||||||
|
labelsSwarm json
|
||||||
|
networkSwarm json
|
||||||
|
replicas integer [not null, default: 1]
|
||||||
createdAt text [not null]
|
createdAt text [not null]
|
||||||
projectId text [not null]
|
environmentId text [not null]
|
||||||
serverId text
|
serverId text
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,7 +760,7 @@ table project {
|
|||||||
name text [not null]
|
name text [not null]
|
||||||
description text
|
description text
|
||||||
createdAt text [not null]
|
createdAt text [not null]
|
||||||
userId text [not null]
|
organizationId text [not null]
|
||||||
env text [not null, default: '']
|
env text [not null, default: '']
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,7 +790,16 @@ table redis {
|
|||||||
externalPort integer
|
externalPort integer
|
||||||
createdAt text [not null]
|
createdAt text [not null]
|
||||||
applicationStatus applicationStatus [not null, default: 'idle']
|
applicationStatus applicationStatus [not null, default: 'idle']
|
||||||
projectId text [not null]
|
healthCheckSwarm json
|
||||||
|
restartPolicySwarm json
|
||||||
|
placementSwarm json
|
||||||
|
updateConfigSwarm json
|
||||||
|
rollbackConfigSwarm json
|
||||||
|
modeSwarm json
|
||||||
|
labelsSwarm json
|
||||||
|
networkSwarm json
|
||||||
|
replicas integer [not null, default: 1]
|
||||||
|
environmentId text [not null]
|
||||||
serverId text
|
serverId text
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,7 +812,34 @@ table registry {
|
|||||||
registryUrl text [not null, default: '']
|
registryUrl text [not null, default: '']
|
||||||
createdAt text [not null]
|
createdAt text [not null]
|
||||||
selfHosted RegistryType [not null, default: 'cloud']
|
selfHosted RegistryType [not null, default: 'cloud']
|
||||||
userId text [not null]
|
organizationId text [not null]
|
||||||
|
}
|
||||||
|
|
||||||
|
table rollback {
|
||||||
|
rollbackId text [pk, not null]
|
||||||
|
deploymentId text [not null]
|
||||||
|
version serial [not null, increment]
|
||||||
|
image text
|
||||||
|
createdAt text [not null]
|
||||||
|
fullContext jsonb
|
||||||
|
}
|
||||||
|
|
||||||
|
table schedule {
|
||||||
|
scheduleId text [pk, not null]
|
||||||
|
name text [not null]
|
||||||
|
cronExpression text [not null]
|
||||||
|
appName text [not null]
|
||||||
|
serviceName text
|
||||||
|
shellType shellType [not null, default: 'bash']
|
||||||
|
scheduleType scheduleType [not null, default: 'application']
|
||||||
|
command text [not null]
|
||||||
|
script text
|
||||||
|
applicationId text
|
||||||
|
composeId text
|
||||||
|
serverId text
|
||||||
|
userId text
|
||||||
|
enabled boolean [not null, default: true]
|
||||||
|
createdAt text [not null]
|
||||||
}
|
}
|
||||||
|
|
||||||
table security {
|
table security {
|
||||||
@@ -671,14 +864,14 @@ table server {
|
|||||||
appName text [not null]
|
appName text [not null]
|
||||||
enableDockerCleanup boolean [not null, default: false]
|
enableDockerCleanup boolean [not null, default: false]
|
||||||
createdAt text [not null]
|
createdAt text [not null]
|
||||||
userId text [not null]
|
organizationId text [not null]
|
||||||
serverStatus serverStatus [not null, default: 'active']
|
serverStatus serverStatus [not null, default: 'active']
|
||||||
command text [not null, default: '']
|
command text [not null, default: '']
|
||||||
sshKeyId text
|
sshKeyId text
|
||||||
metricsConfig jsonb [not null, default: `{"server":{"type":"Remote","refreshRate":60,"port":4500,"token":"","urlCallback":"","cronJob":"","retentionDays":2,"thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}`]
|
metricsConfig jsonb [not null, default: `{"server":{"type":"Remote","refreshRate":60,"port":4500,"token":"","urlCallback":"","cronJob":"","retentionDays":2,"thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}`]
|
||||||
}
|
}
|
||||||
|
|
||||||
table session {
|
table session_temp {
|
||||||
id text [pk, not null]
|
id text [pk, not null]
|
||||||
expires_at timestamp [not null]
|
expires_at timestamp [not null]
|
||||||
token text [not null, unique]
|
token text [not null, unique]
|
||||||
@@ -705,49 +898,49 @@ table "ssh-key" {
|
|||||||
description text
|
description text
|
||||||
createdAt text [not null]
|
createdAt text [not null]
|
||||||
lastUsedAt text
|
lastUsedAt text
|
||||||
userId text
|
organizationId text [not null]
|
||||||
}
|
}
|
||||||
|
|
||||||
table telegram {
|
table telegram {
|
||||||
telegramId text [pk, not null]
|
telegramId text [pk, not null]
|
||||||
botToken text [not null]
|
botToken text [not null]
|
||||||
chatId text [not null]
|
chatId text [not null]
|
||||||
|
messageThreadId text
|
||||||
}
|
}
|
||||||
|
|
||||||
table user {
|
table two_factor {
|
||||||
|
id text [pk, not null]
|
||||||
|
secret text [not null]
|
||||||
|
backup_codes text [not null]
|
||||||
|
user_id text [not null]
|
||||||
|
}
|
||||||
|
|
||||||
|
table user_temp {
|
||||||
id text [pk, not null]
|
id text [pk, not null]
|
||||||
name text [not null, default: '']
|
name text [not null, default: '']
|
||||||
token text [not null]
|
|
||||||
isRegistered boolean [not null, default: false]
|
isRegistered boolean [not null, default: false]
|
||||||
expirationDate text [not null]
|
expirationDate text [not null]
|
||||||
createdAt text [not null]
|
createdAt text [not null]
|
||||||
canCreateProjects boolean [not null, default: false]
|
created_at timestamp [default: `now()`]
|
||||||
canAccessToSSHKeys boolean [not null, default: false]
|
two_factor_enabled boolean
|
||||||
canCreateServices boolean [not null, default: false]
|
|
||||||
canDeleteProjects boolean [not null, default: false]
|
|
||||||
canDeleteServices boolean [not null, default: false]
|
|
||||||
canAccessToDocker boolean [not null, default: false]
|
|
||||||
canAccessToAPI boolean [not null, default: false]
|
|
||||||
canAccessToGitProviders boolean [not null, default: false]
|
|
||||||
canAccessToTraefikFiles boolean [not null, default: false]
|
|
||||||
accesedProjects text[] [not null, default: `ARRAY[]::text[]`]
|
|
||||||
accesedServices text[] [not null, default: `ARRAY[]::text[]`]
|
|
||||||
email text [not null, unique]
|
email text [not null, unique]
|
||||||
email_verified boolean [not null]
|
email_verified boolean [not null]
|
||||||
image text
|
image text
|
||||||
role text
|
|
||||||
banned boolean
|
banned boolean
|
||||||
ban_reason text
|
ban_reason text
|
||||||
ban_expires timestamp
|
ban_expires timestamp
|
||||||
updated_at timestamp [not null]
|
updated_at timestamp [not null]
|
||||||
serverIp text
|
serverIp text
|
||||||
certificateType certificateType [not null, default: 'none']
|
certificateType certificateType [not null, default: 'none']
|
||||||
|
https boolean [not null, default: false]
|
||||||
host text
|
host text
|
||||||
letsEncryptEmail text
|
letsEncryptEmail text
|
||||||
sshPrivateKey text
|
sshPrivateKey text
|
||||||
enableDockerCleanup boolean [not null, default: false]
|
enableDockerCleanup boolean [not null, default: false]
|
||||||
enableLogRotation boolean [not null, default: false]
|
logCleanupCron text [default: '0 0 * * *']
|
||||||
|
role text [not null, default: 'user']
|
||||||
enablePaidFeatures boolean [not null, default: false]
|
enablePaidFeatures boolean [not null, default: false]
|
||||||
|
allowImpersonation boolean [not null, default: false]
|
||||||
metricsConfig jsonb [not null, default: `{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}`]
|
metricsConfig jsonb [not null, default: `{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}`]
|
||||||
cleanupCacheApplications boolean [not null, default: false]
|
cleanupCacheApplications boolean [not null, default: false]
|
||||||
cleanupCacheOnPreviews boolean [not null, default: false]
|
cleanupCacheOnPreviews boolean [not null, default: false]
|
||||||
@@ -766,6 +959,29 @@ table verification {
|
|||||||
updated_at timestamp
|
updated_at timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table volume_backup {
|
||||||
|
volumeBackupId text [pk, not null]
|
||||||
|
name text [not null]
|
||||||
|
volumeName text [not null]
|
||||||
|
prefix text [not null]
|
||||||
|
serviceType serviceType [not null, default: 'application']
|
||||||
|
appName text [not null]
|
||||||
|
serviceName text
|
||||||
|
turnOff boolean [not null, default: false]
|
||||||
|
cronExpression text [not null]
|
||||||
|
keepLatestCount integer
|
||||||
|
enabled boolean
|
||||||
|
applicationId text
|
||||||
|
postgresId text
|
||||||
|
mariadbId text
|
||||||
|
mongoId text
|
||||||
|
mysqlId text
|
||||||
|
redisId text
|
||||||
|
composeId text
|
||||||
|
createdAt text [not null]
|
||||||
|
destinationId text [not null]
|
||||||
|
}
|
||||||
|
|
||||||
ref: mount.applicationId > application.applicationId
|
ref: mount.applicationId > application.applicationId
|
||||||
|
|
||||||
ref: mount.postgresId > postgres.postgresId
|
ref: mount.postgresId > postgres.postgresId
|
||||||
@@ -780,7 +996,13 @@ ref: mount.redisId > redis.redisId
|
|||||||
|
|
||||||
ref: mount.composeId > compose.composeId
|
ref: mount.composeId > compose.composeId
|
||||||
|
|
||||||
ref: application.projectId > project.projectId
|
ref: user_temp.id - account.user_id
|
||||||
|
|
||||||
|
ref: ai.organizationId - organization.id
|
||||||
|
|
||||||
|
ref: apikey.user_id > user_temp.id
|
||||||
|
|
||||||
|
ref: application.environmentId > environment.environmentId
|
||||||
|
|
||||||
ref: application.customGitSSHKeyId > "ssh-key".sshKeyId
|
ref: application.customGitSSHKeyId > "ssh-key".sshKeyId
|
||||||
|
|
||||||
@@ -790,6 +1012,8 @@ ref: application.githubId - github.githubId
|
|||||||
|
|
||||||
ref: application.gitlabId - gitlab.gitlabId
|
ref: application.gitlabId - gitlab.gitlabId
|
||||||
|
|
||||||
|
ref: application.giteaId - gitea.giteaId
|
||||||
|
|
||||||
ref: application.bitbucketId - bitbucket.bitbucketId
|
ref: application.bitbucketId - bitbucket.bitbucketId
|
||||||
|
|
||||||
ref: application.serverId > server.serverId
|
ref: application.serverId > server.serverId
|
||||||
@@ -804,13 +1028,17 @@ ref: backup.mysqlId > mysql.mysqlId
|
|||||||
|
|
||||||
ref: backup.mongoId > mongo.mongoId
|
ref: backup.mongoId > mongo.mongoId
|
||||||
|
|
||||||
|
ref: backup.userId > user_temp.id
|
||||||
|
|
||||||
|
ref: backup.composeId > compose.composeId
|
||||||
|
|
||||||
ref: git_provider.gitProviderId - bitbucket.gitProviderId
|
ref: git_provider.gitProviderId - bitbucket.gitProviderId
|
||||||
|
|
||||||
ref: certificate.serverId > server.serverId
|
ref: certificate.serverId > server.serverId
|
||||||
|
|
||||||
ref: certificate.userId - user.id
|
ref: certificate.organizationId - organization.id
|
||||||
|
|
||||||
ref: compose.projectId > project.projectId
|
ref: compose.environmentId > environment.environmentId
|
||||||
|
|
||||||
ref: compose.customGitSSHKeyId > "ssh-key".sshKeyId
|
ref: compose.customGitSSHKeyId > "ssh-key".sshKeyId
|
||||||
|
|
||||||
@@ -820,6 +1048,8 @@ ref: compose.gitlabId - gitlab.gitlabId
|
|||||||
|
|
||||||
ref: compose.bitbucketId - bitbucket.bitbucketId
|
ref: compose.bitbucketId - bitbucket.bitbucketId
|
||||||
|
|
||||||
|
ref: compose.giteaId - gitea.giteaId
|
||||||
|
|
||||||
ref: compose.serverId > server.serverId
|
ref: compose.serverId > server.serverId
|
||||||
|
|
||||||
ref: deployment.applicationId > application.applicationId
|
ref: deployment.applicationId > application.applicationId
|
||||||
@@ -830,7 +1060,15 @@ ref: deployment.serverId > server.serverId
|
|||||||
|
|
||||||
ref: deployment.previewDeploymentId > preview_deployments.previewDeploymentId
|
ref: deployment.previewDeploymentId > preview_deployments.previewDeploymentId
|
||||||
|
|
||||||
ref: destination.userId - user.id
|
ref: deployment.scheduleId > schedule.scheduleId
|
||||||
|
|
||||||
|
ref: deployment.backupId > backup.backupId
|
||||||
|
|
||||||
|
ref: rollback.deploymentId - deployment.deploymentId
|
||||||
|
|
||||||
|
ref: deployment.volumeBackupId > volume_backup.volumeBackupId
|
||||||
|
|
||||||
|
ref: destination.organizationId - organization.id
|
||||||
|
|
||||||
ref: domain.applicationId > application.applicationId
|
ref: domain.applicationId > application.applicationId
|
||||||
|
|
||||||
@@ -838,23 +1076,33 @@ ref: domain.composeId > compose.composeId
|
|||||||
|
|
||||||
ref: preview_deployments.domainId - domain.domainId
|
ref: preview_deployments.domainId - domain.domainId
|
||||||
|
|
||||||
|
ref: environment.projectId > project.projectId
|
||||||
|
|
||||||
ref: github.gitProviderId - git_provider.gitProviderId
|
ref: github.gitProviderId - git_provider.gitProviderId
|
||||||
|
|
||||||
ref: gitlab.gitProviderId - git_provider.gitProviderId
|
ref: gitlab.gitProviderId - git_provider.gitProviderId
|
||||||
|
|
||||||
ref: gitea.gitProviderId - git_provider.gitProviderId
|
ref: gitea.gitProviderId - git_provider.gitProviderId
|
||||||
|
|
||||||
ref: git_provider.userId - user.id
|
ref: git_provider.organizationId - organization.id
|
||||||
|
|
||||||
ref: mariadb.projectId > project.projectId
|
ref: git_provider.userId - user_temp.id
|
||||||
|
|
||||||
|
ref: invitation.organization_id - organization.id
|
||||||
|
|
||||||
|
ref: mariadb.environmentId > environment.environmentId
|
||||||
|
|
||||||
ref: mariadb.serverId > server.serverId
|
ref: mariadb.serverId > server.serverId
|
||||||
|
|
||||||
ref: mongo.projectId > project.projectId
|
ref: member.organization_id > organization.id
|
||||||
|
|
||||||
|
ref: member.user_id - user_temp.id
|
||||||
|
|
||||||
|
ref: mongo.environmentId > environment.environmentId
|
||||||
|
|
||||||
ref: mongo.serverId > server.serverId
|
ref: mongo.serverId > server.serverId
|
||||||
|
|
||||||
ref: mysql.projectId > project.projectId
|
ref: mysql.environmentId > environment.environmentId
|
||||||
|
|
||||||
ref: mysql.serverId > server.serverId
|
ref: mysql.serverId > server.serverId
|
||||||
|
|
||||||
@@ -868,30 +1116,58 @@ ref: notification.emailId - email.emailId
|
|||||||
|
|
||||||
ref: notification.gotifyId - gotify.gotifyId
|
ref: notification.gotifyId - gotify.gotifyId
|
||||||
|
|
||||||
ref: notification.userId - user.id
|
ref: notification.ntfyId - ntfy.ntfyId
|
||||||
|
|
||||||
|
ref: notification.customId - custom.customId
|
||||||
|
|
||||||
|
ref: notification.organizationId - organization.id
|
||||||
|
|
||||||
|
ref: organization.owner_id > user_temp.id
|
||||||
|
|
||||||
ref: port.applicationId > application.applicationId
|
ref: port.applicationId > application.applicationId
|
||||||
|
|
||||||
ref: postgres.projectId > project.projectId
|
ref: postgres.environmentId > environment.environmentId
|
||||||
|
|
||||||
ref: postgres.serverId > server.serverId
|
ref: postgres.serverId > server.serverId
|
||||||
|
|
||||||
ref: preview_deployments.applicationId > application.applicationId
|
ref: preview_deployments.applicationId > application.applicationId
|
||||||
|
|
||||||
ref: project.userId - user.id
|
ref: project.organizationId > organization.id
|
||||||
|
|
||||||
ref: redirect.applicationId > application.applicationId
|
ref: redirect.applicationId > application.applicationId
|
||||||
|
|
||||||
ref: redis.projectId > project.projectId
|
ref: redis.environmentId > environment.environmentId
|
||||||
|
|
||||||
ref: redis.serverId > server.serverId
|
ref: redis.serverId > server.serverId
|
||||||
|
|
||||||
ref: registry.userId - user.id
|
ref: schedule.applicationId - application.applicationId
|
||||||
|
|
||||||
|
ref: schedule.composeId > compose.composeId
|
||||||
|
|
||||||
|
ref: schedule.serverId > server.serverId
|
||||||
|
|
||||||
|
ref: schedule.userId > user_temp.id
|
||||||
|
|
||||||
ref: security.applicationId > application.applicationId
|
ref: security.applicationId > application.applicationId
|
||||||
|
|
||||||
ref: server.userId - user.id
|
|
||||||
|
|
||||||
ref: server.sshKeyId > "ssh-key".sshKeyId
|
ref: server.sshKeyId > "ssh-key".sshKeyId
|
||||||
|
|
||||||
ref: "ssh-key".userId - user.id
|
ref: server.organizationId > organization.id
|
||||||
|
|
||||||
|
ref: "ssh-key".organizationId - organization.id
|
||||||
|
|
||||||
|
ref: volume_backup.applicationId - application.applicationId
|
||||||
|
|
||||||
|
ref: volume_backup.postgresId - postgres.postgresId
|
||||||
|
|
||||||
|
ref: volume_backup.mariadbId - mariadb.mariadbId
|
||||||
|
|
||||||
|
ref: volume_backup.mongoId - mongo.mongoId
|
||||||
|
|
||||||
|
ref: volume_backup.mysqlId - mysql.mysqlId
|
||||||
|
|
||||||
|
ref: volume_backup.redisId - redis.redisId
|
||||||
|
|
||||||
|
ref: volume_backup.composeId - compose.composeId
|
||||||
|
|
||||||
|
ref: volume_backup.destinationId - destination.destinationId
|
||||||
@@ -31,7 +31,8 @@ export const user = pgTable("user", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => nanoid()),
|
.$defaultFn(() => nanoid()),
|
||||||
name: text("name").notNull().default(""),
|
firstName: text("firstName").notNull().default(""),
|
||||||
|
lastName: text("lastName").notNull().default(""),
|
||||||
isRegistered: boolean("isRegistered").notNull().default(false),
|
isRegistered: boolean("isRegistered").notNull().default(false),
|
||||||
expirationDate: text("expirationDate")
|
expirationDate: text("expirationDate")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -56,7 +57,7 @@ export const user = pgTable("user", {
|
|||||||
host: text("host"),
|
host: text("host"),
|
||||||
letsEncryptEmail: text("letsEncryptEmail"),
|
letsEncryptEmail: text("letsEncryptEmail"),
|
||||||
sshPrivateKey: text("sshPrivateKey"),
|
sshPrivateKey: text("sshPrivateKey"),
|
||||||
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
|
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
|
||||||
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
|
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
|
||||||
role: text("role").notNull().default("user"),
|
role: text("role").notNull().default("user"),
|
||||||
// Metrics
|
// Metrics
|
||||||
@@ -332,6 +333,7 @@ export const apiUpdateUser = createSchema.partial().extend({
|
|||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
currentPassword: z.string().optional(),
|
currentPassword: z.string().optional(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
lastName: z.string().optional(),
|
||||||
metricsConfig: z
|
metricsConfig: z
|
||||||
.object({
|
.object({
|
||||||
server: z.object({
|
server: z.object({
|
||||||
|
|||||||
123
packages/server/src/emails/emails/volume-backup.tsx
Normal file
123
packages/server/src/emails/emails/volume-backup.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Tailwind,
|
||||||
|
Text,
|
||||||
|
} from "@react-email/components";
|
||||||
|
|
||||||
|
export type TemplateProps = {
|
||||||
|
projectName: string;
|
||||||
|
applicationName: string;
|
||||||
|
volumeName: string;
|
||||||
|
serviceType:
|
||||||
|
| "application"
|
||||||
|
| "postgres"
|
||||||
|
| "mysql"
|
||||||
|
| "mongodb"
|
||||||
|
| "mariadb"
|
||||||
|
| "redis"
|
||||||
|
| "compose";
|
||||||
|
type: "error" | "success";
|
||||||
|
errorMessage?: string;
|
||||||
|
backupSize?: string;
|
||||||
|
date: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VolumeBackupEmail = ({
|
||||||
|
projectName = "dokploy",
|
||||||
|
applicationName = "frontend",
|
||||||
|
volumeName = "app-data",
|
||||||
|
serviceType = "application",
|
||||||
|
type = "success",
|
||||||
|
errorMessage,
|
||||||
|
backupSize,
|
||||||
|
date = "2023-05-01T00:00:00.000Z",
|
||||||
|
}: TemplateProps) => {
|
||||||
|
const previewText = `Volume backup for ${applicationName} was ${type === "success" ? "successful ✅" : "failed ❌"}`;
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Tailwind
|
||||||
|
config={{
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: "#007291",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Head />
|
||||||
|
|
||||||
|
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||||
|
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||||
|
<Section className="mt-[32px]">
|
||||||
|
<Img
|
||||||
|
src={
|
||||||
|
"https://raw.githubusercontent.com/Dokploy/dokploy/refs/heads/canary/apps/dokploy/logo.png"
|
||||||
|
}
|
||||||
|
width="100"
|
||||||
|
height="50"
|
||||||
|
alt="Dokploy"
|
||||||
|
className="my-0 mx-auto"
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
|
||||||
|
Volume backup for <strong>{applicationName}</strong>
|
||||||
|
</Heading>
|
||||||
|
<Text className="text-black text-[14px] leading-[24px]">
|
||||||
|
Hello,
|
||||||
|
</Text>
|
||||||
|
<Text className="text-black text-[14px] leading-[24px]">
|
||||||
|
Your volume backup for <strong>{applicationName}</strong> was{" "}
|
||||||
|
{type === "success"
|
||||||
|
? "successful ✅"
|
||||||
|
: "failed. Please check the error message below. ❌"}
|
||||||
|
.
|
||||||
|
</Text>
|
||||||
|
<Section className="flex text-black text-[14px] leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
|
||||||
|
<Text className="!leading-3 font-bold">Details: </Text>
|
||||||
|
<Text className="!leading-3">
|
||||||
|
Project Name: <strong>{projectName}</strong>
|
||||||
|
</Text>
|
||||||
|
<Text className="!leading-3">
|
||||||
|
Application Name: <strong>{applicationName}</strong>
|
||||||
|
</Text>
|
||||||
|
<Text className="!leading-3">
|
||||||
|
Volume Name: <strong>{volumeName}</strong>
|
||||||
|
</Text>
|
||||||
|
<Text className="!leading-3">
|
||||||
|
Service Type: <strong>{serviceType}</strong>
|
||||||
|
</Text>
|
||||||
|
{backupSize && (
|
||||||
|
<Text className="!leading-3">
|
||||||
|
Backup Size: <strong>{backupSize}</strong>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text className="!leading-3">
|
||||||
|
Date: <strong>{date}</strong>
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
{type === "error" && errorMessage ? (
|
||||||
|
<Section className="flex text-black text-[14px] mt-4 leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
|
||||||
|
<Text className="!leading-3 font-bold">Reason: </Text>
|
||||||
|
<Text className="text-[12px] leading-[24px]">
|
||||||
|
{errorMessage || "Error message not provided"}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Tailwind>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VolumeBackupEmail;
|
||||||
@@ -132,11 +132,15 @@ const { handler, api } = betterAuth({
|
|||||||
const hutk = getHubSpotUTK(
|
const hutk = getHubSpotUTK(
|
||||||
context?.request?.headers?.get("cookie") || undefined,
|
context?.request?.headers?.get("cookie") || undefined,
|
||||||
);
|
);
|
||||||
|
// Cast to include additional fields
|
||||||
|
const userWithFields = user as typeof user & {
|
||||||
|
lastName?: string;
|
||||||
|
};
|
||||||
const hubspotSuccess = await submitToHubSpot(
|
const hubspotSuccess = await submitToHubSpot(
|
||||||
{
|
{
|
||||||
email: user.email,
|
email: user.email,
|
||||||
firstName: user.name,
|
firstName: user.name || "", // name is mapped to firstName column
|
||||||
lastName: user.name,
|
lastName: userWithFields.lastName || "",
|
||||||
},
|
},
|
||||||
hutk,
|
hutk,
|
||||||
);
|
);
|
||||||
@@ -204,6 +208,9 @@ const { handler, api } = betterAuth({
|
|||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
modelName: "user",
|
modelName: "user",
|
||||||
|
fields: {
|
||||||
|
name: "firstName", // Map better-auth's default 'name' field to 'firstName' column
|
||||||
|
},
|
||||||
additionalFields: {
|
additionalFields: {
|
||||||
role: {
|
role: {
|
||||||
type: "string",
|
type: "string",
|
||||||
@@ -220,6 +227,12 @@ const { handler, api } = betterAuth({
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
},
|
},
|
||||||
|
lastName: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
input: true,
|
||||||
|
defaultValue: "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -316,16 +329,11 @@ export const validateRequest = async (request: IncomingMessage) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
// When accessing from DB, use actual column names
|
||||||
id,
|
const userFromDb = apiKeyRecord.user as typeof apiKeyRecord.user & {
|
||||||
name,
|
firstName: string;
|
||||||
email,
|
lastName: string;
|
||||||
emailVerified,
|
};
|
||||||
image,
|
|
||||||
createdAt,
|
|
||||||
updatedAt,
|
|
||||||
twoFactorEnabled,
|
|
||||||
} = apiKeyRecord.user;
|
|
||||||
|
|
||||||
const mockSession = {
|
const mockSession = {
|
||||||
session: {
|
session: {
|
||||||
@@ -333,14 +341,14 @@ export const validateRequest = async (request: IncomingMessage) => {
|
|||||||
activeOrganizationId: organizationId || "",
|
activeOrganizationId: organizationId || "",
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
id,
|
id: userFromDb.id,
|
||||||
name,
|
name: userFromDb.firstName, // Map firstName back to name for better-auth
|
||||||
email,
|
email: userFromDb.email,
|
||||||
emailVerified,
|
emailVerified: userFromDb.emailVerified,
|
||||||
image,
|
image: userFromDb.image,
|
||||||
createdAt,
|
createdAt: userFromDb.createdAt,
|
||||||
updatedAt,
|
updatedAt: userFromDb.updatedAt,
|
||||||
twoFactorEnabled,
|
twoFactorEnabled: userFromDb.twoFactorEnabled,
|
||||||
role: member?.role || "member",
|
role: member?.role || "member",
|
||||||
ownerId: member?.organization.ownerId || apiKeyRecord.user.id,
|
ownerId: member?.organization.ownerId || apiKeyRecord.user.id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const isAdminPresent = async () => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const findAdmin = async () => {
|
export const findOwner = async () => {
|
||||||
const admin = await db.query.member.findFirst({
|
const admin = await db.query.member.findFirst({
|
||||||
where: eq(member.role, "owner"),
|
where: eq(member.role, "owner"),
|
||||||
with: {
|
with: {
|
||||||
@@ -107,11 +107,11 @@ export const getDokployUrl = async () => {
|
|||||||
if (IS_CLOUD) {
|
if (IS_CLOUD) {
|
||||||
return "https://app.dokploy.com";
|
return "https://app.dokploy.com";
|
||||||
}
|
}
|
||||||
const admin = await findAdmin();
|
const owner = await findOwner();
|
||||||
|
|
||||||
if (admin.user.host) {
|
if (owner.user.host) {
|
||||||
const protocol = admin.user.https ? "https" : "http";
|
const protocol = owner.user.https ? "https" : "http";
|
||||||
return `${protocol}://${admin.user.host}`;
|
return `${protocol}://${owner.user.host}`;
|
||||||
}
|
}
|
||||||
return `http://${admin.user.serverIp}:${process.env.PORT}`;
|
return `http://${owner.user.serverIp}:${process.env.PORT}`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -104,14 +104,28 @@ export const suggestVariants = async ({
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
prompt: `
|
prompt: `
|
||||||
Act as advanced DevOps engineer and generate a list of open source projects what can cover users needs(up to 3 items).
|
Act as advanced DevOps engineer and analyze the user's request to determine the appropriate suggestions (up to 3 items).
|
||||||
|
|
||||||
|
CRITICAL - Read the user's request carefully and follow the appropriate strategy:
|
||||||
|
|
||||||
|
Strategy A - If the user specifies a PARTICULAR APPLICATION/SERVICE (e.g., "deploy Chatwoot", "install sendingtk/chatwoot:develop", "setup Bitwarden"):
|
||||||
|
- Generate different deployment VARIANTS of that SAME application
|
||||||
|
- Each variant should be a different configuration (minimal, full stack, with different databases, development vs production, etc.)
|
||||||
|
- Example: For "Chatwoot" → "Chatwoot with PostgreSQL", "Chatwoot Development", "Chatwoot Full Stack"
|
||||||
|
- The name MUST include the specific application name the user mentioned
|
||||||
|
|
||||||
|
Strategy B - If the user describes a GENERAL NEED or USE CASE (e.g., "personal blog", "project management tool", "chat application"):
|
||||||
|
- Suggest different open source projects that fulfill that need
|
||||||
|
- Each suggestion should be a different tool/platform that solves the same problem
|
||||||
|
- Example: For "personal blog" → "WordPress", "Ghost", "Hugo with Nginx"
|
||||||
|
- The name should be the actual project name
|
||||||
|
|
||||||
Return your response as a JSON object with the following structure:
|
Return your response as a JSON object with the following structure:
|
||||||
{
|
{
|
||||||
"suggestions": [
|
"suggestions": [
|
||||||
{
|
{
|
||||||
"id": "project-slug",
|
"id": "project-or-variant-slug",
|
||||||
"name": "Project Name",
|
"name": "Project Name or Variant Name",
|
||||||
"shortDescription": "Brief one-line description",
|
"shortDescription": "Brief one-line description",
|
||||||
"description": "Detailed description"
|
"description": "Detailed description"
|
||||||
}
|
}
|
||||||
@@ -120,10 +134,14 @@ export const suggestVariants = async ({
|
|||||||
|
|
||||||
Important rules for the response:
|
Important rules for the response:
|
||||||
1. Use slug format for the id field (lowercase, hyphenated)
|
1. Use slug format for the id field (lowercase, hyphenated)
|
||||||
2. The description field should ONLY contain a plain text description of the project, its features, and use cases
|
2. Determine which strategy to use based on whether the user specified a particular application or described a general need
|
||||||
3. Do NOT include any code snippets, configuration examples, or installation instructions in the description
|
3. For Strategy A (specific app): The name must include the app name and describe the variant configuration
|
||||||
4. The shortDescription should be a single-line summary focusing on the main technologies
|
4. For Strategy B (general need): The name should be the actual project/tool name that fulfills the need
|
||||||
5. All projects should be installable in docker and have docker compose support
|
5. The description field should ONLY contain a plain text description of the project or variant, its features, and use cases
|
||||||
|
6. Do NOT include any code snippets, configuration examples, or installation instructions in the description
|
||||||
|
7. The shortDescription should be a single-line summary focusing on key technologies or differentiators
|
||||||
|
8. All suggestions should be installable in docker and have docker compose support
|
||||||
|
9. Provide variety in your suggestions - different complexity levels, tech stacks, or approaches
|
||||||
|
|
||||||
User wants to create a new project with the following details:
|
User wants to create a new project with the following details:
|
||||||
|
|
||||||
@@ -186,6 +204,24 @@ export const suggestVariants = async ({
|
|||||||
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
|
6. If a service depends on a database or other service, INCLUDE that service in the docker-compose
|
||||||
7. Make sure all required services are defined in the docker-compose
|
7. Make sure all required services are defined in the docker-compose
|
||||||
|
|
||||||
|
Docker Image Rules (CRITICAL):
|
||||||
|
1. ALWAYS use 'image:' field, NEVER use 'build:' field
|
||||||
|
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
|
||||||
|
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
|
||||||
|
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
|
||||||
|
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
|
||||||
|
6. Examples of correct image usage:
|
||||||
|
- image: sendingtk/chatwoot:develop
|
||||||
|
- image: postgres:16-alpine
|
||||||
|
- image: redis:7-alpine
|
||||||
|
- image: chatwoot/chatwoot:latest
|
||||||
|
7. Examples of INCORRECT usage (DO NOT USE):
|
||||||
|
- build: .
|
||||||
|
- build: ./app
|
||||||
|
- build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
|
||||||
Volume Mounting and Configuration Rules:
|
Volume Mounting and Configuration Rules:
|
||||||
1. DO NOT create configuration files unless the service CANNOT work without them
|
1. DO NOT create configuration files unless the service CANNOT work without them
|
||||||
2. Most services can work with just environment variables - USE THEM FIRST
|
2. Most services can work with just environment variables - USE THEM FIRST
|
||||||
@@ -214,6 +250,8 @@ export const suggestVariants = async ({
|
|||||||
- serviceName: the name of the service in the docker-compose
|
- serviceName: the name of the service in the docker-compose
|
||||||
2. Make sure the service is properly configured to work with the specified port
|
2. Make sure the service is properly configured to work with the specified port
|
||||||
|
|
||||||
|
User's original request: ${input}
|
||||||
|
|
||||||
Project details:
|
Project details:
|
||||||
${suggestion?.description}
|
${suggestion?.description}
|
||||||
`,
|
`,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user