mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 21:31:11 +00:00
Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 909ee9a387 | |||
| ded23bb05f | |||
| 717792f186 | |||
| c0265b49b4 | |||
| b53fface69 | |||
| ff275fe94e | |||
| ca578ed075 | |||
| a691b469a1 | |||
| 6d8aa9d8da | |||
| 3b8611771c | |||
| 2cb499fb64 | |||
| e541308a4f | |||
| b976c7b4f7 | |||
| 7f3a85ac38 | |||
| ac9ad72875 | |||
| d8b7c1ea5e | |||
| d542967ae4 | |||
| aedb35b8df | |||
| 8bc1bd36d8 | |||
| 1b9504897a | |||
| 7b3c5355fa | |||
| 1828e7c276 | |||
| d770e3ce7d | |||
| d583ba8bd3 | |||
| b8e8704493 | |||
| 518ee44ea6 | |||
| f62e41ede6 | |||
| 046a608f8c | |||
| 171c70492d | |||
| a4425cfb1d | |||
| 8e18a8a683 | |||
| fc5b8d03e5 | |||
| 1183c7c4a9 | |||
| dec05aca22 | |||
| 95b1afc2c2 | |||
| 52aca4636d | |||
| c741ff36c0 | |||
| 795065810d | |||
| 4bf6dfff13 | |||
| 59ebf809aa | |||
| a1a672b672 | |||
| 92a1f0d0de | |||
| c61163d721 | |||
| a2329aed26 | |||
| f6c1e77279 | |||
| dbe2b38ea9 | |||
| eff6a1865e | |||
| 90e96e1806 | |||
| dc9c67129e | |||
| 561fb43d42 | |||
| 795f6f094e | |||
| 26c871946a | |||
| 65198f6082 | |||
| 07f95a034f | |||
| 2bda2ccc04 | |||
| 1c9a538457 | |||
| 4e18a64da2 | |||
| 3e10a8dd6d | |||
| 63edae4740 | |||
| c2071bbbfd | |||
| 1325a0fccc | |||
| 9412b52172 | |||
| 7825537964 | |||
| 3be9cdfe65 | |||
| fa7a54b4eb | |||
| daba2d7fbc | |||
| b07d7f2005 | |||
| 02edd6abe7 | |||
| a60b0af419 | |||
| 9687224152 | |||
| 86bce3335f | |||
| 37f0bba43f | |||
| 16f0f0eeb4 | |||
| 4d58f3987f | |||
| 3848fa9c0d | |||
| f12ecc3350 | |||
| 3cad115155 | |||
| 34c5b688ac | |||
| 3fbd67fe4d | |||
| 491054f1d9 | |||
| 5492ed12a0 | |||
| cba6ce9c57 | |||
| 8bf1167530 | |||
| 739fd08372 | |||
| 870d592a97 | |||
| 56fdb6151d | |||
| a089870e8b | |||
| 5bcaf9dafb | |||
| f238347ed7 | |||
| 9919e369d1 | |||
| aaf8d7e12f | |||
| 1ccf15f331 | |||
| 4732b38d5a | |||
| c76c5b2f90 | |||
| c6c259f926 | |||
| 1617d07fbd | |||
| e237e34e14 | |||
| 71521f2711 | |||
| 36db6f589a | |||
| 3cefade4a0 | |||
| 5ceb88bebe | |||
| 92b628c8fc | |||
| 790d7c1f36 | |||
| 00d9603209 | |||
| fe85f0f998 | |||
| cf22a0e058 | |||
| c05e6d5a6b | |||
| d39902d575 | |||
| 79b5ff96ca | |||
| 67fa8e4104 | |||
| c085ec3793 | |||
| f14f12c778 | |||
| 37c8d6477e | |||
| 2003886aa0 | |||
| 19958ee9d9 | |||
| 63144e6855 | |||
| c8b87c7105 | |||
| bd92dfb8c1 | |||
| 692c68f013 | |||
| b1795e1d3c | |||
| 79af8da827 | |||
| 6293a39187 | |||
| d3a873a6f5 | |||
| 54272b95b6 | |||
| 6ec530043e | |||
| 8545a1d9e7 | |||
| c9ca719d97 | |||
| b50b1d4c86 | |||
| 98af5a2f07 | |||
| 5ed94954e6 | |||
| 7728e5ea9e | |||
| 7ca96f6087 | |||
| 012d9596b2 | |||
| e2bbbc86d8 | |||
| 0e3c228c4e | |||
| 52eb38fa48 | |||
| ee802cd54c | |||
| 898af1f74c | |||
| e43d99e796 | |||
| e19dd43293 | |||
| 30c408b130 | |||
| 57bb9f27bd | |||
| b9a8064371 | |||
| cee674235f | |||
| 60ad78282e | |||
| 3cb0523681 | |||
| 4032895176 | |||
| bb0c59fc5c |
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup biomeJs
|
||||
uses: biomejs/setup-biome@v2
|
||||
@@ -19,4 +19,4 @@ jobs:
|
||||
- name: Run Biome formatter
|
||||
run: biome format --write
|
||||
|
||||
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 # v1.3.2
|
||||
- uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
|
||||
|
||||
@@ -34,3 +34,16 @@ jobs:
|
||||
fi
|
||||
git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]"
|
||||
git push origin main
|
||||
|
||||
- name: Sync cherry-pick back into canary
|
||||
run: |
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
git fetch origin canary main
|
||||
git checkout -B canary origin/canary
|
||||
if ! git merge origin/main -m "chore: sync hotfix from main into canary [skip ci]"; then
|
||||
git merge --abort
|
||||
echo "::error::Could not auto-sync the hotfix into canary (real conflict, not just history divergence). Merge main into canary manually to avoid a conflict on the next release PR."
|
||||
exit 1
|
||||
fi
|
||||
git push origin canary
|
||||
|
||||
@@ -14,9 +14,9 @@ jobs:
|
||||
matrix:
|
||||
job: [build, test, typecheck]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: pnpm/action-setup@v5
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: "pnpm"
|
||||
|
||||
+4
-1
@@ -31,7 +31,7 @@ WORKDIR /app
|
||||
# Set production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y tini curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy only the necessary files
|
||||
COPY --from=build /prod/dokploy/.next ./.next
|
||||
@@ -69,5 +69,8 @@ EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
|
||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||
|
||||
# tini reaps HEALTHCHECK child processes that Node (as PID 1) leaves defunct.
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
|
||||
# Ejecutar node directamente: pnpm como wrapper queda residente (~100MB RSS)
|
||||
CMD ["sh", "-c", "node -r dotenv/config dist/wait-for-postgres.mjs && node -r dotenv/config dist/migration.mjs && exec node -r dotenv/config dist/server.mjs"]
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"rimraf": "6.1.3",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^7.0.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.22.0",
|
||||
"engines": {
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@dokploy/server/*": ["../../packages/server/src/*"]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
const revokeSessionSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
describe("revokeSession input validation", () => {
|
||||
it("accepts a valid session id", () => {
|
||||
const result = revokeSessionSchema.safeParse({ sessionId: "abc123" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing sessionId", () => {
|
||||
const result = revokeSessionSchema.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-string sessionId", () => {
|
||||
const result = revokeSessionSchema.safeParse({ sessionId: 123 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ const makeDomain = (serviceName: string) =>
|
||||
https: false,
|
||||
uniqueConfigKey: 1,
|
||||
port: 3000,
|
||||
enabled: true,
|
||||
}) as any;
|
||||
|
||||
// If the returned shell fragment is safe, parse() yields only string tokens.
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { Compose } from "@dokploy/server/services/compose";
|
||||
import type { Domain } from "@dokploy/server/services/domain";
|
||||
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// With sourceType "raw", addDomainToCompose parses compose.composeFile
|
||||
// directly instead of reading from disk, so baseCompose exposes it as a
|
||||
// getter that always reflects the current composeYaml for each test case.
|
||||
const baseComposeYaml = `
|
||||
services:
|
||||
frigate:
|
||||
image: frigate
|
||||
`;
|
||||
let composeYaml = baseComposeYaml;
|
||||
|
||||
const baseCompose = {
|
||||
appName: "test-app",
|
||||
composeType: "docker-compose",
|
||||
composePath: "docker-compose.yml",
|
||||
sourceType: "raw",
|
||||
serverId: null,
|
||||
isolatedDeployment: false,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
get composeFile() {
|
||||
return composeYaml;
|
||||
},
|
||||
} as unknown as Compose;
|
||||
|
||||
const baseDomain: Domain = {
|
||||
host: "frigate.example.com",
|
||||
port: 8971,
|
||||
customEntrypoint: null,
|
||||
https: false,
|
||||
uniqueConfigKey: 1,
|
||||
customCertResolver: null,
|
||||
certificateType: "none",
|
||||
applicationId: "",
|
||||
composeId: "compose-id",
|
||||
domainType: "compose",
|
||||
serviceName: "frigate",
|
||||
domainId: "domain-id",
|
||||
path: "/",
|
||||
createdAt: "",
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const serviceLabels = (
|
||||
result: Awaited<ReturnType<typeof addDomainToCompose>>,
|
||||
) => (result?.services?.frigate?.labels as string[] | undefined) ?? [];
|
||||
|
||||
describe("addDomainToCompose enabled filtering", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
composeYaml = baseComposeYaml;
|
||||
});
|
||||
|
||||
it("generates traefik labels for an enabled domain", async () => {
|
||||
const result = await addDomainToCompose(baseCompose, [
|
||||
{ ...baseDomain, enabled: true },
|
||||
]);
|
||||
|
||||
const labels = serviceLabels(result);
|
||||
expect(labels).toContain("traefik.enable=true");
|
||||
expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a disabled domain entirely (no traefik labels)", async () => {
|
||||
const result = await addDomainToCompose(baseCompose, [
|
||||
{ ...baseDomain, enabled: false },
|
||||
]);
|
||||
|
||||
const labels = serviceLabels(result);
|
||||
expect(labels).not.toContain("traefik.enable=true");
|
||||
expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"docker-compose",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
|
||||
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
|
||||
- traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api
|
||||
- custom.label=preserved
|
||||
`,
|
||||
],
|
||||
[
|
||||
"stack",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
deploy:
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
|
||||
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
|
||||
- traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api
|
||||
- custom.label=preserved
|
||||
`,
|
||||
],
|
||||
] as const)(
|
||||
"removes stale labels for a disabled domain from %s rebuilds",
|
||||
async (composeType, staleComposeYaml) => {
|
||||
composeYaml = staleComposeYaml;
|
||||
|
||||
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
|
||||
{ ...baseDomain, enabled: false },
|
||||
]);
|
||||
|
||||
const service = result?.services?.frigate;
|
||||
const labels =
|
||||
composeType === "docker-compose"
|
||||
? service?.labels
|
||||
: service?.deploy?.labels;
|
||||
expect(labels).toContain("custom.label=preserved");
|
||||
expect(
|
||||
(labels as string[]).some((label) => label.includes("test-app-1")),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"docker-compose",
|
||||
`services:
|
||||
legacy:
|
||||
image: frigate
|
||||
labels:
|
||||
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
|
||||
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
|
||||
- custom.label=preserved
|
||||
frigate:
|
||||
image: frigate
|
||||
`,
|
||||
],
|
||||
[
|
||||
"stack",
|
||||
`services:
|
||||
legacy:
|
||||
image: frigate
|
||||
deploy:
|
||||
labels:
|
||||
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
|
||||
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
|
||||
- custom.label=preserved
|
||||
frigate:
|
||||
image: frigate
|
||||
`,
|
||||
],
|
||||
] as const)(
|
||||
"removes stale labels from the previous %s service after reassignment",
|
||||
async (composeType, staleComposeYaml) => {
|
||||
composeYaml = staleComposeYaml;
|
||||
|
||||
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
|
||||
{ ...baseDomain, serviceName: "frigate", enabled: false },
|
||||
]);
|
||||
|
||||
const previousService = result?.services?.legacy;
|
||||
const labels =
|
||||
composeType === "docker-compose"
|
||||
? previousService?.labels
|
||||
: previousService?.deploy?.labels;
|
||||
expect(labels).toContain("custom.label=preserved");
|
||||
expect(
|
||||
(labels as string[]).some((label) => label.includes("test-app-1")),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"docker-compose",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`)
|
||||
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
|
||||
traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api
|
||||
custom.label: preserved
|
||||
`,
|
||||
],
|
||||
[
|
||||
"stack",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
deploy:
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`)
|
||||
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
|
||||
traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api
|
||||
custom.label: preserved
|
||||
`,
|
||||
],
|
||||
] as const)(
|
||||
"removes stale mapping labels for a disabled domain from %s rebuilds",
|
||||
async (composeType, staleComposeYaml) => {
|
||||
composeYaml = staleComposeYaml;
|
||||
|
||||
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
|
||||
{ ...baseDomain, enabled: false },
|
||||
]);
|
||||
|
||||
const service = result?.services?.frigate;
|
||||
const labels =
|
||||
composeType === "docker-compose"
|
||||
? service?.labels
|
||||
: service?.deploy?.labels;
|
||||
expect(labels).toMatchObject({ "custom.label": "preserved" });
|
||||
expect(
|
||||
Object.keys(labels ?? {}).some((label) => label.includes("test-app-1")),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"docker-compose",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`)
|
||||
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
|
||||
custom.label: preserved
|
||||
`,
|
||||
"traefik.docker.network",
|
||||
],
|
||||
[
|
||||
"stack",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
deploy:
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`)
|
||||
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
|
||||
custom.label: preserved
|
||||
`,
|
||||
"traefik.swarm.network",
|
||||
],
|
||||
] as const)(
|
||||
"regenerates routing in mapping labels for an enabled domain in %s",
|
||||
async (composeType, mappingComposeYaml, networkLabel) => {
|
||||
composeYaml = mappingComposeYaml;
|
||||
|
||||
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
|
||||
{ ...baseDomain, enabled: true },
|
||||
]);
|
||||
|
||||
const service = result?.services?.frigate;
|
||||
const labels =
|
||||
composeType === "docker-compose"
|
||||
? service?.labels
|
||||
: service?.deploy?.labels;
|
||||
expect(labels).toMatchObject({
|
||||
"custom.label": "preserved",
|
||||
"traefik.enable": "true",
|
||||
[networkLabel]: "dokploy-network",
|
||||
"traefik.http.routers.test-app-1-web.rule":
|
||||
"Host(`frigate.example.com`)",
|
||||
"traefik.http.services.test-app-1-web.loadbalancer.server.port": "8971",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("emits labels only for the enabled domain when both are present", async () => {
|
||||
const result = await addDomainToCompose(baseCompose, [
|
||||
{ ...baseDomain, host: "enabled.example.com", enabled: true },
|
||||
{
|
||||
...baseDomain,
|
||||
host: "disabled.example.com",
|
||||
uniqueConfigKey: 2,
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const labels = serviceLabels(result);
|
||||
expect(labels.some((l) => l.includes("Host(`enabled.example.com`)"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(labels.some((l) => l.includes("Host(`disabled.example.com`)"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ describe("Host rule format regression tests", () => {
|
||||
customEntrypoint: null,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ describe("createDomainLabels", () => {
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
it("should create basic labels for web entrypoint", async () => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>()),
|
||||
execAsyncRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("raw remote compose conversion (#4794)", () => {
|
||||
it("uses the saved raw source and preserves supported mount syntax", async () => {
|
||||
vi.mocked(execAsyncRemote).mockResolvedValue({
|
||||
stdout: "services:\n test:\n image: alpine:latest\n",
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
const compose = {
|
||||
appName: "raw-stack",
|
||||
composeFile: `
|
||||
services:
|
||||
test:
|
||||
image: alpine:latest
|
||||
volumes:
|
||||
- type: tmpfs
|
||||
target: /scratch
|
||||
- type: volume
|
||||
source: test-data
|
||||
target: /data
|
||||
tmpfs:
|
||||
- /cache
|
||||
volumes:
|
||||
test-data:
|
||||
`,
|
||||
composePath: "./docker-compose.yml",
|
||||
composeType: "stack",
|
||||
isolatedDeployment: false,
|
||||
isolatedDeploymentsVolume: false,
|
||||
randomize: false,
|
||||
serverId: "remote-server",
|
||||
sourceType: "raw",
|
||||
suffix: "",
|
||||
} as unknown as Parameters<typeof addDomainToCompose>[0];
|
||||
|
||||
const converted = await addDomainToCompose(compose, []);
|
||||
|
||||
expect(converted?.services?.test?.volumes).toEqual([
|
||||
{ type: "tmpfs", target: "/scratch" },
|
||||
{
|
||||
type: "volume",
|
||||
source: "test-data",
|
||||
target: "/data",
|
||||
},
|
||||
]);
|
||||
expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]);
|
||||
expect(execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
|
||||
// Real end-to-end: the same Environment Settings text must reach a *running*
|
||||
// container with identical values under docker-compose and under swarm stack.
|
||||
// Covers https://github.com/Dokploy/dokploy/issues/5095 and /5096.
|
||||
|
||||
// exactly what a user types in the Environment Settings tab
|
||||
const uiEnv = [
|
||||
"SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0",
|
||||
"PASSWORD=mysecret#",
|
||||
"MIDDLE=sec#ret",
|
||||
"SHEBANG=#!secret",
|
||||
"DOLLARS=pa$$word",
|
||||
"SPACED=hello world",
|
||||
'JSON={"a":1}',
|
||||
"COMMENTED=value # a real comment",
|
||||
'QUOTED_HASH="abc#de"',
|
||||
].join("\n");
|
||||
|
||||
const expected: Record<string, string> = {
|
||||
SENTRY_DSN: "https://examplePublicKey@o0.ingest.sentry.io/0",
|
||||
PASSWORD: "mysecret#",
|
||||
MIDDLE: "sec#ret",
|
||||
SHEBANG: "#!secret",
|
||||
DOLLARS: "pa$$word",
|
||||
SPACED: "hello world",
|
||||
JSON: '{"a":1}',
|
||||
COMMENTED: "value",
|
||||
QUOTED_HASH: "abc#de",
|
||||
};
|
||||
|
||||
const keys = Object.keys(expected);
|
||||
const composeApp = `env-delivery-c${process.pid}`;
|
||||
const stackApp = `env-delivery-s${process.pid}`;
|
||||
const pathFor = (app: string) =>
|
||||
join(process.cwd(), ".docker", "compose", app, "code");
|
||||
|
||||
const sh = (cmd: string, args: string[], cwd?: string) =>
|
||||
execFileSync(cmd, args, { cwd, encoding: "utf8" });
|
||||
|
||||
const setup = (
|
||||
app: string,
|
||||
composeType: "docker-compose" | "stack",
|
||||
svc: string,
|
||||
) => {
|
||||
const codePath = pathFor(app);
|
||||
mkdirSync(codePath, { recursive: true });
|
||||
sh("bash", [
|
||||
"-c",
|
||||
getCreateEnvFileCommand({
|
||||
appName: app,
|
||||
composePath: "docker-compose.yml",
|
||||
composeType,
|
||||
env: uiEnv,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
serverId: null,
|
||||
environment: { project: { env: "" }, env: "" },
|
||||
} as Parameters<typeof getCreateEnvFileCommand>[0]),
|
||||
]);
|
||||
writeFileSync(join(codePath, "docker-compose.yml"), svc);
|
||||
return codePath;
|
||||
};
|
||||
|
||||
// `printenv` per key, NUL-delimited so values keep their spaces and #
|
||||
const dumpScript = keys.map((k) => `printf '%s\\0' "$${k}"`).join("; ");
|
||||
|
||||
const parseDump = (out: string) =>
|
||||
Object.fromEntries(
|
||||
out
|
||||
.split("\0")
|
||||
.slice(0, keys.length)
|
||||
.map((v, i) => [keys[i] as string, v]),
|
||||
);
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
sh("docker", ["stack", "rm", stackApp]);
|
||||
} catch {}
|
||||
try {
|
||||
sh("docker", ["compose", "down", "--remove-orphans"], pathFor(composeApp));
|
||||
} catch {}
|
||||
for (const app of [composeApp, stackApp]) {
|
||||
rmSync(join(process.cwd(), ".docker", "compose", app), {
|
||||
force: true,
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("env delivery into a running container", () => {
|
||||
it("docker-compose: values survive compose-go interpolation", () => {
|
||||
const codePath = setup(
|
||||
composeApp,
|
||||
"docker-compose",
|
||||
"services:\n app:\n image: busybox\n env_file: .env\n",
|
||||
);
|
||||
|
||||
const out = sh(
|
||||
"docker",
|
||||
["compose", "run", "--rm", "-T", "app", "sh", "-c", dumpScript],
|
||||
codePath,
|
||||
);
|
||||
|
||||
expect(parseDump(out)).toEqual(expected);
|
||||
}, 120000);
|
||||
|
||||
it("swarm stack: values survive the literal env-file parser", () => {
|
||||
const codePath = setup(
|
||||
stackApp,
|
||||
"stack",
|
||||
"services:\n app:\n image: busybox\n command: sleep 300\n env_file: .env\n",
|
||||
);
|
||||
|
||||
sh(
|
||||
"docker",
|
||||
["stack", "deploy", "-c", "docker-compose.yml", stackApp],
|
||||
codePath,
|
||||
);
|
||||
|
||||
let containerId = "";
|
||||
for (let i = 0; i < 60 && !containerId; i++) {
|
||||
containerId = (
|
||||
sh("docker", [
|
||||
"ps",
|
||||
"--filter",
|
||||
`label=com.docker.swarm.service.name=${stackApp}_app`,
|
||||
"--format",
|
||||
"{{.ID}}",
|
||||
]).split("\n")[0] ?? ""
|
||||
).trim();
|
||||
if (!containerId) execFileSync("sleep", ["1"]);
|
||||
}
|
||||
expect(containerId, "swarm task never started").toBeTruthy();
|
||||
|
||||
const out = sh("docker", ["exec", containerId, "sh", "-c", dumpScript]);
|
||||
|
||||
expect(parseDump(out)).toEqual(expected);
|
||||
}, 180000);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { prepareEnvironmentVariablesForFile } from "@dokploy/server/utils/docker/utils";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
// https://github.com/Dokploy/dokploy/issues/5096 — compose-go interpolates the
|
||||
// generated .env, docker stack deploy reads it literally; quoting cannot serve both.
|
||||
|
||||
const appName = `env-file-compose-type-${process.pid}`;
|
||||
const projectPath = join(process.cwd(), ".docker", "compose", appName);
|
||||
const codePath = join(projectPath, "code");
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(projectPath, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
const buildEnvFile = (composeType: "docker-compose" | "stack", env: string) => {
|
||||
mkdirSync(codePath, { recursive: true });
|
||||
const command = getCreateEnvFileCommand({
|
||||
appName,
|
||||
composePath: "docker-compose.yml",
|
||||
composeType,
|
||||
env,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
serverId: null,
|
||||
environment: { project: { env: "" }, env: "" },
|
||||
} as Parameters<typeof getCreateEnvFileCommand>[0]);
|
||||
execFileSync("bash", ["-c", command]);
|
||||
return readFileSync(join(codePath, ".env"), "utf8");
|
||||
};
|
||||
|
||||
describe("prepareEnvironmentVariablesForFile", () => {
|
||||
const env = "DSN=https://key@o0.io/0\nPASSWORD=pa$$word";
|
||||
|
||||
it("quotes and escapes by default, for the docker-compose .env", () => {
|
||||
expect(prepareEnvironmentVariablesForFile(env, "", "")).toEqual([
|
||||
'DSN="https://key@o0.io/0"',
|
||||
'PASSWORD="pa\\$\\$word"',
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes values untouched when raw", () => {
|
||||
expect(prepareEnvironmentVariablesForFile(env, "", "", true)).toEqual([
|
||||
"DSN=https://key@o0.io/0",
|
||||
"PASSWORD=pa$$word",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCreateEnvFileCommand", () => {
|
||||
it("writes a stack .env that docker reads back without the wrapping quotes", () => {
|
||||
const contents = buildEnvFile("stack", "DSN=https://key@o0.io/0");
|
||||
expect(contents).toContain("DSN=https://key@o0.io/0");
|
||||
expect(contents).not.toContain('DSN="');
|
||||
|
||||
writeFileSync(
|
||||
join(codePath, "docker-compose.yml"),
|
||||
"services:\n test:\n image: busybox\n env_file: .env\n",
|
||||
);
|
||||
|
||||
const rendered = execFileSync(
|
||||
"docker",
|
||||
["stack", "config", "-c", "docker-compose.yml"],
|
||||
{ cwd: codePath, encoding: "utf8" },
|
||||
);
|
||||
|
||||
expect(rendered).toContain("DSN: https://key@o0.io/0");
|
||||
// #5096 symptom: quotes surviving into the value
|
||||
expect(rendered).not.toContain("DSN: '\"");
|
||||
}, 60000);
|
||||
|
||||
it("keeps quoting the docker-compose .env so $ survives interpolation", () => {
|
||||
const contents = buildEnvFile("docker-compose", "PASSWORD=pa$$word");
|
||||
expect(contents).toContain('PASSWORD="pa\\$\\$word"');
|
||||
});
|
||||
});
|
||||
@@ -34,13 +34,13 @@ const cases: Record<string, string> = {
|
||||
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
|
||||
};
|
||||
|
||||
// How each value must be typed in the UI so the (unchanged) dotenv input
|
||||
// parser resolves it to the raw string in `cases` above.
|
||||
// How each value must be typed in the UI so the env input parser resolves it
|
||||
// to the raw string in `cases` above.
|
||||
const inputEncoding: Record<string, string> = {
|
||||
PASSWORD: "pa$$word",
|
||||
SPECIAL: `'!"#$%&/()=?'`,
|
||||
NESTED_JSON: '{"nested":{"a":1}}',
|
||||
MAIL_PASSWORD: `"abc#de"`,
|
||||
MAIL_PASSWORD: "abc#de",
|
||||
TRAILING_BACKSLASH: "trailing\\",
|
||||
QUOTE_INSIDE: 'she said "hi"',
|
||||
APOSTROPHE: "it's a test",
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
|
||||
import { cloudflareClient } from "@dokploy/server/utils/dns/cloudflare";
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as Response;
|
||||
|
||||
const cfSuccess = (result: unknown) =>
|
||||
jsonResponse({ success: true, errors: [], result });
|
||||
|
||||
const cfError = (message: string, status = 400) =>
|
||||
jsonResponse(
|
||||
{ success: false, errors: [{ code: 1, message }] },
|
||||
false,
|
||||
status,
|
||||
);
|
||||
|
||||
const config = { providerType: "cloudflare" as const, apiToken: "cf-token" };
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("cloudflareClient.listZones", () => {
|
||||
it("returns a single page of zones", async () => {
|
||||
mockFetch.mockResolvedValue(cfSuccess([{ id: "z1", name: "example.com" }]));
|
||||
|
||||
const zones = await cloudflareClient.listZones(config);
|
||||
|
||||
expect(zones).toEqual([{ id: "z1", name: "example.com" }]);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url] = mockFetch.mock.calls[0] as [string];
|
||||
expect(url).toContain("/zones?per_page=50&page=1");
|
||||
});
|
||||
|
||||
it("paginates until a short page is returned", async () => {
|
||||
const page = (count: number) =>
|
||||
cfSuccess(
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `z${i}`,
|
||||
name: `zone${i}.com`,
|
||||
})),
|
||||
);
|
||||
mockFetch.mockResolvedValueOnce(page(50)).mockResolvedValueOnce(page(3));
|
||||
|
||||
const zones = await cloudflareClient.listZones(config);
|
||||
|
||||
expect(zones).toHaveLength(53);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch.mock.calls[1]?.[0]).toContain("page=2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.listRecords", () => {
|
||||
it("lists records for a zone", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
cfSuccess([
|
||||
{
|
||||
id: "r1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 1,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await cloudflareClient.listRecords(config, "zone-1");
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: "r1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 1,
|
||||
},
|
||||
]);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toContain("/zones/zone-1/dns_records");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.upsertRecord", () => {
|
||||
it("creates a record when none exists for the name/type", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "new-1" }));
|
||||
|
||||
const result = await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "new-1" });
|
||||
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(createInit.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("updates the existing record instead of creating a duplicate", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([{ id: "existing-1" }]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
|
||||
|
||||
const result = await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "existing-1" });
|
||||
const [updateUrl, updateInit] = mockFetch.mock.calls[1] as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(updateUrl).toContain("/dns_records/existing-1");
|
||||
expect(updateInit.method).toBe("PUT");
|
||||
});
|
||||
|
||||
it("defaults ttl to 1 (automatic) when not provided", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "new-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "CNAME",
|
||||
name: "www.example.com",
|
||||
content: "example.com",
|
||||
});
|
||||
|
||||
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
const body = JSON.parse(createInit.body as string);
|
||||
expect(body.ttl).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.updateRecord", () => {
|
||||
it("PUTs directly to the given record id", async () => {
|
||||
mockFetch.mockResolvedValue(cfSuccess({ id: "r1" }));
|
||||
|
||||
const result = await cloudflareClient.updateRecord(config, "zone-1", "r1", {
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "9.9.9.9",
|
||||
ttl: 300,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "r1" });
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain("/zones/zone-1/dns_records/r1");
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "9.9.9.9",
|
||||
ttl: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.deleteRecord", () => {
|
||||
it("DELETEs the given record id", async () => {
|
||||
mockFetch.mockResolvedValue(cfSuccess({}));
|
||||
|
||||
await cloudflareClient.deleteRecord(config, "zone-1", "r1");
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain("/zones/zone-1/dns_records/r1");
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.testConnection", () => {
|
||||
it("succeeds when the token can list zones", async () => {
|
||||
mockFetch.mockResolvedValue(cfSuccess([]));
|
||||
await expect(
|
||||
cloudflareClient.testConnection(config),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces Cloudflare's error message on an invalid token", async () => {
|
||||
mockFetch.mockResolvedValue(cfError("Invalid API Token"));
|
||||
|
||||
await expect(cloudflareClient.testConnection(config)).rejects.toThrow(
|
||||
"Invalid API Token",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient auth header", () => {
|
||||
it("trims whitespace pasted into the token", async () => {
|
||||
mockFetch.mockResolvedValue(cfSuccess([]));
|
||||
|
||||
await cloudflareClient.testConnection({
|
||||
providerType: "cloudflare",
|
||||
apiToken: " cf-token\n",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer cf-token",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: { dnsProvider: { findFirst: vi.fn(), findMany: vi.fn() } },
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
DNS_SECRET_MASK,
|
||||
maskDnsProviderConfig,
|
||||
mergeDnsProviderConfig,
|
||||
} from "@dokploy/server/services/dns-provider";
|
||||
|
||||
describe("maskDnsProviderConfig", () => {
|
||||
it("masks the apiToken for a cloudflare config", () => {
|
||||
const masked = maskDnsProviderConfig({
|
||||
providerType: "cloudflare",
|
||||
apiToken: "real-token",
|
||||
});
|
||||
|
||||
expect(masked).toEqual({
|
||||
providerType: "cloudflare",
|
||||
apiToken: DNS_SECRET_MASK,
|
||||
});
|
||||
});
|
||||
|
||||
it("masks only the secretAccessKey for a route53 config, keeping accessKeyId visible", () => {
|
||||
const masked = maskDnsProviderConfig({
|
||||
providerType: "route53",
|
||||
accessKeyId: "AKIA_VISIBLE",
|
||||
secretAccessKey: "shh",
|
||||
});
|
||||
|
||||
expect(masked).toEqual({
|
||||
providerType: "route53",
|
||||
accessKeyId: "AKIA_VISIBLE",
|
||||
secretAccessKey: DNS_SECRET_MASK,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an empty sensitive field untouched instead of masking a blank value", () => {
|
||||
const masked = maskDnsProviderConfig({
|
||||
providerType: "cloudflare",
|
||||
apiToken: "",
|
||||
});
|
||||
|
||||
expect(masked).toEqual({ providerType: "cloudflare", apiToken: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeDnsProviderConfig", () => {
|
||||
it("restores the real secret when the incoming config still has the mask placeholder", () => {
|
||||
const existing = {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: "real-token",
|
||||
};
|
||||
const incoming = {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: DNS_SECRET_MASK,
|
||||
};
|
||||
|
||||
expect(mergeDnsProviderConfig(incoming, existing)).toEqual(existing);
|
||||
});
|
||||
|
||||
it("keeps a freshly entered secret instead of the stored one", () => {
|
||||
const existing = {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: "old-token",
|
||||
};
|
||||
const incoming = {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: "new-token",
|
||||
};
|
||||
|
||||
expect(mergeDnsProviderConfig(incoming, existing)).toEqual(incoming);
|
||||
});
|
||||
|
||||
it("throws when switching provider type while the field is still masked", () => {
|
||||
const existing = {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: "real-token",
|
||||
};
|
||||
const incoming = {
|
||||
providerType: "route53" as const,
|
||||
accessKeyId: "AKIA",
|
||||
secretAccessKey: DNS_SECRET_MASK,
|
||||
};
|
||||
|
||||
expect(() => mergeDnsProviderConfig(incoming, existing)).toThrow(
|
||||
"Credentials must be re-entered",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not require re-entry for fields that are not sensitive", () => {
|
||||
const existing = {
|
||||
providerType: "route53" as const,
|
||||
accessKeyId: "AKIA_OLD",
|
||||
secretAccessKey: "old-secret",
|
||||
};
|
||||
const incoming = {
|
||||
providerType: "route53" as const,
|
||||
accessKeyId: "AKIA_NEW",
|
||||
secretAccessKey: DNS_SECRET_MASK,
|
||||
};
|
||||
|
||||
expect(mergeDnsProviderConfig(incoming, existing)).toEqual({
|
||||
providerType: "route53",
|
||||
accessKeyId: "AKIA_NEW",
|
||||
secretAccessKey: "old-secret",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type HasInput = { input: any };
|
||||
|
||||
const {
|
||||
send,
|
||||
Route53Client,
|
||||
ListHostedZonesCommand,
|
||||
ListResourceRecordSetsCommand,
|
||||
ChangeResourceRecordSetsCommand,
|
||||
} = vi.hoisted(() => {
|
||||
class FakeCommand {
|
||||
input: any;
|
||||
constructor(input: any) {
|
||||
this.input = input;
|
||||
}
|
||||
}
|
||||
const send = vi.fn();
|
||||
class Route53Client {
|
||||
send(command: unknown) {
|
||||
return send(command);
|
||||
}
|
||||
}
|
||||
return {
|
||||
send,
|
||||
Route53Client,
|
||||
ListHostedZonesCommand: class extends FakeCommand {},
|
||||
ListResourceRecordSetsCommand: class extends FakeCommand {},
|
||||
ChangeResourceRecordSetsCommand: class extends FakeCommand {},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@aws-sdk/client-route-53", () => ({
|
||||
Route53Client,
|
||||
ListHostedZonesCommand,
|
||||
ListResourceRecordSetsCommand,
|
||||
ChangeResourceRecordSetsCommand,
|
||||
}));
|
||||
|
||||
import { route53Client } from "@dokploy/server/utils/dns/route53";
|
||||
|
||||
const config = {
|
||||
providerType: "route53" as const,
|
||||
accessKeyId: "AKIA_TEST",
|
||||
secretAccessKey: "secret",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
send.mockReset();
|
||||
});
|
||||
|
||||
describe("route53Client.listZones", () => {
|
||||
it("strips the hostedzone prefix and the trailing dot", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
HostedZones: [{ Id: "/hostedzone/Z123", Name: "example.com." }],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const zones = await route53Client.listZones(config);
|
||||
|
||||
expect(zones).toEqual([{ id: "Z123", name: "example.com" }]);
|
||||
});
|
||||
|
||||
it("follows the marker until IsTruncated is false", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({
|
||||
HostedZones: [{ Id: "/hostedzone/Z1", Name: "a.com." }],
|
||||
IsTruncated: true,
|
||||
NextMarker: "marker-1",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
HostedZones: [{ Id: "/hostedzone/Z2", Name: "b.com." }],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const zones = await route53Client.listZones(config);
|
||||
|
||||
expect(zones).toEqual([
|
||||
{ id: "Z1", name: "a.com" },
|
||||
{ id: "Z2", name: "b.com" },
|
||||
]);
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect((send.mock.calls[1]?.[0] as HasInput).input.Marker).toBe("marker-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.listRecords", () => {
|
||||
it("builds a type:name id and strips the trailing dot from the name", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||
},
|
||||
],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: "A:app.example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 300,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{ Name: "alias.example.com.", Type: "A", ResourceRecords: [] },
|
||||
],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records).toEqual([]);
|
||||
});
|
||||
|
||||
it("joins multiple values for the same record", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "example.com.",
|
||||
Type: "NS",
|
||||
TTL: 172800,
|
||||
ResourceRecords: [
|
||||
{ Value: "ns1.example.com" },
|
||||
{ Value: "ns2.example.com" },
|
||||
],
|
||||
},
|
||||
],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.upsertRecord", () => {
|
||||
it("sends a single UPSERT change", async () => {
|
||||
send.mockResolvedValueOnce({});
|
||||
|
||||
const result = await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "A:app.example.com" });
|
||||
const command = send.mock.calls[0]?.[0] as HasInput;
|
||||
expect(command.input.HostedZoneId).toBe("Z123");
|
||||
expect(command.input.ChangeBatch.Changes).toEqual([
|
||||
{
|
||||
Action: "UPSERT",
|
||||
ResourceRecordSet: {
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.updateRecord", () => {
|
||||
it("only UPSERTs when the name/type identity did not change", async () => {
|
||||
send.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.updateRecord(config, "Z123", "A:app.example.com", {
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "9.9.9.9",
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
const command = send.mock.calls[0]?.[0] as HasInput;
|
||||
expect(command.input.ChangeBatch.Changes).toHaveLength(1);
|
||||
expect(command.input.ChangeBatch.Changes[0].Action).toBe("UPSERT");
|
||||
});
|
||||
|
||||
it("deletes the old record set and creates the new one on rename", async () => {
|
||||
const existingSet = {
|
||||
Name: "old.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.1.1.1" }],
|
||||
};
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] }) // findExactRecordSet lookup
|
||||
.mockResolvedValueOnce({}); // ChangeResourceRecordSets
|
||||
|
||||
await route53Client.updateRecord(config, "Z123", "A:old.example.com", {
|
||||
type: "A",
|
||||
name: "new.example.com",
|
||||
content: "1.1.1.1",
|
||||
});
|
||||
|
||||
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
|
||||
{ Action: "DELETE", ResourceRecordSet: existingSet },
|
||||
{
|
||||
Action: "UPSERT",
|
||||
ResourceRecordSet: {
|
||||
Name: "new.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.1.1.1" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips the DELETE when the old record no longer exists", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.updateRecord(config, "Z123", "A:gone.example.com", {
|
||||
type: "A",
|
||||
name: "new.example.com",
|
||||
content: "1.1.1.1",
|
||||
});
|
||||
|
||||
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(changeCommand.input.ChangeBatch.Changes).toHaveLength(1);
|
||||
expect(changeCommand.input.ChangeBatch.Changes[0].Action).toBe("UPSERT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.deleteRecord", () => {
|
||||
it("deletes the record set found by name/type", async () => {
|
||||
const existingSet = {
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||
};
|
||||
send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] });
|
||||
send.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.deleteRecord(config, "Z123", "A:app.example.com");
|
||||
|
||||
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
|
||||
{ Action: "DELETE", ResourceRecordSet: existingSet },
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws when the record no longer exists", async () => {
|
||||
send.mockResolvedValueOnce({ ResourceRecordSets: [] });
|
||||
|
||||
await expect(
|
||||
route53Client.deleteRecord(config, "Z123", "A:gone.example.com"),
|
||||
).rejects.toThrow("not found");
|
||||
});
|
||||
|
||||
it("throws for a malformed record id", async () => {
|
||||
await expect(
|
||||
route53Client.deleteRecord(config, "Z123", "not-a-valid-id"),
|
||||
).rejects.toThrow("Invalid Route53 record id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.testConnection", () => {
|
||||
it("resolves when ListHostedZones succeeds", async () => {
|
||||
send.mockResolvedValueOnce({ HostedZones: [] });
|
||||
await expect(route53Client.testConnection(config)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("propagates SDK errors", async () => {
|
||||
send.mockRejectedValueOnce(new Error("InvalidClientTokenId"));
|
||||
await expect(route53Client.testConnection(config)).rejects.toThrow(
|
||||
"InvalidClientTokenId",
|
||||
);
|
||||
});
|
||||
});
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import { prepareEnvironmentVariables } from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// https://github.com/Dokploy/dokploy/issues/5095 — expectations calibrated against docker compose v2
|
||||
|
||||
describe("prepareEnvironmentVariables (# in values)", () => {
|
||||
it("preserves # when it is not an inline comment", () => {
|
||||
const serviceEnv = [
|
||||
"TRAILING=secret#",
|
||||
"MIDDLE=sec#ret",
|
||||
"MULTI=a#b#c",
|
||||
"LEADING=#!secret",
|
||||
"NO_SPACE=value# not a comment",
|
||||
"CONN=user=admin;password=b#c",
|
||||
].join("\n");
|
||||
|
||||
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
|
||||
"TRAILING=secret#",
|
||||
"MIDDLE=sec#ret",
|
||||
"MULTI=a#b#c",
|
||||
"LEADING=#!secret",
|
||||
"NO_SPACE=value# not a comment",
|
||||
"CONN=user=admin;password=b#c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips an inline comment only after one or more spaces", () => {
|
||||
const serviceEnv = [
|
||||
"ONE_SPACE=value # comment",
|
||||
"TWO_SPACES=value # comment",
|
||||
"TRAILING_WS=value ",
|
||||
].join("\n");
|
||||
|
||||
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
|
||||
"ONE_SPACE=value",
|
||||
"TWO_SPACES=value",
|
||||
"TRAILING_WS=value",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat a tab-preceded # as a comment", () => {
|
||||
const serviceEnv = "WITH_TAB=value\t# not a comment";
|
||||
|
||||
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
|
||||
"WITH_TAB=value\t# not a comment",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a bare `KEY= # ...` as an empty value", () => {
|
||||
// deliberate divergence: compose reads "# this is a comment" here
|
||||
const serviceEnv = "ONLY_HASH= # this is a comment";
|
||||
|
||||
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
|
||||
"ONLY_HASH=",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves # inside quoted values and still strips a trailing comment", () => {
|
||||
const serviceEnv = [
|
||||
'DOUBLE="val#ue"',
|
||||
"SINGLE='val#ue'",
|
||||
'DOUBLE_THEN_COMMENT="a # b" # comment',
|
||||
"SINGLE_THEN_COMMENT='c # d' # comment",
|
||||
].join("\n");
|
||||
|
||||
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
|
||||
"DOUBLE=val#ue",
|
||||
"SINGLE=val#ue",
|
||||
"DOUBLE_THEN_COMMENT=a # b",
|
||||
"SINGLE_THEN_COMMENT=c # d",
|
||||
]);
|
||||
});
|
||||
|
||||
it("still skips full-line comments", () => {
|
||||
const serviceEnv = [
|
||||
"# leading comment",
|
||||
"FIRST=one",
|
||||
" # indented comment",
|
||||
"SECOND=two",
|
||||
].join("\n");
|
||||
|
||||
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
|
||||
"FIRST=one",
|
||||
"SECOND=two",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves # through project, environment and self references", () => {
|
||||
const projectEnv = "DB_PASS=secret#end";
|
||||
const environmentEnv = "API_KEY=key#123";
|
||||
const serviceEnv = [
|
||||
"DATABASE_URL=postgres://user:${{project.DB_PASS}}@db:5432/app",
|
||||
"TOKEN=${{environment.API_KEY}}",
|
||||
"LOCAL_SECRET=self#value",
|
||||
"COPY=${{LOCAL_SECRET}}",
|
||||
].join("\n");
|
||||
|
||||
expect(
|
||||
prepareEnvironmentVariables(serviceEnv, projectEnv, environmentEnv),
|
||||
).toEqual([
|
||||
"DATABASE_URL=postgres://user:secret#end@db:5432/app",
|
||||
"TOKEN=key#123",
|
||||
"LOCAL_SECRET=self#value",
|
||||
"COPY=self#value",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareEnvironmentVariables (dotenv parity)", () => {
|
||||
it("keeps the behaviour dotenv had for everything except #", () => {
|
||||
const serviceEnv = [
|
||||
"export EXPORTED=value",
|
||||
'ESCAPED="line1\\nline2"',
|
||||
"EMPTY=",
|
||||
'EMPTY_DOUBLE=""',
|
||||
"EMPTY_SINGLE=''",
|
||||
"DOLLARS=pa$$word",
|
||||
'SPACED_QUOTED= "padded" ',
|
||||
].join("\n");
|
||||
|
||||
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
|
||||
"EXPORTED=value",
|
||||
"ESCAPED=line1\nline2",
|
||||
"EMPTY=",
|
||||
"EMPTY_DOUBLE=",
|
||||
"EMPTY_SINGLE=",
|
||||
"DOLLARS=pa$$word",
|
||||
"SPACED_QUOTED=padded",
|
||||
]);
|
||||
});
|
||||
|
||||
it("supports multiline double-quoted values", () => {
|
||||
const serviceEnv = 'MULTILINE="line1\nline2"';
|
||||
|
||||
expect(prepareEnvironmentVariables(serviceEnv, "", "")).toEqual([
|
||||
"MULTILINE=line1\nline2",
|
||||
]);
|
||||
});
|
||||
});
|
||||
+176
@@ -20,6 +20,7 @@ import {
|
||||
import { azureClient } from "@dokploy/server/utils/vault/azure";
|
||||
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
|
||||
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
|
||||
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
@@ -440,3 +441,178 @@ describe("doppler client", () => {
|
||||
).rejects.toThrow("status 401");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scaleway client", () => {
|
||||
const config = {
|
||||
providerType: "scaleway" as const,
|
||||
region: "fr-par",
|
||||
projectId: "project-1",
|
||||
secretKey: "scw-secret-key",
|
||||
apiUrl: "https://api.scaleway.com",
|
||||
};
|
||||
|
||||
const accessResponse = (value: string) =>
|
||||
jsonResponse({
|
||||
secret_id: "secret-1",
|
||||
revision: 1,
|
||||
data: Buffer.from(value).toString("base64"),
|
||||
});
|
||||
|
||||
it("reads a secret by name from the root path and decodes the payload", async () => {
|
||||
mockFetch.mockResolvedValue(accessResponse("postgres://real"));
|
||||
|
||||
const result = await scalewayClient.getSecrets(config, ["db-url"]);
|
||||
|
||||
expect(result).toEqual({ "db-url": "postgres://real" });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(
|
||||
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets-by-path/versions/latest_enabled/access?project_id=project-1&secret_name=db-url&secret_path=%2F",
|
||||
);
|
||||
expect((init.headers as Record<string, string>)["X-Auth-Token"]).toBe(
|
||||
"scw-secret-key",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the folder of a path-qualified ref as secret_path", async () => {
|
||||
mockFetch.mockResolvedValue(accessResponse("value"));
|
||||
|
||||
await scalewayClient.getSecrets(config, ["prod/db/password"]);
|
||||
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toContain(
|
||||
"secret_name=password&secret_path=%2Fprod%2Fdb",
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts a field from a JSON secret and fetches the secret once", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
accessResponse(JSON.stringify({ user: "admin", port: 5432 })),
|
||||
);
|
||||
|
||||
const result = await scalewayClient.getSecrets(config, [
|
||||
"db-creds:user",
|
||||
"db-creds:port",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
"db-creds:user": "admin",
|
||||
"db-creds:port": "5432",
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws when the secret is not JSON but a field is requested", async () => {
|
||||
mockFetch.mockResolvedValue(accessResponse("plain-value"));
|
||||
|
||||
await expect(
|
||||
scalewayClient.getSecrets(config, ["db-creds:user"]),
|
||||
).rejects.toThrow("is not JSON");
|
||||
});
|
||||
|
||||
it("throws when the requested field is missing", async () => {
|
||||
mockFetch.mockResolvedValue(accessResponse(JSON.stringify({ user: "a" })));
|
||||
|
||||
await expect(
|
||||
scalewayClient.getSecrets(config, ["db-creds:password"]),
|
||||
).rejects.toThrow('field "password" not found');
|
||||
});
|
||||
|
||||
it("throws a clear error for a missing secret", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({ message: "not found" }, false, 404),
|
||||
);
|
||||
|
||||
await expect(scalewayClient.getSecrets(config, ["nope"])).rejects.toThrow(
|
||||
'secret "nope" not found in path "/"',
|
||||
);
|
||||
});
|
||||
|
||||
it("reports authentication failures with the API message", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({ message: "denied authentication" }, false, 403),
|
||||
);
|
||||
|
||||
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
|
||||
"authentication failed (status 403: denied authentication)",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when the secret has no enabled version", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({ secret_id: "secret-1" }));
|
||||
|
||||
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
|
||||
"has no enabled version",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a ref without a secret name", async () => {
|
||||
await expect(scalewayClient.getSecrets(config, ["prod/"])).rejects.toThrow(
|
||||
"expected format <name> or <folder>/<name>[:field]",
|
||||
);
|
||||
});
|
||||
|
||||
it("tests the connection against the secrets listing", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({ secrets: [], total_count: 0 }));
|
||||
|
||||
await scalewayClient.testConnection(config);
|
||||
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets?project_id=project-1&page_size=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists secret names with their folder prefix", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({
|
||||
secrets: [
|
||||
{ id: "1", name: "db-url", path: "/" },
|
||||
{ id: "2", name: "password", path: "/prod/db" },
|
||||
],
|
||||
total_count: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
const names = await scalewayClient.listSecretNames?.(config);
|
||||
|
||||
expect(names).toEqual(["db-url", "prod/db/password"]);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("follows pagination until a short page is returned", async () => {
|
||||
const page = (start: number, size: number) =>
|
||||
jsonResponse({
|
||||
secrets: Array.from({ length: size }, (_, index) => ({
|
||||
id: String(start + index),
|
||||
name: `secret-${start + index}`,
|
||||
path: "/",
|
||||
})),
|
||||
});
|
||||
mockFetch.mockImplementation(async (url: string) =>
|
||||
url.includes("page=2") ? page(101, 2) : page(1, 100),
|
||||
);
|
||||
|
||||
const names = await scalewayClient.listSecretNames?.(config);
|
||||
|
||||
expect(names).toHaveLength(102);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("resolves env refs end to end through a scaleway provider", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "scw-prod",
|
||||
providerType: "scaleway",
|
||||
config,
|
||||
assignments: assignedEverywhere,
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue(accessResponse("s3cret"));
|
||||
|
||||
const result = await resolveVaultReferences(
|
||||
"DB_PASSWORD=${{vault.scw-prod.prod/db-password}}",
|
||||
scope,
|
||||
);
|
||||
|
||||
expect(result).toBe("DB_PASSWORD=s3cret");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ const ENTERPRISE_RESOURCES = [
|
||||
"monitoring",
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
"dnsProvider",
|
||||
];
|
||||
|
||||
describe("enterpriseOnlyResources set", () => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getSubnetCapacity } from "@dokploy/server/services/server-health";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
describe("getSubnetCapacity", () => {
|
||||
test("returns null for missing/invalid input", () => {
|
||||
expect(getSubnetCapacity(undefined)).toBeNull();
|
||||
expect(getSubnetCapacity("")).toBeNull();
|
||||
expect(getSubnetCapacity("10.0.0.0")).toBeNull();
|
||||
expect(getSubnetCapacity("not-a-subnet")).toBeNull();
|
||||
expect(getSubnetCapacity("10.0.0.0/33")).toBeNull();
|
||||
expect(getSubnetCapacity("10.0.0.0/-1")).toBeNull();
|
||||
});
|
||||
|
||||
test("excludes network and broadcast addresses", () => {
|
||||
expect(getSubnetCapacity("10.0.1.0/24")).toBe(254);
|
||||
expect(getSubnetCapacity("10.0.0.0/16")).toBe(65534);
|
||||
expect(getSubnetCapacity("10.99.99.0/30")).toBe(2);
|
||||
});
|
||||
|
||||
test("returns 0 for subnets too small to hold a host", () => {
|
||||
expect(getSubnetCapacity("10.0.0.0/31")).toBe(0);
|
||||
expect(getSubnetCapacity("10.0.0.0/32")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import {
|
||||
getBackupOverviewIcon,
|
||||
getServiceOverviewIcon,
|
||||
} from "@dokploy/server/services/overview";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
describe("getServiceOverviewIcon", () => {
|
||||
test("returns a db icon for every known DB engine type", () => {
|
||||
for (const type of [
|
||||
"postgres",
|
||||
"mariadb",
|
||||
"mysql",
|
||||
"mongo",
|
||||
"redis",
|
||||
"libsql",
|
||||
] as const) {
|
||||
expect(getServiceOverviewIcon({ type, icon: null })).toEqual({
|
||||
kind: "db",
|
||||
engine: type,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("returns a custom icon for application/compose with a service icon set", () => {
|
||||
expect(
|
||||
getServiceOverviewIcon({
|
||||
type: "application",
|
||||
icon: "data:image/png;base64,x",
|
||||
}),
|
||||
).toEqual({ kind: "custom", url: "data:image/png;base64,x" });
|
||||
expect(
|
||||
getServiceOverviewIcon({
|
||||
type: "compose",
|
||||
icon: "data:image/png;base64,y",
|
||||
}),
|
||||
).toEqual({ kind: "custom", url: "data:image/png;base64,y" });
|
||||
});
|
||||
|
||||
test("falls back to a generic icon for application/compose without a service icon", () => {
|
||||
expect(getServiceOverviewIcon({ type: "application", icon: null })).toEqual(
|
||||
{
|
||||
kind: "generic",
|
||||
type: "application",
|
||||
},
|
||||
);
|
||||
expect(getServiceOverviewIcon({ type: "compose", icon: null })).toEqual({
|
||||
kind: "generic",
|
||||
type: "compose",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBackupOverviewIcon", () => {
|
||||
test("returns a db icon for a database backup with a known databaseType", () => {
|
||||
expect(
|
||||
getBackupOverviewIcon({
|
||||
databaseType: "postgres",
|
||||
serviceType: null,
|
||||
serviceOwnerType: "postgres",
|
||||
}),
|
||||
).toEqual({ kind: "db", engine: "postgres" });
|
||||
});
|
||||
|
||||
test("returns a webServer icon for a web-server database backup", () => {
|
||||
expect(
|
||||
getBackupOverviewIcon({
|
||||
databaseType: "web-server",
|
||||
serviceType: null,
|
||||
serviceOwnerType: "web-server",
|
||||
}),
|
||||
).toEqual({ kind: "webServer" });
|
||||
});
|
||||
|
||||
test("returns a db icon for a compose-type backup dumping a known DB engine", () => {
|
||||
expect(
|
||||
getBackupOverviewIcon({
|
||||
databaseType: "mysql",
|
||||
serviceType: null,
|
||||
serviceOwnerType: "compose",
|
||||
}),
|
||||
).toEqual({ kind: "db", engine: "mysql" });
|
||||
});
|
||||
|
||||
test("returns a db icon for a volume backup of a known DB engine service", () => {
|
||||
expect(
|
||||
getBackupOverviewIcon({
|
||||
databaseType: null,
|
||||
serviceType: "mongo",
|
||||
serviceOwnerType: "mongo",
|
||||
}),
|
||||
).toEqual({ kind: "db", engine: "mongo" });
|
||||
});
|
||||
|
||||
test("returns a generic application icon for a volume backup of an application", () => {
|
||||
expect(
|
||||
getBackupOverviewIcon({
|
||||
databaseType: null,
|
||||
serviceType: "application",
|
||||
serviceOwnerType: "application",
|
||||
}),
|
||||
).toEqual({ kind: "generic", type: "application" });
|
||||
});
|
||||
|
||||
test("returns a generic compose icon for a volume backup of a compose service", () => {
|
||||
expect(
|
||||
getBackupOverviewIcon({
|
||||
databaseType: null,
|
||||
serviceType: "compose",
|
||||
serviceOwnerType: "compose",
|
||||
}),
|
||||
).toEqual({ kind: "generic", type: "compose" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { OverviewDomain } from "@dokploy/server/services/overview";
|
||||
import { sortOverviewDomains } from "@dokploy/server/services/overview";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const makeDomain = (overrides: Partial<OverviewDomain>): OverviewDomain => ({
|
||||
domainId: "id",
|
||||
host: "example.com",
|
||||
path: "/",
|
||||
port: 3000,
|
||||
customEntrypoint: null,
|
||||
https: true,
|
||||
certificateType: "letsencrypt",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
enabled: true,
|
||||
domainType: "application",
|
||||
serviceOwnerId: "app",
|
||||
serviceOwnerType: "application",
|
||||
serviceName: "App",
|
||||
projectId: "project",
|
||||
projectName: "Project",
|
||||
environmentId: "environment",
|
||||
environmentName: "Environment",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("sortOverviewDomains", () => {
|
||||
test("sorts by createdAt asc/desc", () => {
|
||||
const domains = [
|
||||
makeDomain({ domainId: "old", createdAt: "2023-01-01T00:00:00.000Z" }),
|
||||
makeDomain({ domainId: "new", createdAt: "2024-06-01T00:00:00.000Z" }),
|
||||
makeDomain({ domainId: "mid", createdAt: "2023-12-01T00:00:00.000Z" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
sortOverviewDomains(domains, "createdAt-asc").map((d) => d.domainId),
|
||||
).toEqual(["old", "mid", "new"]);
|
||||
expect(
|
||||
sortOverviewDomains(domains, "createdAt-desc").map((d) => d.domainId),
|
||||
).toEqual(["new", "mid", "old"]);
|
||||
});
|
||||
|
||||
test("sorts by port asc/desc, with portless domains always last", () => {
|
||||
const domains = [
|
||||
makeDomain({ domainId: "none", port: null }),
|
||||
makeDomain({ domainId: "low", port: 80 }),
|
||||
makeDomain({ domainId: "high", port: 8080 }),
|
||||
];
|
||||
|
||||
expect(
|
||||
sortOverviewDomains(domains, "port-asc").map((d) => d.domainId),
|
||||
).toEqual(["low", "high", "none"]);
|
||||
expect(
|
||||
sortOverviewDomains(domains, "port-desc").map((d) => d.domainId),
|
||||
).toEqual(["high", "low", "none"]);
|
||||
});
|
||||
|
||||
test("port sort with all domains portless is a no-op", () => {
|
||||
const domains = [
|
||||
makeDomain({ domainId: "a", port: null }),
|
||||
makeDomain({ domainId: "b", port: null }),
|
||||
];
|
||||
|
||||
expect(
|
||||
sortOverviewDomains(domains, "port-desc").map((d) => d.domainId),
|
||||
).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("does not mutate the input array", () => {
|
||||
const domains = [
|
||||
makeDomain({ domainId: "b", port: 8080 }),
|
||||
makeDomain({ domainId: "a", port: 80 }),
|
||||
];
|
||||
const original = [...domains];
|
||||
|
||||
sortOverviewDomains(domains, "port-asc");
|
||||
|
||||
expect(domains).toEqual(original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { OverviewService } from "@dokploy/server/services/overview";
|
||||
import { sortOverviewServices } from "@dokploy/server/services/overview";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const makeService = (overrides: Partial<OverviewService>): OverviewService => ({
|
||||
id: "id",
|
||||
type: "application",
|
||||
name: "name",
|
||||
appName: "app-name",
|
||||
status: "running",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
serverId: null,
|
||||
serverName: null,
|
||||
icon: null,
|
||||
projectId: "project",
|
||||
projectName: "Project",
|
||||
environmentId: "environment",
|
||||
environmentName: "Environment",
|
||||
lastDeployAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("sortOverviewServices", () => {
|
||||
test("sorts by name asc/desc", () => {
|
||||
const services = [
|
||||
makeService({ id: "b", name: "Bravo" }),
|
||||
makeService({ id: "a", name: "Alpha" }),
|
||||
makeService({ id: "c", name: "Charlie" }),
|
||||
];
|
||||
|
||||
expect(sortOverviewServices(services, "name-asc").map((s) => s.id)).toEqual(
|
||||
["a", "b", "c"],
|
||||
);
|
||||
expect(
|
||||
sortOverviewServices(services, "name-desc").map((s) => s.id),
|
||||
).toEqual(["c", "b", "a"]);
|
||||
});
|
||||
|
||||
test("sorts by type asc/desc", () => {
|
||||
const services = [
|
||||
makeService({ id: "app", type: "application" }),
|
||||
makeService({ id: "pg", type: "postgres" }),
|
||||
makeService({ id: "comp", type: "compose" }),
|
||||
];
|
||||
|
||||
expect(sortOverviewServices(services, "type-asc").map((s) => s.id)).toEqual(
|
||||
["app", "comp", "pg"],
|
||||
);
|
||||
expect(
|
||||
sortOverviewServices(services, "type-desc").map((s) => s.id),
|
||||
).toEqual(["pg", "comp", "app"]);
|
||||
});
|
||||
|
||||
test("sorts by createdAt asc/desc", () => {
|
||||
const services = [
|
||||
makeService({ id: "old", createdAt: "2023-01-01T00:00:00.000Z" }),
|
||||
makeService({ id: "new", createdAt: "2024-06-01T00:00:00.000Z" }),
|
||||
makeService({ id: "mid", createdAt: "2023-12-01T00:00:00.000Z" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
sortOverviewServices(services, "createdAt-asc").map((s) => s.id),
|
||||
).toEqual(["old", "mid", "new"]);
|
||||
expect(
|
||||
sortOverviewServices(services, "createdAt-desc").map((s) => s.id),
|
||||
).toEqual(["new", "mid", "old"]);
|
||||
});
|
||||
|
||||
test("sorts by lastDeploy asc/desc, with never-deployed services always last", () => {
|
||||
const services = [
|
||||
makeService({ id: "never", lastDeployAt: null }),
|
||||
makeService({ id: "old", lastDeployAt: "2023-01-01T00:00:00.000Z" }),
|
||||
makeService({ id: "new", lastDeployAt: "2024-06-01T00:00:00.000Z" }),
|
||||
];
|
||||
|
||||
expect(
|
||||
sortOverviewServices(services, "lastDeploy-desc").map((s) => s.id),
|
||||
).toEqual(["new", "old", "never"]);
|
||||
expect(
|
||||
sortOverviewServices(services, "lastDeploy-asc").map((s) => s.id),
|
||||
).toEqual(["old", "new", "never"]);
|
||||
});
|
||||
|
||||
test("lastDeploy sort with all services never deployed is a no-op", () => {
|
||||
const services = [
|
||||
makeService({ id: "a", lastDeployAt: null }),
|
||||
makeService({ id: "b", lastDeployAt: null }),
|
||||
];
|
||||
|
||||
expect(
|
||||
sortOverviewServices(services, "lastDeploy-desc").map((s) => s.id),
|
||||
).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("does not mutate the input array", () => {
|
||||
const services = [
|
||||
makeService({ id: "b", name: "Bravo" }),
|
||||
makeService({ id: "a", name: "Alpha" }),
|
||||
];
|
||||
const original = [...services];
|
||||
|
||||
sortOverviewServices(services, "name-asc");
|
||||
|
||||
expect(services).toEqual(original);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ const baseDomain: Domain = {
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
describe("forwardAuthMiddlewareName", () => {
|
||||
|
||||
@@ -151,6 +151,7 @@ const baseDomain: Domain = {
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const baseRedirect: Redirect = {
|
||||
|
||||
+1
-1
@@ -187,7 +187,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
|
||||
To use a cluster feature, you need to configure at least
|
||||
a registry first. Please, go to{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-foreground"
|
||||
>
|
||||
Settings
|
||||
|
||||
+1
-1
@@ -234,7 +234,7 @@ export const HandleRedirect = ({
|
||||
<FormItem>
|
||||
<FormLabel>Replacement</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="http://mydomain/$${1}" {...field} />
|
||||
<Input placeholder="http://mydomain/$1" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
|
||||
@@ -148,7 +148,7 @@ export const ShowDeployment = ({
|
||||
<DialogDescription className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-2">
|
||||
See all the details of this deployment |{" "}
|
||||
<Badge variant="blank" className="text-xs">
|
||||
<Badge variant="blank" className="text-xs tabular-nums">
|
||||
{filteredLogs.length} lines
|
||||
</Badge>
|
||||
</span>
|
||||
|
||||
@@ -14,6 +14,7 @@ import Link from "next/link";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -35,7 +36,9 @@ interface ColumnsProps {
|
||||
validationStates: ValidationStates;
|
||||
handleValidateDomain: (host: string) => Promise<void>;
|
||||
handleDeleteDomain: (domainId: string) => Promise<void>;
|
||||
handleToggleEnable: (domainId: string) => Promise<void>;
|
||||
isDeleting: boolean;
|
||||
isToggling: boolean;
|
||||
serverIp?: string;
|
||||
canCreateDomain: boolean;
|
||||
canDeleteDomain: boolean;
|
||||
@@ -47,7 +50,9 @@ export const createColumns = ({
|
||||
validationStates,
|
||||
handleValidateDomain,
|
||||
handleDeleteDomain,
|
||||
handleToggleEnable,
|
||||
isDeleting,
|
||||
isToggling,
|
||||
serverIp,
|
||||
canCreateDomain,
|
||||
canDeleteDomain,
|
||||
@@ -249,6 +254,42 @@ export const createColumns = ({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const domain = row.original;
|
||||
if (!canCreateDomain) {
|
||||
return (
|
||||
<Badge variant={domain.enabled ? "outline" : "secondary"}>
|
||||
{domain.enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
checked={domain.enabled}
|
||||
onCheckedChange={() => handleToggleEnable(domain.domainId)}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{domain.enabled
|
||||
? "Domain is active. Toggle to disable routing without deleting it."
|
||||
: "Domain is disabled and not routed. Toggle to enable it again."}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
|
||||
|
||||
export type CacheType = "fetch" | "cache";
|
||||
|
||||
@@ -300,7 +301,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(dictionary.success);
|
||||
toast.success(
|
||||
dictionary.success,
|
||||
data.domainType === "compose"
|
||||
? { description: COMPOSE_REDEPLOY_TOAST }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (data.domainType === "application") {
|
||||
await utils.domain.byApplicationId.invalidate({
|
||||
@@ -337,12 +343,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
{type === "compose" && (
|
||||
<AlertBlock type="info" className="mb-4">
|
||||
Whenever you make changes to domains, remember to redeploy your
|
||||
compose to apply the changes.
|
||||
</AlertBlock>
|
||||
)}
|
||||
{type === "compose" && <ComposeRedeployAlert className="mb-4" />}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
|
||||
/**
|
||||
* Domains attached to a compose service are rendered as docker labels and only
|
||||
* take effect on the next deployment. These strings keep the "redeploy required"
|
||||
* wording consistent across the add/edit dialog, the domains list and the
|
||||
* toasts shown after create/update/delete/toggle operations.
|
||||
*/
|
||||
export const COMPOSE_REDEPLOY_HINT =
|
||||
"Whenever you make changes to domains, remember to redeploy your compose to apply the changes.";
|
||||
|
||||
export const COMPOSE_REDEPLOY_TOAST =
|
||||
"Redeploy the compose to apply the changes.";
|
||||
|
||||
export const ComposeRedeployAlert = ({ className }: { className?: string }) => (
|
||||
<AlertBlock type="info" className={className}>
|
||||
{COMPOSE_REDEPLOY_HINT}
|
||||
</AlertBlock>
|
||||
);
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -58,11 +59,13 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { createColumns } from "./columns";
|
||||
import { DnsHelperModal } from "./dns-helper-modal";
|
||||
import { AddDomain } from "./handle-domain";
|
||||
import { HandleForwardAuth } from "./handle-forward-auth";
|
||||
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
|
||||
|
||||
export type ValidationState = {
|
||||
isLoading: boolean;
|
||||
@@ -146,12 +149,34 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
api.domain.validateDomain.useMutation();
|
||||
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
||||
api.domain.delete.useMutation();
|
||||
const { mutateAsync: toggleEnable, isPending: isToggling } =
|
||||
api.domain.toggleEnable.useMutation();
|
||||
|
||||
const handleToggleEnable = async (domainId: string) => {
|
||||
try {
|
||||
const result = await toggleEnable({ domainId });
|
||||
refetch();
|
||||
toast.success(
|
||||
result.enabled ? "Domain enabled" : "Domain disabled",
|
||||
result.requiresRedeploy
|
||||
? { description: COMPOSE_REDEPLOY_TOAST }
|
||||
: undefined,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Error updating the domain");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDomain = async (domainId: string) => {
|
||||
try {
|
||||
await deleteDomain({ domainId });
|
||||
refetch();
|
||||
toast.success("Domain deleted successfully");
|
||||
toast.success(
|
||||
"Domain deleted successfully",
|
||||
type === "compose"
|
||||
? { description: COMPOSE_REDEPLOY_TOAST }
|
||||
: undefined,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Error deleting domain");
|
||||
}
|
||||
@@ -200,7 +225,9 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
validationStates,
|
||||
handleValidateDomain,
|
||||
handleDeleteDomain,
|
||||
handleToggleEnable,
|
||||
isDeleting: isRemoving,
|
||||
isToggling,
|
||||
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
|
||||
canCreateDomain,
|
||||
canDeleteDomain,
|
||||
@@ -265,6 +292,11 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{type === "compose" && data && data.length > 0 && (
|
||||
<div className="px-6 pb-4">
|
||||
<ComposeRedeployAlert />
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="flex w-full flex-row gap-4">
|
||||
{isLoadingDomains ? (
|
||||
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
|
||||
@@ -413,7 +445,10 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
return (
|
||||
<Card
|
||||
key={item.domainId}
|
||||
className="relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit"
|
||||
className={cn(
|
||||
"relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit",
|
||||
!item.enabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -466,18 +501,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
description="Are you sure you want to delete this domain?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteDomain({
|
||||
domainId: item.domainId,
|
||||
})
|
||||
.then((_data) => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Domain deleted successfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting domain");
|
||||
});
|
||||
await handleDeleteDomain(item.domainId);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
@@ -492,15 +516,41 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full break-all">
|
||||
<Link
|
||||
className="flex items-center gap-2 text-base font-medium hover:underline"
|
||||
target="_blank"
|
||||
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
||||
>
|
||||
{item.host}
|
||||
<ExternalLink className="size-4 min-w-4" />
|
||||
</Link>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="w-full break-all">
|
||||
<Link
|
||||
className="flex items-center gap-2 text-base font-medium hover:underline"
|
||||
target="_blank"
|
||||
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
||||
>
|
||||
{item.host}
|
||||
<ExternalLink className="size-4 min-w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
{canCreateDomain && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center shrink-0">
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleEnable(item.domainId)
|
||||
}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{item.enabled
|
||||
? "Domain is active. Toggle to disable routing without deleting it."
|
||||
: "Domain is disabled and not routed. Toggle to enable it again."}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Domain Details */}
|
||||
|
||||
+13
-5
@@ -50,6 +50,7 @@ interface Props {
|
||||
id: string;
|
||||
type: "application" | "compose";
|
||||
serverId?: string;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
const RestoreBackupSchema = z.object({
|
||||
@@ -64,7 +65,12 @@ const RestoreBackupSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
||||
export const RestoreVolumeBackups = ({
|
||||
id,
|
||||
type,
|
||||
serverId,
|
||||
trigger,
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||
@@ -144,10 +150,12 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
Restore Volume Backup
|
||||
</Button>
|
||||
{trigger ?? (
|
||||
<Button variant="outline">
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
Restore Volume Backup
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -74,6 +74,7 @@ interface Props {
|
||||
databaseType?: DatabaseType;
|
||||
serverId?: string | null;
|
||||
backupType?: "database" | "compose";
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
const RestoreBackupSchema = z
|
||||
@@ -200,6 +201,7 @@ export const RestoreBackup = ({
|
||||
databaseType,
|
||||
serverId,
|
||||
backupType = "database",
|
||||
trigger,
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -311,10 +313,12 @@ export const RestoreBackup = ({
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
Restore Backup
|
||||
</Button>
|
||||
{trigger ?? (
|
||||
<Button variant="outline">
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
Restore Backup
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -14,10 +14,11 @@ import {
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Boxes,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircuitBoard,
|
||||
ExternalLink,
|
||||
GlobeIcon,
|
||||
Loader2,
|
||||
Rocket,
|
||||
Server,
|
||||
@@ -71,24 +72,26 @@ function getServiceInfo(d: DeploymentRow) {
|
||||
return {
|
||||
type: "Application" as const,
|
||||
name: app.name,
|
||||
icon: app.icon,
|
||||
projectId: app.environment.project.projectId,
|
||||
environmentId: app.environment.environmentId,
|
||||
projectName: app.environment.project.name,
|
||||
environmentName: app.environment.name,
|
||||
serviceId: app.applicationId,
|
||||
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
|
||||
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}?tab=deployments`,
|
||||
};
|
||||
}
|
||||
if (comp?.environment?.project && comp.environment) {
|
||||
return {
|
||||
type: "Compose" as const,
|
||||
name: comp.name,
|
||||
icon: comp.icon,
|
||||
projectId: comp.environment.project.projectId,
|
||||
environmentId: comp.environment.environmentId,
|
||||
projectName: comp.environment.project.name,
|
||||
environmentName: comp.environment.name,
|
||||
serviceId: comp.composeId,
|
||||
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
|
||||
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}?tab=deployments`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -175,10 +178,16 @@ export function ShowDeploymentsTable() {
|
||||
if (!info) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{info.type === "Application" ? (
|
||||
<Rocket className="size-4 text-muted-foreground shrink-0" />
|
||||
{info.icon ? (
|
||||
<img
|
||||
src={info.icon}
|
||||
alt={info.name}
|
||||
className="size-4 object-contain shrink-0"
|
||||
/>
|
||||
) : info.type === "Application" ? (
|
||||
<GlobeIcon className="size-4 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<Boxes className="size-4 text-muted-foreground shrink-0" />
|
||||
<CircuitBoard className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-medium truncate">{info.name}</span>
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Boxes,
|
||||
Database,
|
||||
Gauge,
|
||||
HardDrive,
|
||||
Layers,
|
||||
Loader2,
|
||||
type LucideIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { AppRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type DiskUsageItem =
|
||||
inferRouterOutputs<AppRouter>["dockerDiskUsage"]["getDiskUsage"][number];
|
||||
type BuildCacheBase =
|
||||
inferRouterOutputs<AppRouter>["dockerDiskUsage"]["getBuildCache"][number];
|
||||
type BuildCacheRow = BuildCacheBase & { key: string };
|
||||
|
||||
interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
const STAT_CARDS: { type: string; title: string; icon: LucideIcon }[] = [
|
||||
{ type: "Images", title: "Images", icon: Layers },
|
||||
{ type: "Containers", title: "Containers", icon: Boxes },
|
||||
{ type: "Local Volumes", title: "Volumes", icon: HardDrive },
|
||||
{ type: "Build Cache", title: "Build Cache", icon: Database },
|
||||
];
|
||||
|
||||
const SortableHeader = ({
|
||||
column,
|
||||
title,
|
||||
}: {
|
||||
column: {
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (asc: boolean) => void;
|
||||
};
|
||||
title: string;
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{title}
|
||||
<ArrowUpDown className="ml-2 size-4" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const StatCard = ({
|
||||
icon: Icon,
|
||||
title,
|
||||
item,
|
||||
isLoading,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
item?: DiskUsageItem;
|
||||
isLoading: boolean;
|
||||
}) => (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
{title}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="mt-3 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-2 text-2xl font-semibold">{item?.size ?? "-"}</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-3 text-xs text-muted-foreground">
|
||||
<span>{item?.totalCount ?? 0} total</span>
|
||||
<span>{item?.active ?? 0} active</span>
|
||||
<span>{item?.reclaimable ?? "-"} reclaimable</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
export const ShowDiskUsage = ({ serverId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "Size", desc: true },
|
||||
]);
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const { data: diskUsage, isLoading: isLoadingDiskUsage } =
|
||||
api.dockerDiskUsage.getDiskUsage.useQuery({ serverId });
|
||||
const { data: buildCache, isLoading: isLoadingBuildCache } =
|
||||
api.dockerDiskUsage.getBuildCache.useQuery({ serverId });
|
||||
const { mutateAsync: pruneBuildCache, isPending: isPruning } =
|
||||
api.dockerDiskUsage.pruneBuildCache.useMutation();
|
||||
|
||||
const usageByType = useMemo(
|
||||
() => new Map((diskUsage ?? []).map((item) => [item.type, item])),
|
||||
[diskUsage],
|
||||
);
|
||||
|
||||
const rows = useMemo<BuildCacheRow[]>(
|
||||
() =>
|
||||
(buildCache ?? []).map((entry) => ({
|
||||
...entry,
|
||||
key: entry.id,
|
||||
})),
|
||||
[buildCache],
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!globalFilter.trim()) {
|
||||
return rows;
|
||||
}
|
||||
const query = globalFilter.toLowerCase();
|
||||
return rows.filter(
|
||||
(entry) =>
|
||||
entry.id.toLowerCase().includes(query) ||
|
||||
entry.description.toLowerCase().includes(query) ||
|
||||
entry.type.toLowerCase().includes(query),
|
||||
);
|
||||
}, [rows, globalFilter]);
|
||||
|
||||
const handlePrune = async () => {
|
||||
try {
|
||||
await pruneBuildCache({ serverId });
|
||||
toast.success("Build cache pruned");
|
||||
await Promise.all([
|
||||
utils.dockerDiskUsage.getBuildCache.invalidate(),
|
||||
utils.dockerDiskUsage.getDiskUsage.invalidate(),
|
||||
]);
|
||||
} catch (error) {
|
||||
toast.error("Error pruning build cache", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnDef<BuildCacheRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Cache ID" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{row.original.id.slice(0, 16)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
header: ({ column }) => <SortableHeader column={column} title="Type" />,
|
||||
cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge>,
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div
|
||||
className="max-w-[360px] truncate text-xs text-muted-foreground"
|
||||
title={row.original.description}
|
||||
>
|
||||
{row.original.description || "-"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "Size",
|
||||
accessorFn: (entry) => entry.sizeBytes,
|
||||
header: ({ column }) => <SortableHeader column={column} title="Size" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">{row.original.size}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
accessorKey: "createdSince",
|
||||
header: "Created",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{row.original.createdSince}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "lastUsed",
|
||||
accessorKey: "lastUsedSince",
|
||||
header: "Last Used",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{row.original.lastUsedSince}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "usageCount",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Usage" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">
|
||||
{row.original.usageCount}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "flags",
|
||||
enableSorting: false,
|
||||
header: "Flags",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-1">
|
||||
{row.original.inUse && <Badge variant="blue">In use</Badge>}
|
||||
{row.original.shared && <Badge variant="outline">Shared</Badge>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
getRowId: (row) => row.key,
|
||||
state: {
|
||||
sorting,
|
||||
pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{STAT_CARDS.map((stat) => (
|
||||
<StatCard
|
||||
key={stat.type}
|
||||
icon={stat.icon}
|
||||
title={stat.title}
|
||||
item={usageByType.get(stat.type)}
|
||||
isLoading={isLoadingDiskUsage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Gauge className="size-6 text-muted-foreground self-center" />
|
||||
Build Cache
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Cache layers kept by the Docker builder on the selected
|
||||
server.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<DialogAction
|
||||
title="Prune build cache"
|
||||
description="This will remove all build cache entries that are not currently in use. This action cannot be undone."
|
||||
onClick={handlePrune}
|
||||
disabled={isPruning}
|
||||
>
|
||||
<Button variant="outline" size="sm" disabled={isPruning}>
|
||||
<Trash2 className="size-4 mr-1" />
|
||||
Prune build cache
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 py-8 border-t">
|
||||
{isLoadingBuildCache ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : !buildCache?.length ? (
|
||||
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<Database className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<p className="text-sm font-medium">No build cache found</p>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
Layers cached by "docker build" on this server will appear
|
||||
here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search by id, type or description..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
No build cache entries match your filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{table.getPageCount() > 1 && (
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,425 @@
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Activity, ArrowUpDown, Loader2, RefreshCw } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api, type RouterOutputs } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
type DockerEvent = RouterOutputs["docker"]["getEvents"]["events"][number];
|
||||
type EventRow = DockerEvent & { key: string };
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ label: "Last 5 minutes", value: 5 },
|
||||
{ label: "Last 15 minutes", value: 15 },
|
||||
{ label: "Last hour", value: 60 },
|
||||
{ label: "Last 6 hours", value: 360 },
|
||||
{ label: "Last 24 hours", value: 1440 },
|
||||
];
|
||||
|
||||
type BadgeVariant = "blue" | "green" | "yellow" | "orange" | "red" | "blank";
|
||||
|
||||
const TYPE_VARIANTS: Record<string, BadgeVariant> = {
|
||||
container: "blue",
|
||||
image: "green",
|
||||
volume: "yellow",
|
||||
network: "orange",
|
||||
service: "blue",
|
||||
node: "orange",
|
||||
};
|
||||
|
||||
const ACTION_VARIANTS: Record<string, BadgeVariant> = {
|
||||
create: "green",
|
||||
start: "green",
|
||||
pull: "green",
|
||||
connect: "green",
|
||||
die: "red",
|
||||
destroy: "red",
|
||||
kill: "red",
|
||||
stop: "red",
|
||||
remove: "red",
|
||||
disconnect: "red",
|
||||
pause: "yellow",
|
||||
unpause: "yellow",
|
||||
};
|
||||
|
||||
const getTypeVariant = (type?: string): BadgeVariant =>
|
||||
(type && TYPE_VARIANTS[type]) || "blank";
|
||||
|
||||
const getActionVariant = (action?: string): BadgeVariant =>
|
||||
(action && ACTION_VARIANTS[action]) || "blank";
|
||||
|
||||
const getResource = (event: DockerEvent) =>
|
||||
event.Actor?.Attributes?.name ?? event.Actor?.ID ?? "-";
|
||||
|
||||
const getAttributesText = (event: DockerEvent) => {
|
||||
const attributes = Object.entries(event.Actor?.Attributes ?? {}).filter(
|
||||
([key]) => key !== "name",
|
||||
);
|
||||
return attributes.map(([key, value]) => `${key}=${value}`).join(" ") || "-";
|
||||
};
|
||||
|
||||
const SortableHeader = ({
|
||||
column,
|
||||
title,
|
||||
}: {
|
||||
column: {
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (asc: boolean) => void;
|
||||
};
|
||||
title: string;
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{title}
|
||||
<ArrowUpDown className="ml-2 size-4" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
export const ShowDockerEvents = ({ serverId }: Props) => {
|
||||
const [minutes, setMinutes] = useState(15);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "time", desc: true },
|
||||
]);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 15,
|
||||
});
|
||||
|
||||
const { data, isLoading, isRefetching, refetch, error } =
|
||||
api.docker.getEvents.useQuery({
|
||||
serverId,
|
||||
minutes,
|
||||
});
|
||||
|
||||
const events = useMemo<EventRow[]>(
|
||||
() =>
|
||||
(data?.events ?? []).map((event, index) => ({
|
||||
...event,
|
||||
key: `${event.time}-${event.Action}-${index}`,
|
||||
})),
|
||||
[data],
|
||||
);
|
||||
|
||||
const filteredEvents = useMemo(() => {
|
||||
if (!search.trim()) return events;
|
||||
const query = search.toLowerCase();
|
||||
return events.filter((event) => {
|
||||
return (
|
||||
event.Type?.toLowerCase().includes(query) ||
|
||||
event.Action?.toLowerCase().includes(query) ||
|
||||
event.Actor?.Attributes?.name?.toLowerCase().includes(query) ||
|
||||
event.Actor?.ID?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [events, search]);
|
||||
|
||||
const columns = useMemo<ColumnDef<EventRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "time",
|
||||
accessorFn: (event) => event.time ?? 0,
|
||||
header: ({ column }) => <SortableHeader column={column} title="Time" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{row.original.time
|
||||
? new Date(row.original.time * 1000).toLocaleTimeString()
|
||||
: "-"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
accessorFn: (event) => event.Type ?? "",
|
||||
header: ({ column }) => <SortableHeader column={column} title="Type" />,
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={getTypeVariant(row.original.Type)}>
|
||||
{row.original.Type ?? "unknown"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
accessorFn: (event) => event.Action ?? "",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Action" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={getActionVariant(row.original.Action)}>
|
||||
{row.original.Action ?? "-"}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "resource",
|
||||
accessorFn: (event) => getResource(event),
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Resource" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resource = getResource(row.original);
|
||||
return (
|
||||
<div
|
||||
className="max-w-[220px] truncate font-mono text-xs"
|
||||
title={resource}
|
||||
>
|
||||
{resource}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "attributes",
|
||||
accessorFn: (event) => getAttributesText(event),
|
||||
header: "Attributes",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => {
|
||||
const attributesText = getAttributesText(row.original);
|
||||
return (
|
||||
<div
|
||||
className="max-w-[360px] truncate text-xs text-muted-foreground"
|
||||
title={attributesText}
|
||||
>
|
||||
{attributesText}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredEvents,
|
||||
columns,
|
||||
getRowId: (row) => row.key,
|
||||
state: {
|
||||
sorting,
|
||||
pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Activity className="size-6 text-muted-foreground self-center" />
|
||||
Docker Events
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Events reported by the Docker daemon, equivalent to running
|
||||
"docker events".
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isRefetching}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-4 mr-1 ${isRefetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 py-8 border-t">
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error.message}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Filter by type, action or name..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select
|
||||
value={String(minutes)}
|
||||
onValueChange={(value) => setMinutes(Number(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RANGE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={String(option.value)}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<span>Loading events...</span>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
No events found in the selected time range.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{table.getPageCount() > 1 && (
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Go to</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={table.getPageCount()}
|
||||
defaultValue={table.getState().pagination.pageIndex + 1}
|
||||
key={table.getState().pagination.pageIndex}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
const value = Number(
|
||||
(e.target as HTMLInputElement).value,
|
||||
);
|
||||
if (!Number.isFinite(value)) return;
|
||||
const page = Math.min(
|
||||
Math.max(value, 1),
|
||||
table.getPageCount(),
|
||||
);
|
||||
table.setPageIndex(page - 1);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
if (!Number.isFinite(value)) return;
|
||||
const page = Math.min(
|
||||
Math.max(value, 1),
|
||||
table.getPageCount(),
|
||||
);
|
||||
table.setPageIndex(page - 1);
|
||||
}}
|
||||
className="w-16 h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,454 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Cpu,
|
||||
Download,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Network,
|
||||
RefreshCw,
|
||||
Server as ServerIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
const bytesToGb = (bytes: number) => (bytes / 1024 ** 3).toFixed(1);
|
||||
const nanoCpusToCores = (nanoCpus: number) => (nanoCpus / 1e9).toFixed(2);
|
||||
const pct = (used: number, total: number) =>
|
||||
Math.min(100, Math.round((used / (total || 1)) * 100));
|
||||
|
||||
const SINCE_HOURS_OPTIONS = [
|
||||
{ label: "Last hour", value: 1 },
|
||||
{ label: "Last 6 hours", value: 6 },
|
||||
{ label: "Last 24 hours", value: 24 },
|
||||
{ label: "Last 7 days", value: 168 },
|
||||
];
|
||||
|
||||
export const ShowHealth = ({ serverId }: Props) => {
|
||||
const [sinceHours, setSinceHours] = useState(24);
|
||||
const {
|
||||
data: health,
|
||||
isFetching,
|
||||
refetch,
|
||||
isFetched,
|
||||
} = api.docker.getServerHealth.useQuery(
|
||||
{ serverId, sinceHours },
|
||||
{ refetchOnMount: false, refetchOnWindowFocus: false },
|
||||
);
|
||||
|
||||
const memUsedPct = health
|
||||
? pct(health.resources.memUsedBytes, health.resources.memTotalBytes)
|
||||
: 0;
|
||||
const diskUsedPct = health
|
||||
? pct(health.disk.usedBytes, health.disk.totalBytes)
|
||||
: 0;
|
||||
const inotifyUsedPct = health
|
||||
? pct(health.inotify.currentInstances, health.inotify.maxInstances)
|
||||
: 0;
|
||||
|
||||
const daemonLogsWindowText = health?.daemonLogsWindow
|
||||
? `Logs from ${new Date(health.daemonLogsWindow.fromEpoch * 1000).toLocaleString()} to ${new Date(health.daemonLogsWindow.toEpoch * 1000).toLocaleString()}`
|
||||
: null;
|
||||
const daemonErrorsText = [
|
||||
daemonLogsWindowText,
|
||||
health && health.daemonErrors.length > 0
|
||||
? health.daemonErrors.join("\n")
|
||||
: "(no daemon errors matched in this window)",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
const buildHealthReport = () => {
|
||||
if (!health) return "";
|
||||
const lines: string[] = [];
|
||||
lines.push("# Dokploy server health report");
|
||||
lines.push(`Generated: ${new Date(health.checkedAt).toLocaleString()}`);
|
||||
lines.push(
|
||||
`Window: ${SINCE_HOURS_OPTIONS.find((o) => o.value === sinceHours)?.label ?? `${sinceHours}h`}`,
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Containers & services");
|
||||
lines.push(`Containers: ${health.containers.containerCount}`);
|
||||
lines.push(`Swarm services: ${health.containers.serviceCount}`);
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Host resources");
|
||||
lines.push(
|
||||
`Memory: ${bytesToGb(health.resources.memUsedBytes)} / ${bytesToGb(health.resources.memTotalBytes)} GB`,
|
||||
);
|
||||
lines.push(`CPU cores: ${health.resources.cpuCount}`);
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Disk (/)");
|
||||
lines.push(
|
||||
`${bytesToGb(health.disk.usedBytes)} / ${bytesToGb(health.disk.totalBytes)} GB`,
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Inotify");
|
||||
lines.push(
|
||||
`max_user_instances: ${health.inotify.currentInstances} / ${health.inotify.maxInstances}`,
|
||||
);
|
||||
lines.push(`max_user_watches: ${health.inotify.maxWatches}`);
|
||||
lines.push(`max_queued_events: ${health.inotify.maxQueuedEvents}`);
|
||||
lines.push(
|
||||
`Persisted in sysctl: ${health.inotify.persisted ? "yes" : "no"}`,
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Docker networks");
|
||||
lines.push(`Total networks: ${health.dockerNetworks.count}`);
|
||||
if (health.dockerNetworks.addressPools) {
|
||||
lines.push(
|
||||
`default-address-pools: ${JSON.stringify(health.dockerNetworks.addressPools)}`,
|
||||
);
|
||||
}
|
||||
if (health.dockerNetworks.usageError) {
|
||||
lines.push(
|
||||
`Could not read network IP usage: ${health.dockerNetworks.usageError}`,
|
||||
);
|
||||
} else if (health.dockerNetworks.usage.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"| Network | Driver | Subnet | IPs in use | Capacity | Usage |",
|
||||
);
|
||||
lines.push("|---|---|---|---|---|---|");
|
||||
for (const n of health.dockerNetworks.usage) {
|
||||
lines.push(
|
||||
`| ${n.name} | ${n.driver} | ${n.subnet ?? "auto"} | ${n.containersInUse} | ${n.capacity ?? "—"} | ${n.percentUsed ?? "—"}% |`,
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
if (health.reservation) {
|
||||
lines.push("## Memory/CPU reservation");
|
||||
lines.push(
|
||||
`Reserved memory: ${bytesToGb(health.reservation.memoryReservedBytes)} GB`,
|
||||
);
|
||||
lines.push(
|
||||
`Reserved CPU: ${nanoCpusToCores(health.reservation.cpuReservedNanoCpus)} cores`,
|
||||
);
|
||||
lines.push(`Across ${health.reservation.appCount} Application services`);
|
||||
if (health.reservation.unsupportedComposeCount > 0) {
|
||||
lines.push(
|
||||
`${health.reservation.unsupportedComposeCount} Compose service(s) not included (not tracked per-service)`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("## Docker daemon errors (raw)");
|
||||
lines.push("```");
|
||||
lines.push(daemonErrorsText);
|
||||
lines.push("```");
|
||||
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const report = buildHealthReport();
|
||||
if (!report) return;
|
||||
const blob = new Blob([report], { type: "text/markdown" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `dokploy-health-report-${new Date().toISOString().replace(/[:.]/g, "-")}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Server diagnostics</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-xl">
|
||||
Runs a read-only check over SSH (inotify limits, disk, Docker
|
||||
network pool, daemon errors). Runs automatically when you open
|
||||
this tab — click Re-check to refresh.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={String(sinceHours)}
|
||||
onValueChange={(v) => setSinceHours(Number(v))}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SINCE_HOURS_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={String(opt.value)}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => refetch()} disabled={isFetching}>
|
||||
{isFetching ? (
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
)}
|
||||
{isFetched ? "Re-check" : "Check server health"}
|
||||
</Button>
|
||||
{health && !health.error && (
|
||||
<Button variant="outline" onClick={handleDownload}>
|
||||
<Download className="size-4 mr-2" />
|
||||
Download report
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFetching && !health && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground border border-dashed rounded-xl">
|
||||
<Loader2 className="size-8 animate-spin" />
|
||||
<span>Checking server health…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{health?.error && (
|
||||
<div className="text-sm text-destructive border border-destructive/30 bg-destructive/10 rounded-lg p-3">
|
||||
Couldn't read server health: {health.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{health && !health.error && (
|
||||
<div
|
||||
className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 transition-opacity ${isFetching ? "opacity-50" : ""}`}
|
||||
>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium mb-2">
|
||||
<ServerIcon className="size-4 text-muted-foreground" />
|
||||
Containers & services
|
||||
</div>
|
||||
<div className="text-2xl font-semibold">
|
||||
{health.containers.containerCount}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
containers · {health.containers.serviceCount} swarm services
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium mb-2">
|
||||
<Cpu className="size-4 text-muted-foreground" />
|
||||
Host resources
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
Memory: {bytesToGb(health.resources.memUsedBytes)} /{" "}
|
||||
{bytesToGb(health.resources.memTotalBytes)} GB
|
||||
</div>
|
||||
<Progress value={memUsedPct} className="h-2 mt-1" />
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{health.resources.cpuCount} CPU cores
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium mb-2">
|
||||
<HardDrive className="size-4 text-muted-foreground" />
|
||||
Disk (/)
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{bytesToGb(health.disk.usedBytes)} /{" "}
|
||||
{bytesToGb(health.disk.totalBytes)} GB
|
||||
</div>
|
||||
<Progress value={diskUsedPct} className="h-2 mt-1" />
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between gap-2 text-sm font-medium mb-2">
|
||||
<span className="flex items-center gap-2">
|
||||
<AlertTriangle className="size-4 text-muted-foreground" />
|
||||
Inotify
|
||||
</span>
|
||||
<Badge
|
||||
variant={health.inotify.persisted ? "default" : "secondary"}
|
||||
>
|
||||
{health.inotify.persisted ? "persisted" : "runtime only"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
max_user_instances: {health.inotify.currentInstances} /{" "}
|
||||
{health.inotify.maxInstances.toLocaleString()} (
|
||||
{inotifyUsedPct}%)
|
||||
</div>
|
||||
<Progress value={inotifyUsedPct} className="h-2 mt-1" />
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
max_user_watches: {health.inotify.maxWatches.toLocaleString()}{" "}
|
||||
· max_queued_events:{" "}
|
||||
{health.inotify.maxQueuedEvents.toLocaleString()}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium mb-2">
|
||||
<Network className="size-4 text-muted-foreground" />
|
||||
Docker networks
|
||||
</div>
|
||||
<div className="text-2xl font-semibold">
|
||||
{health.dockerNetworks.count}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
The default Docker address pool is exhausted around ~30
|
||||
networks unless <code>default-address-pools</code> is
|
||||
configured in <code>/etc/docker/daemon.json</code>.
|
||||
{health.dockerNetworks.addressPools
|
||||
? " Custom pool detected on this server."
|
||||
: ""}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{health.reservation && (
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium mb-2">
|
||||
<Cpu className="size-4 text-muted-foreground" />
|
||||
Memory/CPU reservation
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{bytesToGb(health.reservation.memoryReservedBytes)} GB
|
||||
reserved ·{" "}
|
||||
{nanoCpusToCores(health.reservation.cpuReservedNanoCpus)}{" "}
|
||||
CPUs reserved
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
Across {health.reservation.appCount} Application services.
|
||||
{health.reservation.unsupportedComposeCount > 0
|
||||
? ` ${health.reservation.unsupportedComposeCount} Compose service(s) on this server aren't included — reservations aren't tracked per-service for Compose.`
|
||||
: ""}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{health &&
|
||||
!health.error &&
|
||||
(health.dockerNetworks.usage.length > 0 ||
|
||||
health.dockerNetworks.usageError) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h4 className="text-sm font-medium flex items-center gap-2">
|
||||
<Network className="size-4 text-muted-foreground" />
|
||||
Network IP usage
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
IPs assigned to containers vs. the subnet's usable capacity,
|
||||
per network — including reserved networks like{" "}
|
||||
<code>dokploy-network</code>. A network stuck near 100% blocks
|
||||
new containers from starting even when the host has plenty of
|
||||
other resources free.
|
||||
</p>
|
||||
{health.dockerNetworks.usageError ? (
|
||||
<div className="text-sm text-destructive border border-destructive/30 bg-destructive/10 rounded-lg p-3">
|
||||
Couldn't read network IP usage:{" "}
|
||||
{health.dockerNetworks.usageError}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Network</TableHead>
|
||||
<TableHead>Driver</TableHead>
|
||||
<TableHead>Subnet</TableHead>
|
||||
<TableHead>IPs in use</TableHead>
|
||||
<TableHead>Usage</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{health.dockerNetworks.usage.map((n) => (
|
||||
<TableRow key={n.name}>
|
||||
<TableCell className="font-medium">
|
||||
{n.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{n.driver}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{n.subnet ?? "Auto"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{n.capacity !== null
|
||||
? `${n.containersInUse} / ${n.capacity}`
|
||||
: n.containersInUse}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{n.percentUsed !== null ? (
|
||||
<Badge
|
||||
variant={
|
||||
n.percentUsed >= 90
|
||||
? "red"
|
||||
: n.percentUsed >= 70
|
||||
? "yellow"
|
||||
: "green"
|
||||
}
|
||||
>
|
||||
{n.percentUsed}%
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{health && !health.error && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h4 className="text-sm font-medium">
|
||||
Docker daemon errors (
|
||||
{SINCE_HOURS_OPTIONS.find(
|
||||
(o) => o.value === sinceHours,
|
||||
)?.label.toLowerCase()}
|
||||
)
|
||||
</h4>
|
||||
<CodeEditor
|
||||
value={daemonErrorsText}
|
||||
language="shell"
|
||||
disabled
|
||||
lineWrapping
|
||||
wrapperClassName="h-64 rounded-lg border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,470 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import type { inferRouterOutputs } from "@trpc/server";
|
||||
import { ArrowUpDown, Eye, Layers, Loader2, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { AppRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type ImageBase =
|
||||
inferRouterOutputs<AppRouter>["dockerImage"]["getImages"][number];
|
||||
type ImageRow = ImageBase & { key: string };
|
||||
|
||||
interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
const SIZE_UNITS: Record<string, number> = {
|
||||
b: 1,
|
||||
kb: 1e3,
|
||||
mb: 1e6,
|
||||
gb: 1e9,
|
||||
tb: 1e12,
|
||||
};
|
||||
|
||||
const parseSize = (size?: string) => {
|
||||
if (!size) return -1;
|
||||
const match = /^([\d.]+)\s*([a-zA-Z]+)$/.exec(size.trim());
|
||||
if (!match?.[1] || !match[2]) return -1;
|
||||
return Number(match[1]) * (SIZE_UNITS[match[2].toLowerCase()] ?? 1);
|
||||
};
|
||||
|
||||
const getReference = (image: ImageBase) =>
|
||||
image.Repository !== "<none>" && image.Tag !== "<none>"
|
||||
? `${image.Repository}:${image.Tag}`
|
||||
: image.ID;
|
||||
|
||||
const FORCE_HINT_REGEX =
|
||||
/must be forced|image is being used|referenced in multiple repositories/i;
|
||||
|
||||
const SortableHeader = ({
|
||||
column,
|
||||
title,
|
||||
}: {
|
||||
column: {
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (asc: boolean) => void;
|
||||
};
|
||||
title: string;
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{title}
|
||||
<ArrowUpDown className="ml-2 size-4" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const ShowImageConfig = ({
|
||||
imageRef,
|
||||
serverId,
|
||||
}: {
|
||||
imageRef: string;
|
||||
serverId?: string;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data, isLoading, error } = api.dockerImage.getImageConfig.useQuery(
|
||||
{ imageRef, serverId },
|
||||
{ enabled: open },
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="View image config">
|
||||
<Eye className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Image Config</DialogTitle>
|
||||
<DialogDescription>
|
||||
docker image inspect output for "{imageRef}"
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error ? (
|
||||
<AlertBlock type="error">{error.message}</AlertBlock>
|
||||
) : isLoading ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-wrap rounded-lg border p-4 overflow-y-auto text-sm bg-card max-h-[80vh]">
|
||||
<code>
|
||||
<pre className="whitespace-pre-wrap wrap-break-word">
|
||||
<CodeEditor
|
||||
language="json"
|
||||
lineWrapping
|
||||
lineNumbers={false}
|
||||
readOnly
|
||||
value={JSON.stringify(data, null, 2)}
|
||||
/>
|
||||
</pre>
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShowImages = ({ serverId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "Repository", desc: false },
|
||||
]);
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [forcePrompt, setForcePrompt] = useState<{
|
||||
image: ImageRow;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
const { data: images, isLoading } = api.dockerImage.getImages.useQuery({
|
||||
serverId,
|
||||
});
|
||||
const { mutateAsync: removeImage } =
|
||||
api.dockerImage.removeImage.useMutation();
|
||||
|
||||
const rows = useMemo<ImageRow[]>(
|
||||
() =>
|
||||
(images ?? []).map((image) => ({
|
||||
...image,
|
||||
key: `${image.ID}-${image.Repository}-${image.Tag}`,
|
||||
})),
|
||||
[images],
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!globalFilter.trim()) {
|
||||
return rows;
|
||||
}
|
||||
const query = globalFilter.toLowerCase();
|
||||
return rows.filter(
|
||||
(image) =>
|
||||
image.Repository.toLowerCase().includes(query) ||
|
||||
image.Tag.toLowerCase().includes(query) ||
|
||||
image.ID.toLowerCase().includes(query),
|
||||
);
|
||||
}, [rows, globalFilter]);
|
||||
|
||||
const handleDelete = async (image: ImageRow, force = false) => {
|
||||
try {
|
||||
await removeImage({
|
||||
repository: image.Repository,
|
||||
tag: image.Tag,
|
||||
id: image.ID,
|
||||
force,
|
||||
serverId,
|
||||
});
|
||||
toast.success("Image deleted");
|
||||
setForcePrompt(null);
|
||||
await utils.dockerImage.getImages.invalidate();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
if (!force && FORCE_HINT_REGEX.test(message)) {
|
||||
setForcePrompt({ image, message });
|
||||
} else {
|
||||
toast.error("Error deleting image", { description: message });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnDef<ImageRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "Repository",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Repository" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div
|
||||
className="max-w-[280px] truncate font-medium"
|
||||
title={row.original.Repository}
|
||||
>
|
||||
{row.original.Repository}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "Tag",
|
||||
header: ({ column }) => <SortableHeader column={column} title="Tag" />,
|
||||
cell: ({ row }) => <Badge variant="outline">{row.original.Tag}</Badge>,
|
||||
},
|
||||
{
|
||||
accessorKey: "ID",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Image ID" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{row.original.ID}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "Size",
|
||||
accessorFn: (image) => parseSize(image.Size),
|
||||
header: ({ column }) => <SortableHeader column={column} title="Size" />,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground">{row.original.Size}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "Created",
|
||||
accessorFn: (image) => new Date(image.CreatedAt).getTime() || 0,
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground whitespace-nowrap">
|
||||
{row.original.CreatedSince}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableSorting: false,
|
||||
header: () => <div className="text-right">Actions</div>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<ShowImageConfig
|
||||
imageRef={getReference(row.original)}
|
||||
serverId={serverId}
|
||||
/>
|
||||
<DialogAction
|
||||
title="Delete image"
|
||||
description={`The image "${getReference(row.original)}" will be removed from Docker. This action cannot be undone.`}
|
||||
onClick={() => handleDelete(row.original)}
|
||||
>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Delete image">
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[serverId],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
getRowId: (row) => row.key,
|
||||
state: {
|
||||
sorting,
|
||||
pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="">
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Layers className="size-6 text-muted-foreground self-center" />
|
||||
Images
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage the Docker images of the selected server.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4 py-8 border-t">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : !images?.length ? (
|
||||
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
|
||||
<div className="rounded-full bg-muted p-4">
|
||||
<Layers className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<p className="text-sm font-medium">No images found</p>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
Docker images pulled or built on this server will appear
|
||||
here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search by repository, tag or id..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
No images match your filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{table.getPageCount() > 1 && (
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
<AlertDialog
|
||||
open={!!forcePrompt}
|
||||
onOpenChange={(open) => !open && setForcePrompt(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Image is in use</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{forcePrompt?.message} Do you want to force the deletion? This may
|
||||
affect containers still using this image.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setForcePrompt(null)}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
forcePrompt && handleDelete(forcePrompt.image, true)
|
||||
}
|
||||
>
|
||||
Force delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -26,6 +26,11 @@ interface Props {
|
||||
serviceId?: string;
|
||||
}
|
||||
|
||||
// Sentinel the container-picker views fall back to before a real container
|
||||
// is selected/auto-selected — querying logs for it just surfaces Docker's
|
||||
// raw "No such container: select-a-container" daemon error.
|
||||
const PLACEHOLDER_CONTAINER_ID = "select-a-container";
|
||||
|
||||
export const priorities = [
|
||||
{
|
||||
label: "Info",
|
||||
@@ -55,13 +60,16 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
runType,
|
||||
serviceId,
|
||||
}) => {
|
||||
const hasContainer =
|
||||
!!containerId && containerId !== PLACEHOLDER_CONTAINER_ID;
|
||||
|
||||
const { data } = api.docker.getConfig.useQuery(
|
||||
{
|
||||
containerId,
|
||||
serverId: serverId ?? undefined,
|
||||
},
|
||||
{
|
||||
enabled: !!containerId,
|
||||
enabled: hasContainer,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -134,7 +142,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerId) return;
|
||||
if (!hasContainer) return;
|
||||
|
||||
let isCurrentConnection = true;
|
||||
let noDataTimeout: NodeJS.Timeout;
|
||||
@@ -425,10 +433,15 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
) : hasContainer ? (
|
||||
<div className="flex justify-center items-center h-full text-muted-foreground">
|
||||
No logs found
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center items-center h-full text-center text-sm text-muted-foreground px-8">
|
||||
Select a container above to view its logs. If none are listed,
|
||||
make sure the service is deployed and running.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useEffect, useRef } from "react";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { AttachAddon } from "@xterm/addon-attach";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { fixMacOsAltKeys } from "@/lib/terminal-keyboard";
|
||||
@@ -14,6 +15,10 @@ interface Props {
|
||||
serviceId?: string;
|
||||
}
|
||||
|
||||
// Sentinel the container-picker modal renders with before a real container
|
||||
// is selected/auto-selected — never worth opening a connection for.
|
||||
const PLACEHOLDER_CONTAINER_ID = "select-a-container";
|
||||
|
||||
export const DockerTerminal: React.FC<Props> = ({
|
||||
id,
|
||||
containerId,
|
||||
@@ -24,56 +29,105 @@ export const DockerTerminal: React.FC<Props> = ({
|
||||
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
|
||||
const { resolvedTheme } = useTheme();
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(id);
|
||||
if (container) {
|
||||
container.innerHTML = "";
|
||||
if (!containerId || containerId === PLACEHOLDER_CONTAINER_ID) {
|
||||
return;
|
||||
}
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
lineHeight: 1.4,
|
||||
convertEol: true,
|
||||
theme: {
|
||||
cursor: resolvedTheme === "light" ? "#000000" : "transparent",
|
||||
background: "rgba(0, 0, 0, 0)",
|
||||
foreground: "currentColor",
|
||||
},
|
||||
|
||||
let cancelled = false;
|
||||
let term: Terminal | null = null;
|
||||
let ws: WebSocket | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
// Deferred a frame so React StrictMode's dev-only phantom mount+cleanup
|
||||
// (which runs synchronously, before anything paints) never creates a
|
||||
// terminal in the first place — dodges a known xterm.js dispose race
|
||||
// (xtermjs/xterm.js#5011) where a deferred internal callback outlives
|
||||
// term.dispose() and reads renderer state that's already gone.
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (cancelled) return;
|
||||
|
||||
const container = document.getElementById(id);
|
||||
if (container) {
|
||||
container.innerHTML = "";
|
||||
}
|
||||
term = new Terminal({
|
||||
cursorBlink: true,
|
||||
lineHeight: 1.4,
|
||||
convertEol: true,
|
||||
theme: {
|
||||
cursor: resolvedTheme === "light" ? "#000000" : "transparent",
|
||||
background: "rgba(0, 0, 0, 0)",
|
||||
foreground: "currentColor",
|
||||
},
|
||||
});
|
||||
const addonFit = new FitAddon();
|
||||
const clipboardAddon = new ClipboardAddon();
|
||||
term.loadAddon(clipboardAddon);
|
||||
fixMacOsAltKeys(term);
|
||||
// @ts-expect-error
|
||||
term.open(termRef.current);
|
||||
term.loadAddon(addonFit);
|
||||
addonFit.fit();
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}&cols=${term.cols}&rows=${term.rows}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
const addonAttach = new AttachAddon(ws);
|
||||
term.loadAddon(addonAttach);
|
||||
|
||||
const sendResize = (cols: number, rows: number) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
};
|
||||
term.onResize(({ cols, rows }) => sendResize(cols, rows));
|
||||
|
||||
resizeObserver = new ResizeObserver(() => addonFit.fit());
|
||||
if (termRef.current) {
|
||||
resizeObserver.observe(termRef.current);
|
||||
}
|
||||
});
|
||||
const addonFit = new FitAddon();
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
const addonAttach = new AttachAddon(ws);
|
||||
fixMacOsAltKeys(term);
|
||||
// @ts-ignore
|
||||
term.open(termRef.current);
|
||||
// @ts-ignore
|
||||
term.loadAddon(addonFit);
|
||||
term.loadAddon(addonAttach);
|
||||
addonFit.fit();
|
||||
return () => {
|
||||
ws.readyState === WebSocket.OPEN && ws.close();
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(frame);
|
||||
resizeObserver?.disconnect();
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
term?.dispose();
|
||||
};
|
||||
}, [containerId, activeWay, id]);
|
||||
|
||||
const hasContainer =
|
||||
!!containerId && containerId !== PLACEHOLDER_CONTAINER_ID;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<span>
|
||||
Select way to connect to <b>{containerId}</b>
|
||||
</span>
|
||||
<Tabs value={activeWay} onValueChange={setActiveWay}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="bash">Bash</TabsTrigger>
|
||||
<TabsTrigger value="sh">/bin/sh</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="w-full h-full rounded-lg p-2 bg-transparent border">
|
||||
<div id={id} ref={termRef} />
|
||||
</div>
|
||||
{hasContainer && (
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<span>
|
||||
Select way to connect to <b>{containerId}</b>
|
||||
</span>
|
||||
<Tabs value={activeWay} onValueChange={setActiveWay}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="bash">Bash</TabsTrigger>
|
||||
<TabsTrigger value="sh">/bin/sh</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
{hasContainer ? (
|
||||
<div className="w-full h-[420px] rounded-lg p-2 bg-transparent border">
|
||||
<div id={id} ref={termRef} className="h-full" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-[420px] w-full items-center justify-center rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
Select a container above to open a terminal. If none are listed, make
|
||||
sure the service is deployed and running.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -223,7 +223,7 @@ export const ShowHome = () => {
|
||||
</div>
|
||||
{canReadDeployments && (
|
||||
<Link
|
||||
href="/dashboard/deployments"
|
||||
href="/dashboard/overview?tab=deployments"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
view all →
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -103,16 +102,19 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{organizationId ? (
|
||||
<DropdownMenuItem
|
||||
className="group cursor-pointer hover:bg-blue-500/10"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
title="Edit organization"
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</DropdownMenuItem>
|
||||
</Button>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="gap-2 p-2"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md p-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
|
||||
<Plus className="size-4" />
|
||||
@@ -120,7 +122,7 @@ export function AddOrganization({ organizationId }: Props) {
|
||||
<div className="font-medium text-muted-foreground">
|
||||
Add organization
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import type { OverviewBackup } from "@dokploy/server/services/overview-shared";
|
||||
import { getBackupOverviewIcon } from "@dokploy/server/services/overview-shared";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
CircuitBoard,
|
||||
GlobeIcon,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
ServerIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { DB_ENGINE_ICONS } from "@/components/icons/data-tools-icons";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const STATUS_VARIANT: Record<string, "default" | "destructive" | "secondary"> =
|
||||
{
|
||||
done: "default",
|
||||
running: "secondary",
|
||||
error: "destructive",
|
||||
cancelled: "destructive",
|
||||
};
|
||||
|
||||
const renderRowIcon = (row: OverviewBackup) => {
|
||||
const icon = getBackupOverviewIcon(row);
|
||||
if (icon.kind === "db") {
|
||||
const Icon = DB_ENGINE_ICONS[icon.engine as keyof typeof DB_ENGINE_ICONS];
|
||||
return Icon ? <Icon className="h-5 w-5" /> : null;
|
||||
}
|
||||
if (icon.kind === "webServer") {
|
||||
return <ServerIcon className="h-5 w-5 text-muted-foreground" />;
|
||||
}
|
||||
return icon.type === "compose" ? (
|
||||
<CircuitBoard className="h-5 w-5" />
|
||||
) : (
|
||||
<GlobeIcon className="h-5 w-5" />
|
||||
);
|
||||
};
|
||||
|
||||
const detailHref = (row: OverviewBackup) => {
|
||||
if (!row.projectId || !row.environmentId || !row.serviceOwnerId) return null;
|
||||
return `/dashboard/project/${row.projectId}/environment/${row.environmentId}/services/${row.serviceOwnerType}/${row.serviceOwnerId}`;
|
||||
};
|
||||
|
||||
export const ShowOverviewBackups = () => {
|
||||
const {
|
||||
data: backups,
|
||||
isFetching,
|
||||
isFetched,
|
||||
isError,
|
||||
refetch,
|
||||
} = api.overview.backups.useQuery(undefined, {
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const [destinationId, setDestinationId] = useState("all");
|
||||
const [serviceOwnerId, setServiceOwnerId] = useState("all");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||
|
||||
const destinations = useMemo(() => {
|
||||
if (!backups) return [];
|
||||
const map = new Map<string, string>();
|
||||
for (const row of backups) map.set(row.destinationId, row.destinationName);
|
||||
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||
}, [backups]);
|
||||
|
||||
const services = useMemo(() => {
|
||||
if (!backups) return [];
|
||||
const map = new Map<string, string>();
|
||||
for (const row of backups) {
|
||||
if (row.serviceOwnerId) map.set(row.serviceOwnerId, row.serviceName);
|
||||
}
|
||||
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||
}, [backups]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!backups) return [];
|
||||
const filtered = backups.filter(
|
||||
(row) =>
|
||||
(destinationId === "all" || row.destinationId === destinationId) &&
|
||||
(serviceOwnerId === "all" || row.serviceOwnerId === serviceOwnerId),
|
||||
);
|
||||
const sorted = [...filtered].sort(
|
||||
(a, b) =>
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
);
|
||||
return sortDirection === "asc" ? sorted : sorted.reverse();
|
||||
}, [backups, destinationId, serviceOwnerId, sortDirection]);
|
||||
|
||||
return (
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3 justify-between">
|
||||
<h3 className="text-lg font-medium">
|
||||
Backups{" "}
|
||||
{isFetched && (
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
({rows.length})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isFetched && (
|
||||
<>
|
||||
<Select value={destinationId} onValueChange={setDestinationId}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Destination" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All destinations</SelectItem>
|
||||
{destinations.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={serviceOwnerId}
|
||||
onValueChange={setServiceOwnerId}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All services</SelectItem>
|
||||
{services.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setSortDirection((d) => (d === "desc" ? "asc" : "desc"))
|
||||
}
|
||||
>
|
||||
<ArrowUpDown className="size-4 mr-2" />
|
||||
{sortDirection === "desc" ? "Newest first" : "Oldest first"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={() => refetch()} disabled={isFetching} size="sm">
|
||||
{isFetching ? (
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<RefreshCw className="size-4 mr-2" />
|
||||
)}
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFetching && !isFetched && (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading backups...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFetched && isError && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
|
||||
<span>Failed to load backups.</span>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFetched && !isError && rows.length === 0 && (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground">
|
||||
No backups match the current filters.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFetched && !isError && rows.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Service</TableHead>
|
||||
<TableHead>Destination</TableHead>
|
||||
<TableHead>Kind</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const href = detailHref(row);
|
||||
const name = href ? (
|
||||
<Link href={href} className="hover:underline">
|
||||
{row.serviceName}
|
||||
</Link>
|
||||
) : (
|
||||
row.serviceName
|
||||
);
|
||||
return (
|
||||
<TableRow key={row.deploymentId}>
|
||||
<TableCell className="whitespace-nowrap text-sm">
|
||||
{new Date(row.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{renderRowIcon(row)}
|
||||
{name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{row.destinationName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{row.kind === "backup" ? "Database" : "Volume"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.status && (
|
||||
<Badge
|
||||
variant={STATUS_VARIANT[row.status] ?? "secondary"}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Rocket } from "lucide-react";
|
||||
import { useRouter } from "next/router";
|
||||
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
|
||||
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
const SUBTAB_VALUES = ["deployments", "queue"] as const;
|
||||
type SubtabValue = (typeof SUBTAB_VALUES)[number];
|
||||
const DEFAULT_SUBTAB: SubtabValue = "deployments";
|
||||
|
||||
function isValidSubtab(t: string): t is SubtabValue {
|
||||
return SUBTAB_VALUES.includes(t as SubtabValue);
|
||||
}
|
||||
|
||||
export const ShowOverviewDeployments = () => {
|
||||
const router = useRouter();
|
||||
const subtab =
|
||||
typeof router.query.subtab === "string" &&
|
||||
isValidSubtab(router.query.subtab)
|
||||
? router.query.subtab
|
||||
: DEFAULT_SUBTAB;
|
||||
|
||||
const setSubtab = (value: string) => {
|
||||
if (!isValidSubtab(value)) return;
|
||||
const { subtab: _current, ...query } = router.query;
|
||||
router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: value === DEFAULT_SUBTAB ? query : { ...query, subtab: value },
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[45vh]">
|
||||
<div className="rounded-xl bg-background shadow-md h-full">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||
<Rocket className="size-5" />
|
||||
Deployments
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
All application and compose deployments in one place.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs
|
||||
value={subtab}
|
||||
onValueChange={setSubtab}
|
||||
className="w-full min-w-0"
|
||||
>
|
||||
<TabsList className="mt-2">
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="queue">Queue</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
|
||||
<ShowDeploymentsTable />
|
||||
</TabsContent>
|
||||
<TabsContent value="queue" className="mt-0 pt-4">
|
||||
<ShowQueueTable />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardHeader>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,383 @@
|
||||
import type { OverviewDomainSortBy } from "@dokploy/server/services/overview-shared";
|
||||
import { sortOverviewDomains } from "@dokploy/server/services/overview-shared";
|
||||
import { ExternalLink, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { COMPOSE_REDEPLOY_TOAST } from "@/components/dashboard/application/domains/redeploy-hint";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useSortPreference } from "@/hooks/use-sort-preference";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200];
|
||||
|
||||
const SORT_OPTIONS: { value: OverviewDomainSortBy; label: string }[] = [
|
||||
{ value: "createdAt-desc", label: "Newest first" },
|
||||
{ value: "createdAt-asc", label: "Oldest first" },
|
||||
{ value: "port-asc", label: "Port (low-high)" },
|
||||
{ value: "port-desc", label: "Port (high-low)" },
|
||||
];
|
||||
|
||||
const SORT_VALUES = SORT_OPTIONS.map((opt) => opt.value);
|
||||
|
||||
export const ShowOverviewDomains = () => {
|
||||
const utils = api.useUtils();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canToggleDomain = permissions?.domain.create ?? false;
|
||||
|
||||
const { data: domains, isLoading } = api.overview.domains.useQuery();
|
||||
const { data: allProjects } = api.project.all.useQuery();
|
||||
|
||||
const { mutateAsync: toggleEnable, isPending: isToggling } =
|
||||
api.domain.toggleEnable.useMutation();
|
||||
|
||||
const [selectedProjectId, setSelectedProjectId] = useState("all");
|
||||
const [selectedStatus, setSelectedStatus] = useState("all");
|
||||
const [selectedSsl, setSelectedSsl] = useState("all");
|
||||
const [selectedPort, setSelectedPort] = useState("all");
|
||||
const [sortBy, setSort] = useSortPreference<OverviewDomainSortBy>(
|
||||
"overviewDomainsSort",
|
||||
"createdAt-desc",
|
||||
SORT_VALUES,
|
||||
);
|
||||
|
||||
const handleToggleEnable = async (
|
||||
domain: NonNullable<typeof domains>[number],
|
||||
) => {
|
||||
try {
|
||||
const result = await toggleEnable({ domainId: domain.domainId });
|
||||
utils.overview.domains.invalidate();
|
||||
toast.success(
|
||||
result.enabled ? "Domain enabled" : "Domain disabled",
|
||||
result.requiresRedeploy
|
||||
? { description: COMPOSE_REDEPLOY_TOAST }
|
||||
: undefined,
|
||||
);
|
||||
} catch {
|
||||
toast.error(`Error updating "${domain.host}"`);
|
||||
}
|
||||
};
|
||||
|
||||
const availablePorts = useMemo(() => {
|
||||
if (!domains) return [];
|
||||
const ports = new Set<number>();
|
||||
for (const domain of domains) {
|
||||
if (domain.port !== null) ports.add(domain.port);
|
||||
}
|
||||
return Array.from(ports).sort((a, b) => a - b);
|
||||
}, [domains]);
|
||||
|
||||
const filteredDomains = useMemo(() => {
|
||||
if (!domains) return [];
|
||||
const filtered = domains.filter(
|
||||
(domain) =>
|
||||
(selectedProjectId === "all" ||
|
||||
domain.projectId === selectedProjectId) &&
|
||||
(selectedStatus === "all" ||
|
||||
(selectedStatus === "enabled" ? domain.enabled : !domain.enabled)) &&
|
||||
(selectedSsl === "all" ||
|
||||
(selectedSsl === "https" ? domain.https : !domain.https)) &&
|
||||
(selectedPort === "all" || domain.port === Number(selectedPort)),
|
||||
);
|
||||
return sortOverviewDomains(filtered, sortBy);
|
||||
}, [
|
||||
domains,
|
||||
selectedProjectId,
|
||||
selectedStatus,
|
||||
selectedSsl,
|
||||
selectedPort,
|
||||
sortBy,
|
||||
]);
|
||||
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const pageCount = Math.max(1, Math.ceil(filteredDomains.length / pageSize));
|
||||
const currentPageIndex = Math.min(pageIndex, pageCount - 1);
|
||||
const pagedDomains = filteredDomains.slice(
|
||||
currentPageIndex * pageSize,
|
||||
currentPageIndex * pageSize + pageSize,
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3 justify-between">
|
||||
<h3 className="text-lg font-medium">
|
||||
Domains{" "}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
({filteredDomains.length})
|
||||
</span>
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={selectedProjectId}
|
||||
onValueChange={setSelectedProjectId}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="Project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All projects</SelectItem>
|
||||
{allProjects?.map((project) => (
|
||||
<SelectItem key={project.projectId} value={project.projectId}>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="enabled">Enabled</SelectItem>
|
||||
<SelectItem value="disabled">Disabled</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedSsl} onValueChange={setSelectedSsl}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="SSL" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">HTTP & HTTPS</SelectItem>
|
||||
<SelectItem value="https">HTTPS only</SelectItem>
|
||||
<SelectItem value="http">HTTP only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{availablePorts.length > 0 && (
|
||||
<Select value={selectedPort} onValueChange={setSelectedPort}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="Port" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All ports</SelectItem>
|
||||
{availablePorts.map((port) => (
|
||||
<SelectItem key={port} value={String(port)}>
|
||||
{port}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={(v) => setSort(v as OverviewDomainSortBy)}
|
||||
>
|
||||
<SelectTrigger className="w-[170px]">
|
||||
<SelectValue placeholder="Sort by..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading domains...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && filteredDomains.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
|
||||
<span>No domains match the current filters.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && filteredDomains.length > 0 && (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Service</TableHead>
|
||||
<TableHead>Host</TableHead>
|
||||
<TableHead>Path</TableHead>
|
||||
<TableHead>Port</TableHead>
|
||||
<TableHead>Entrypoint</TableHead>
|
||||
<TableHead>Protocol</TableHead>
|
||||
<TableHead>Certificate</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pagedDomains.map((domain) => {
|
||||
const href = `/dashboard/project/${domain.projectId}/environment/${domain.environmentId}/services/${domain.serviceOwnerType}/${domain.serviceOwnerId}`;
|
||||
return (
|
||||
<TableRow key={domain.domainId}>
|
||||
<TableCell>
|
||||
<Link href={href} className="flex flex-col min-w-0">
|
||||
<span className="font-medium truncate">
|
||||
{domain.serviceName}
|
||||
</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{domain.projectName} / {domain.environmentName}
|
||||
</span>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
className="flex items-center gap-2 font-medium hover:underline"
|
||||
target="_blank"
|
||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||
>
|
||||
{domain.host}
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-sm">
|
||||
{domain.path || "/"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{domain.port}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{domain.customEntrypoint ? (
|
||||
<span className="font-mono text-sm">
|
||||
{domain.customEntrypoint}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={domain.https ? "outline" : "secondary"}>
|
||||
{domain.https ? "HTTPS" : "HTTP"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{domain.certificateType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DateTooltip date={domain.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{canToggleDomain ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center justify-end">
|
||||
<Switch
|
||||
checked={domain.enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleEnable(domain)
|
||||
}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{domain.enabled
|
||||
? "Domain is active. Toggle to disable routing without deleting it."
|
||||
: "Domain is disabled and not routed. Toggle to enable it again."}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Badge
|
||||
variant={domain.enabled ? "outline" : "secondary"}
|
||||
>
|
||||
{domain.enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{filteredDomains.length}{" "}
|
||||
{filteredDomains.length === 1 ? "domain" : "domains"} total
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap">Rows per page</span>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => {
|
||||
setPageSize(Number(value));
|
||||
setPageIndex(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[80px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<span className="whitespace-nowrap">
|
||||
Page {currentPageIndex + 1} of {pageCount}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageIndex(currentPageIndex - 1)}
|
||||
disabled={currentPageIndex === 0}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageIndex(currentPageIndex + 1)}
|
||||
disabled={currentPageIndex + 1 >= pageCount}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,550 @@
|
||||
import type {
|
||||
OverviewServiceType,
|
||||
OverviewSortBy,
|
||||
} from "@dokploy/server/services/overview-shared";
|
||||
import { sortOverviewServices } from "@dokploy/server/services/overview-shared";
|
||||
import {
|
||||
Ban,
|
||||
CircuitBoard,
|
||||
GlobeIcon,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
RefreshCw,
|
||||
Search,
|
||||
ServerIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DB_ENGINE_ICONS } from "@/components/icons/data-tools-icons";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useSortPreference } from "@/hooks/use-sort-preference";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200];
|
||||
|
||||
const TYPE_LABELS = {
|
||||
application: "Application",
|
||||
postgres: "PostgreSQL",
|
||||
mariadb: "MariaDB",
|
||||
mongo: "MongoDB",
|
||||
mysql: "MySQL",
|
||||
redis: "Redis",
|
||||
compose: "Compose",
|
||||
libsql: "Libsql",
|
||||
} satisfies Record<OverviewServiceType, string>;
|
||||
|
||||
const STATUS_OPTIONS = ["running", "idle", "done", "error"];
|
||||
|
||||
// "done" is the steady live state (green); "running" only holds mid-deploy (yellow) — relabeled to match what users expect.
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
running: "Deploying",
|
||||
idle: "Idle",
|
||||
done: "Running",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
const SORT_OPTIONS: { value: OverviewSortBy; label: string }[] = [
|
||||
{ value: "lastDeploy-desc", label: "Recently deployed" },
|
||||
{ value: "lastDeploy-asc", label: "Oldest deployed" },
|
||||
{ value: "createdAt-desc", label: "Newest first" },
|
||||
{ value: "createdAt-asc", label: "Oldest first" },
|
||||
{ value: "name-asc", label: "Name (A-Z)" },
|
||||
{ value: "name-desc", label: "Name (Z-A)" },
|
||||
{ value: "type-asc", label: "Type (A-Z)" },
|
||||
{ value: "type-desc", label: "Type (Z-A)" },
|
||||
];
|
||||
|
||||
const SORT_VALUES = SORT_OPTIONS.map((opt) => opt.value);
|
||||
|
||||
// libsql has no deploy/stop actions, same as environment/[environmentId].tsx.
|
||||
const idKeyByType: Record<string, string> = {
|
||||
application: "applicationId",
|
||||
compose: "composeId",
|
||||
postgres: "postgresId",
|
||||
mysql: "mysqlId",
|
||||
mariadb: "mariadbId",
|
||||
redis: "redisId",
|
||||
mongo: "mongoId",
|
||||
};
|
||||
|
||||
export const ShowOverviewServices = () => {
|
||||
const utils = api.useUtils();
|
||||
const {
|
||||
data: services,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = api.overview.services.useQuery();
|
||||
const { data: allProjects } = api.project.all.useQuery();
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedProjectId, setSelectedProjectId] = useState("all");
|
||||
const [selectedType, setSelectedType] = useState("all");
|
||||
const [selectedStatus, setSelectedStatus] = useState("all");
|
||||
const [selectedServerId, setSelectedServerId] = useState("all");
|
||||
const [sortBy, setSort] = useSortPreference<OverviewSortBy>(
|
||||
"overviewServicesSort",
|
||||
"lastDeploy-desc",
|
||||
SORT_VALUES,
|
||||
);
|
||||
|
||||
const applicationActions = {
|
||||
deploy: api.application.deploy.useMutation(),
|
||||
stop: api.application.stop.useMutation(),
|
||||
};
|
||||
const composeActions = {
|
||||
deploy: api.compose.deploy.useMutation(),
|
||||
stop: api.compose.stop.useMutation(),
|
||||
};
|
||||
const postgresActions = {
|
||||
deploy: api.postgres.deploy.useMutation(),
|
||||
stop: api.postgres.stop.useMutation(),
|
||||
};
|
||||
const mysqlActions = {
|
||||
deploy: api.mysql.deploy.useMutation(),
|
||||
stop: api.mysql.stop.useMutation(),
|
||||
};
|
||||
const mariadbActions = {
|
||||
deploy: api.mariadb.deploy.useMutation(),
|
||||
stop: api.mariadb.stop.useMutation(),
|
||||
};
|
||||
const redisActions = {
|
||||
deploy: api.redis.deploy.useMutation(),
|
||||
stop: api.redis.stop.useMutation(),
|
||||
};
|
||||
const mongoActions = {
|
||||
deploy: api.mongo.deploy.useMutation(),
|
||||
stop: api.mongo.stop.useMutation(),
|
||||
};
|
||||
|
||||
const actionsByType: Record<
|
||||
string,
|
||||
{
|
||||
deploy: { mutateAsync: (input: any) => Promise<unknown> };
|
||||
stop: { mutateAsync: (input: any) => Promise<unknown> };
|
||||
}
|
||||
> = {
|
||||
application: applicationActions,
|
||||
compose: composeActions,
|
||||
postgres: postgresActions,
|
||||
mysql: mysqlActions,
|
||||
mariadb: mariadbActions,
|
||||
redis: redisActions,
|
||||
mongo: mongoActions,
|
||||
};
|
||||
|
||||
const handleAction = async (
|
||||
service: NonNullable<typeof services>[number],
|
||||
action: "deploy" | "stop",
|
||||
) => {
|
||||
const actions = actionsByType[service.type];
|
||||
const idKey = idKeyByType[service.type];
|
||||
if (!actions || !idKey) return;
|
||||
|
||||
const labels =
|
||||
action === "deploy"
|
||||
? {
|
||||
loading: "Deploying",
|
||||
success: "queued for deployment",
|
||||
error: "deploying",
|
||||
}
|
||||
: { loading: "Stopping", success: "stopped", error: "stopping" };
|
||||
|
||||
toast.promise(
|
||||
(async () => {
|
||||
await actions[action].mutateAsync({ [idKey]: service.id });
|
||||
})(),
|
||||
{
|
||||
loading: `${labels.loading} ${service.name}...`,
|
||||
success: () => {
|
||||
utils.overview.services.invalidate();
|
||||
return `${service.name} ${labels.success} successfully`;
|
||||
},
|
||||
error: (error) =>
|
||||
`Error ${labels.error} ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const availableServers = useMemo(() => {
|
||||
if (!services) return [];
|
||||
const map = new Map<string, string>();
|
||||
for (const service of services) {
|
||||
if (service.serverId && service.serverName) {
|
||||
map.set(service.serverId, service.serverName);
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries()).map(([serverId, serverName]) => ({
|
||||
serverId,
|
||||
serverName,
|
||||
}));
|
||||
}, [services]);
|
||||
|
||||
const hasServicesWithoutServer = useMemo(
|
||||
() => !!services?.some((service) => !service.serverId),
|
||||
[services],
|
||||
);
|
||||
|
||||
const filteredServices = useMemo(() => {
|
||||
if (!services) return [];
|
||||
const filtered = services.filter(
|
||||
(service) =>
|
||||
service.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
(selectedProjectId === "all" ||
|
||||
service.projectId === selectedProjectId) &&
|
||||
(selectedType === "all" || service.type === selectedType) &&
|
||||
(selectedStatus === "all" || service.status === selectedStatus) &&
|
||||
(selectedServerId === "all" ||
|
||||
(selectedServerId === "dokploy-server" && !service.serverId) ||
|
||||
service.serverId === selectedServerId),
|
||||
);
|
||||
return sortOverviewServices(filtered, sortBy);
|
||||
}, [
|
||||
services,
|
||||
searchQuery,
|
||||
selectedProjectId,
|
||||
selectedType,
|
||||
selectedStatus,
|
||||
selectedServerId,
|
||||
sortBy,
|
||||
]);
|
||||
|
||||
// pageIndex is clamped against the filtered count each render instead of reset via an effect.
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const pageCount = Math.max(1, Math.ceil(filteredServices.length / pageSize));
|
||||
const currentPageIndex = Math.min(pageIndex, pageCount - 1);
|
||||
const pagedServices = filteredServices.slice(
|
||||
currentPageIndex * pageSize,
|
||||
currentPageIndex * pageSize + pageSize,
|
||||
);
|
||||
|
||||
const renderIcon = (service: NonNullable<typeof services>[number]) => {
|
||||
if (service.type in DB_ENGINE_ICONS) {
|
||||
const Icon =
|
||||
DB_ENGINE_ICONS[service.type as keyof typeof DB_ENGINE_ICONS];
|
||||
return <Icon className="h-5 w-5" />;
|
||||
}
|
||||
if (service.icon) {
|
||||
return (
|
||||
<img
|
||||
src={service.icon}
|
||||
alt={service.name}
|
||||
className="size-5 object-contain rounded-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return service.type === "compose" ? (
|
||||
<CircuitBoard className="h-5 w-5" />
|
||||
) : (
|
||||
<GlobeIcon className="h-5 w-5" />
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
|
||||
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3 justify-between">
|
||||
<h3 className="text-lg font-medium">
|
||||
Services{" "}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
({filteredServices.length})
|
||||
</span>
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative">
|
||||
<Input
|
||||
placeholder="Filter services..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pr-9 w-[200px]"
|
||||
/>
|
||||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<Select
|
||||
value={selectedProjectId}
|
||||
onValueChange={setSelectedProjectId}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="Project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All projects</SelectItem>
|
||||
{allProjects?.map((project) => (
|
||||
<SelectItem key={project.projectId} value={project.projectId}>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedType} onValueChange={setSelectedType}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
{Object.entries(TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
{STATUS_OPTIONS.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{STATUS_LABELS[status] ?? status}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(availableServers.length > 0 || hasServicesWithoutServer) && (
|
||||
<Select
|
||||
value={selectedServerId}
|
||||
onValueChange={setSelectedServerId}
|
||||
>
|
||||
<SelectTrigger className="w-[170px]">
|
||||
<SelectValue placeholder="Server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All servers</SelectItem>
|
||||
{hasServicesWithoutServer && (
|
||||
<SelectItem value="dokploy-server">
|
||||
Dokploy server
|
||||
</SelectItem>
|
||||
)}
|
||||
{availableServers.map((s) => (
|
||||
<SelectItem key={s.serverId} value={s.serverId}>
|
||||
{s.serverName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={(v) => setSort(v as OverviewSortBy)}
|
||||
>
|
||||
<SelectTrigger className="w-[190px]">
|
||||
<SelectValue placeholder="Sort by..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading services...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && filteredServices.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
|
||||
<span>No services match the current filters.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && filteredServices.length > 0 && (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Service</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Server</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Last Deploy</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pagedServices.map((service) => {
|
||||
const href = `/dashboard/project/${service.projectId}/environment/${service.environmentId}/services/${service.type}/${service.id}`;
|
||||
const hasActions = service.type in actionsByType;
|
||||
return (
|
||||
<TableRow key={service.id}>
|
||||
<TableCell>
|
||||
<Link href={href} className="flex items-center gap-2">
|
||||
{renderIcon(service)}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-medium truncate">
|
||||
{service.name}
|
||||
</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{service.projectName} / {service.environmentName}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{TYPE_LABELS[service.type]}</TableCell>
|
||||
<TableCell>
|
||||
<StatusTooltip
|
||||
status={service.status as any}
|
||||
className="size-2.5"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<ServerIcon className="size-3.5" />
|
||||
<span className="truncate">
|
||||
{service.serverName ?? "Dokploy server"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DateTooltip date={service.createdAt} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{service.lastDeployAt ? (
|
||||
<DateTooltip date={service.lastDeployAt} />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{hasActions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel className="truncate">
|
||||
{service.name}
|
||||
</DropdownMenuLabel>
|
||||
<DialogAction
|
||||
title="Deploy Service"
|
||||
description={`Are you sure you want to deploy "${service.name}"?`}
|
||||
type="default"
|
||||
onClick={() => handleAction(service, "deploy")}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Deploy
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
<DialogAction
|
||||
title="Stop Service"
|
||||
description={`Are you sure you want to stop "${service.name}"?`}
|
||||
onClick={() => handleAction(service, "stop")}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 text-orange-500 focus:text-orange-500"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
Stop
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{filteredServices.length}{" "}
|
||||
{filteredServices.length === 1 ? "service" : "services"} total
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap">Rows per page</span>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => {
|
||||
setPageSize(Number(value));
|
||||
setPageIndex(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[80px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<span className="whitespace-nowrap">
|
||||
Page {currentPageIndex + 1} of {pageCount}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageIndex(currentPageIndex - 1)}
|
||||
disabled={currentPageIndex === 0}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPageIndex(currentPageIndex + 1)}
|
||||
disabled={currentPageIndex + 1 >= pageCount}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -175,7 +175,7 @@ export const SearchCommand = () => {
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
router.push("/dashboard/deployments");
|
||||
router.push("/dashboard/overview?tab=deployments");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -52,13 +52,14 @@ export const ShowNodes = ({ serverId }: Props) => {
|
||||
serverId,
|
||||
});
|
||||
const { data: registry } = api.registry.all.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
const { mutateAsync: deleteNode } = api.cluster.removeWorker.useMutation();
|
||||
|
||||
const haveAtLeastOneRegistry = !!(registry && registry?.length > 0);
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
<CardHeader className="flex flex-row gap-2 justify-between w-full items-center flex-wrap">
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -68,7 +69,7 @@ export const ShowNodes = ({ serverId }: Props) => {
|
||||
</CardTitle>
|
||||
<CardDescription>Add nodes to your cluster</CardDescription>
|
||||
</div>
|
||||
{haveAtLeastOneRegistry && (
|
||||
{haveAtLeastOneRegistry && permissions?.server.create && (
|
||||
<div className="flex flex-row gap-2">
|
||||
<AddNode serverId={serverId} />
|
||||
</div>
|
||||
@@ -144,34 +145,35 @@ export const ShowNodes = ({ serverId }: Props) => {
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<ShowNodeData data={node} />
|
||||
{!node?.ManagerStatus?.Leader && (
|
||||
<DialogAction
|
||||
title="Delete Node"
|
||||
description="Are you sure you want to delete this node from the cluster?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteNode({
|
||||
nodeId: node.ID,
|
||||
serverId,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Node deleted successfully",
|
||||
);
|
||||
{!node?.ManagerStatus?.Leader &&
|
||||
permissions?.server.delete && (
|
||||
<DialogAction
|
||||
title="Delete Node"
|
||||
description="Are you sure you want to delete this node from the cluster?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteNode({
|
||||
nodeId: node.ID,
|
||||
serverId,
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting node");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Node deleted successfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting node");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DialogAction>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { dnsProviderIcons } from "@/components/icons/dns-provider-icons";
|
||||
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 {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const providerLabels = {
|
||||
cloudflare: "Cloudflare",
|
||||
route53: "AWS Route53",
|
||||
} as const;
|
||||
|
||||
type ProviderType = keyof typeof providerLabels;
|
||||
|
||||
const DnsProviderSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: "Name is required" })
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, {
|
||||
message: "Only letters, numbers, dashes and underscores",
|
||||
}),
|
||||
providerType: z.enum(["cloudflare", "route53"]),
|
||||
apiToken: z.string(),
|
||||
accessKeyId: z.string(),
|
||||
secretAccessKey: z.string(),
|
||||
});
|
||||
|
||||
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
|
||||
|
||||
const defaultValues: DnsProviderForm = {
|
||||
name: "",
|
||||
providerType: "cloudflare",
|
||||
apiToken: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
};
|
||||
|
||||
const buildConfig = (data: DnsProviderForm) => {
|
||||
switch (data.providerType) {
|
||||
case "cloudflare":
|
||||
return {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: data.apiToken,
|
||||
};
|
||||
case "route53":
|
||||
return {
|
||||
providerType: "route53" as const,
|
||||
accessKeyId: data.accessKeyId,
|
||||
secretAccessKey: data.secretAccessKey,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const extractErrorMessage = (err: unknown) => {
|
||||
if (!(err instanceof Error)) return undefined;
|
||||
try {
|
||||
const issues = JSON.parse(err.message) as { message?: string }[];
|
||||
if (Array.isArray(issues)) {
|
||||
return issues
|
||||
.map((issue) => issue.message)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
} catch {}
|
||||
return err.message;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
dnsProviderId?: string;
|
||||
}
|
||||
|
||||
export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { data: provider } = api.dnsProvider.one.useQuery(
|
||||
{ dnsProviderId: dnsProviderId || "" },
|
||||
{ enabled: !!dnsProviderId && isOpen },
|
||||
);
|
||||
|
||||
const { mutateAsync, isPending, error, isError } = dnsProviderId
|
||||
? api.dnsProvider.update.useMutation()
|
||||
: api.dnsProvider.create.useMutation();
|
||||
|
||||
const { mutateAsync: testConnection, isPending: isTesting } =
|
||||
api.dnsProvider.testConnection.useMutation();
|
||||
|
||||
const form = useForm<DnsProviderForm>({
|
||||
defaultValues,
|
||||
resolver: zodResolver(DnsProviderSchema),
|
||||
});
|
||||
|
||||
const providerType = form.watch("providerType");
|
||||
|
||||
useEffect(() => {
|
||||
if (provider) {
|
||||
form.reset({
|
||||
...defaultValues,
|
||||
name: provider.name,
|
||||
providerType: provider.config.providerType,
|
||||
...(provider.config.providerType === "cloudflare" && {
|
||||
apiToken: provider.config.apiToken,
|
||||
}),
|
||||
...(provider.config.providerType === "route53" && {
|
||||
accessKeyId: provider.config.accessKeyId,
|
||||
secretAccessKey: provider.config.secretAccessKey,
|
||||
}),
|
||||
});
|
||||
} else if (!dnsProviderId) {
|
||||
form.reset(defaultValues);
|
||||
}
|
||||
}, [provider, dnsProviderId, form, isOpen]);
|
||||
|
||||
const onSubmit = async (data: DnsProviderForm) => {
|
||||
const payload: any = {
|
||||
name: data.name,
|
||||
config: buildConfig(data),
|
||||
...(dnsProviderId && { dnsProviderId }),
|
||||
};
|
||||
await mutateAsync(payload)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
dnsProviderId ? "DNS provider updated" : "DNS provider created",
|
||||
);
|
||||
utils.dnsProvider.all.invalidate();
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const onTestConnection = async () => {
|
||||
const isValid = await form.trigger();
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
const data = form.getValues();
|
||||
await testConnection({
|
||||
config: buildConfig(data),
|
||||
...(dnsProviderId && { dnsProviderId }),
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Connection successful");
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error("Connection failed", {
|
||||
description: extractErrorMessage(err),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{dnsProviderId ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10"
|
||||
>
|
||||
<PenBoxIcon className="size-4 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="cursor-pointer space-x-3">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add Provider
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{dnsProviderId ? "Update DNS Provider" : "Add DNS Provider"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Connect a DNS provider to create records for your domains
|
||||
automatically instead of setting them up by hand.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="prod-cloudflare" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="providerType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Provider</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
disabled={!!dnsProviderId}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(providerLabels).map(([value, label]) => {
|
||||
const ProviderIcon =
|
||||
dnsProviderIcons[value as ProviderType];
|
||||
return (
|
||||
<SelectItem key={value} value={value}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ProviderIcon className="size-4 shrink-0" />
|
||||
{label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{providerType === "cloudflare" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="apiToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Token</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Create a token scoped to Zone → DNS → Edit for the zones
|
||||
you want Dokploy to manage. Avoid the Global API Key.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{providerType === "route53" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="accessKeyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Access Key ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="secretAccessKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Access Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use an IAM user/role scoped to{" "}
|
||||
<code>route53:ListHostedZones</code>,{" "}
|
||||
<code>route53:ListResourceRecordSets</code> and{" "}
|
||||
<code>route53:ChangeResourceRecordSets</code> — avoid
|
||||
root account credentials.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
isLoading={isTesting}
|
||||
onClick={onTestConnection}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{dnsProviderId ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,271 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import { PenBoxIcon, PlusIcon } from "lucide-react";
|
||||
import { 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 {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const DnsRecordSchema = z.object({
|
||||
type: z.enum(["A", "CNAME"]),
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
content: z.string().min(1, { message: "Content is required" }),
|
||||
ttl: z.string(),
|
||||
});
|
||||
|
||||
type DnsRecordForm = z.infer<typeof DnsRecordSchema>;
|
||||
|
||||
interface DnsRecordValue {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content: string;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dnsProviderId: string;
|
||||
zoneId: string;
|
||||
zoneName: string;
|
||||
record?: DnsRecordValue;
|
||||
}
|
||||
|
||||
export const HandleDnsRecord = ({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
zoneName,
|
||||
record,
|
||||
}: Props) => {
|
||||
const utils = api.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { mutateAsync, isPending, error, isError } = record
|
||||
? api.dnsProvider.updateRecord.useMutation()
|
||||
: api.dnsProvider.createRecord.useMutation();
|
||||
|
||||
const { data: servers } = api.server.all.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
const { data: panelPublicIp } = api.server.publicIp.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
const { data: panelStoredIp } = api.settings.getIp.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const panelIp = panelPublicIp || panelStoredIp;
|
||||
const ipSuggestions = [
|
||||
...(panelIp ? [{ ip: panelIp, label: "This Dokploy server" }] : []),
|
||||
...(servers ?? []).map((server) => ({
|
||||
ip: server.ipAddress,
|
||||
label: server.name,
|
||||
})),
|
||||
].filter(
|
||||
(suggestion, index, all) =>
|
||||
!!suggestion.ip && all.findIndex((s) => s.ip === suggestion.ip) === index,
|
||||
);
|
||||
|
||||
const form = useForm<DnsRecordForm>({
|
||||
defaultValues: record
|
||||
? {
|
||||
type: record.type === "CNAME" ? "CNAME" : "A",
|
||||
name: record.name,
|
||||
content: record.content,
|
||||
ttl: record.ttl && record.ttl !== 1 ? String(record.ttl) : "",
|
||||
}
|
||||
: { type: "A", name: "", content: "", ttl: "" },
|
||||
resolver: zodResolver(DnsRecordSchema),
|
||||
});
|
||||
|
||||
const type = form.watch("type");
|
||||
|
||||
const onSubmit = async (data: DnsRecordForm) => {
|
||||
const name = data.name.trim() === "@" ? zoneName : data.name;
|
||||
const payload = {
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
type: data.type,
|
||||
name,
|
||||
content: data.content,
|
||||
ttl: data.ttl ? Number(data.ttl) : undefined,
|
||||
...(record && { recordId: record.id }),
|
||||
};
|
||||
await mutateAsync(payload as any)
|
||||
.then(() => {
|
||||
toast.success(record ? "Record updated" : "Record created");
|
||||
utils.dnsProvider.listRecords.invalidate({ dnsProviderId, zoneId });
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{record ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-blue-500/10 size-6"
|
||||
>
|
||||
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm">
|
||||
<PlusIcon className="size-3.5 mr-1.5" />
|
||||
Add Record
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{record ? "Edit Record" : "Add Record"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{record
|
||||
? "Update this DNS record."
|
||||
: "Create a new A or CNAME record in this zone."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="A">A</SelectItem>
|
||||
<SelectItem value="CNAME">CNAME</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="app.example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Use <code>@</code> for the root domain.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{type === "A" && ipSuggestions.length > 0 && (
|
||||
<FormItem>
|
||||
<FormLabel>Fill from server (optional)</FormLabel>
|
||||
<Select
|
||||
onValueChange={(ip) =>
|
||||
form.setValue("content", ip, { shouldValidate: true })
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{ipSuggestions.map((suggestion) => (
|
||||
<SelectItem key={suggestion.ip} value={suggestion.ip}>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<span>{suggestion.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{suggestion.ip}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{type === "A" ? "IPv4 Address" : "Target"}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={
|
||||
type === "A" ? "203.0.113.10" : "app.example.com"
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ttl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>TTL (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" placeholder="Auto" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" isLoading={isPending}>
|
||||
{record ? "Update" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Globe,
|
||||
Loader2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
import { HandleDnsRecord } from "./handle-dns-record";
|
||||
|
||||
interface ZoneRecordsProps {
|
||||
dnsProviderId: string;
|
||||
zoneId: string;
|
||||
zoneName: string;
|
||||
}
|
||||
|
||||
const ZoneRecords = ({ dnsProviderId, zoneId, zoneName }: ZoneRecordsProps) => {
|
||||
const utils = api.useUtils();
|
||||
const { data, isLoading, isError, error } =
|
||||
api.dnsProvider.listRecords.useQuery({ dnsProviderId, zoneId });
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const { mutateAsync: deleteRecord, isPending: isDeleting } =
|
||||
api.dnsProvider.deleteRecord.useMutation();
|
||||
|
||||
const canWrite = !!permissions?.dnsProvider.update;
|
||||
const canDelete = !!permissions?.dnsProvider.delete;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 pl-8 pb-2 pr-3">
|
||||
{isLoading && (
|
||||
<div className="flex flex-row gap-2 items-center text-sm text-muted-foreground py-3">
|
||||
<span>Loading records...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<p className="text-sm text-destructive py-2">{error?.message}</p>
|
||||
)}
|
||||
{data && data.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No records found in this zone.
|
||||
</p>
|
||||
)}
|
||||
{data?.map((record) => {
|
||||
const isEditable = record.type === "A" || record.type === "CNAME";
|
||||
return (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs"
|
||||
>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{record.type}
|
||||
</Badge>
|
||||
<span className="font-medium truncate">{record.name}</span>
|
||||
<span className="text-muted-foreground truncate flex-1">
|
||||
→ {record.content}
|
||||
</span>
|
||||
{canWrite && isEditable && (
|
||||
<HandleDnsRecord
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zoneId}
|
||||
zoneName={zoneName}
|
||||
record={record}
|
||||
/>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DialogAction
|
||||
title="Delete Record"
|
||||
description={`Delete the ${record.type} record "${record.name}"? This removes it from the DNS provider, not just from Dokploy.`}
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteRecord({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
recordId: record.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Record deleted");
|
||||
utils.dnsProvider.listRecords.invalidate({
|
||||
dnsProviderId,
|
||||
zoneId,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting the record");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10 size-6"
|
||||
isLoading={isDeleting}
|
||||
>
|
||||
<Trash2 className="size-3.5 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{canWrite && (
|
||||
<div className="pt-1">
|
||||
<HandleDnsRecord
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zoneId}
|
||||
zoneName={zoneName}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
dnsProviderId: string;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
export const ShowDnsProviderZones = ({
|
||||
dnsProviderId,
|
||||
providerName,
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [expandedZoneId, setExpandedZoneId] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, isError, error } =
|
||||
api.dnsProvider.listZones.useQuery({ dnsProviderId }, { enabled: isOpen });
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
setExpandedZoneId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Globe className="size-3.5 mr-1.5" />
|
||||
View Domains
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Domains for {providerName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Zones this provider's API token can manage. Click a zone to see and
|
||||
manage its records.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isLoading && (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground py-6">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<p className="text-sm text-destructive py-2">{error?.message}</p>
|
||||
)}
|
||||
{data && data.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No zones found for this token. Make sure it has access to at least
|
||||
one zone.
|
||||
</p>
|
||||
)}
|
||||
{data && data.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 max-h-[60vh] overflow-y-auto">
|
||||
{data.map((zone) => {
|
||||
const isExpanded = expandedZoneId === zone.id;
|
||||
return (
|
||||
<div key={zone.id} className="rounded-md border">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-left"
|
||||
onClick={() =>
|
||||
setExpandedZoneId(isExpanded ? null : zone.id)
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="size-3.5 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<Globe className="size-3.5 text-muted-foreground shrink-0" />
|
||||
{zone.name}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<ZoneRecords
|
||||
dnsProviderId={dnsProviderId}
|
||||
zoneId={zone.id}
|
||||
zoneName={zone.name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Globe, Loader2, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { dnsProviderIcons } from "@/components/icons/dns-provider-icons";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import { HandleDnsProvider } from "./handle-dns-provider";
|
||||
import { ShowDnsProviderZones } from "./show-dns-provider-zones";
|
||||
|
||||
const providerLabels: Record<string, string> = {
|
||||
cloudflare: "Cloudflare",
|
||||
route53: "AWS Route53",
|
||||
};
|
||||
|
||||
export const ShowDnsProviders = () => {
|
||||
const { mutateAsync, isPending: isRemoving } =
|
||||
api.dnsProvider.remove.useMutation();
|
||||
const { data, isPending, refetch } = api.dnsProvider.all.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Globe className="size-6 text-muted-foreground self-center" />
|
||||
DNS Providers
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Connect a DNS provider so Dokploy can create the A/CNAME record
|
||||
for a domain instead of you setting it up by hand.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 py-8 border-t">
|
||||
{isPending ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data?.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<Globe className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground text-center">
|
||||
You don't have any DNS providers configured
|
||||
</span>
|
||||
{permissions?.dnsProvider.create && <HandleDnsProvider />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 min-h-[25vh]">
|
||||
<div className="flex flex-col gap-4 rounded-lg">
|
||||
{data?.map((provider) => {
|
||||
const ProviderIcon =
|
||||
dnsProviderIcons[provider.providerType];
|
||||
return (
|
||||
<div
|
||||
key={provider.dnsProviderId}
|
||||
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<ProviderIcon className="size-7 shrink-0" />
|
||||
<div className="flex gap-2 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
{provider.name}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{providerLabels[provider.providerType] ??
|
||||
provider.providerType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-1">
|
||||
<ShowDnsProviderZones
|
||||
dnsProviderId={provider.dnsProviderId}
|
||||
providerName={provider.name}
|
||||
/>
|
||||
{permissions?.dnsProvider.update && (
|
||||
<HandleDnsProvider
|
||||
dnsProviderId={provider.dnsProviderId}
|
||||
/>
|
||||
)}
|
||||
{permissions?.dnsProvider.delete && (
|
||||
<DialogAction
|
||||
title="Delete DNS Provider"
|
||||
description="Domains that rely on this provider to manage their records will need to be updated manually. Are you sure?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
dnsProviderId: provider.dnsProviderId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("DNS provider deleted");
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Error deleting the DNS provider",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{permissions?.dnsProvider.create && (
|
||||
<div className="flex flex-row gap-2 flex-wrap w-full justify-end mr-4">
|
||||
<HandleDnsProvider />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+3
-3
@@ -70,9 +70,9 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
|
||||
<TooltipContent side="top" className="max-w-sm">
|
||||
<p>
|
||||
Runs a full Docker cleanup daily, pruning stopped containers,
|
||||
unused images, volumes, build cache, and system resources. This
|
||||
may remove images built for Compose services that run on-demand
|
||||
(backup runners, cron jobs, one-off tasks).
|
||||
unused images, build cache, and system resources. This may remove
|
||||
images built for Compose services that run on-demand (backup
|
||||
runners, cron jobs, one-off tasks).
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
For custom cleanup strategies, use{" "}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Stethoscope } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ShowHealth } from "@/components/dashboard/docker/health/show-health";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const ShowHealthModal = ({ serverId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Stethoscope className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[85vh] overflow-y-auto">
|
||||
<div className="flex gap-4 py-4 w-full">
|
||||
<ShowHealth serverId={serverId} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -35,6 +35,7 @@ import { TerminalModal } from "../web-server/terminal-modal";
|
||||
import { ShowServerActions } from "./actions/show-server-actions";
|
||||
import { HandleServers } from "./handle-servers";
|
||||
import { SetupServer } from "./setup-server";
|
||||
import { ShowHealthModal } from "./show-health-modal";
|
||||
import { ShowMonitoringModal } from "./show-monitoring-modal";
|
||||
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
|
||||
|
||||
@@ -324,6 +325,24 @@ export const ShowServers = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{permissions?.docker.read &&
|
||||
permissions?.server.read &&
|
||||
server.sshKeyId &&
|
||||
!isBuildServer && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<ShowHealthModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Health</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{permissions?.server.delete && (
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type PaginationState,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import { ArrowUpDown, Loader2, LogOut, Smartphone } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
type SessionRow = {
|
||||
id: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
isCurrent: boolean;
|
||||
};
|
||||
|
||||
const SortableHeader = ({
|
||||
column,
|
||||
title,
|
||||
}: {
|
||||
column: {
|
||||
getIsSorted: () => false | "asc" | "desc";
|
||||
toggleSorting: (asc: boolean) => void;
|
||||
};
|
||||
title: string;
|
||||
}) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 h-8"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{title}
|
||||
<ArrowUpDown className="ml-2 size-3.5" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
export const ShowSessions = () => {
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const isOwner = auth?.role === "owner";
|
||||
const {
|
||||
data: sessions,
|
||||
isPending,
|
||||
refetch,
|
||||
} = api.user.listSessions.useQuery();
|
||||
const { mutateAsync: revoke, isPending: isRevoking } =
|
||||
api.user.revokeSession.useMutation();
|
||||
const { data: members } = api.user.all.useQuery(undefined, {
|
||||
enabled: isOwner,
|
||||
});
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<
|
||||
"all" | "active" | "expired"
|
||||
>("all");
|
||||
const [userFilter, setUserFilter] = useState("all");
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "createdAt", desc: true },
|
||||
]);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const handleRevoke = async (sessionId: string) => {
|
||||
try {
|
||||
await revoke({ sessionId });
|
||||
toast.success("Session revoked successfully");
|
||||
refetch();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to revoke session",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let list = sessions ?? [];
|
||||
|
||||
if (statusFilter !== "all") {
|
||||
list = list.filter((s) => {
|
||||
const isExpired = new Date(s.expiresAt) <= new Date();
|
||||
return statusFilter === "expired" ? isExpired : !isExpired;
|
||||
});
|
||||
}
|
||||
|
||||
if (isOwner && userFilter !== "all") {
|
||||
list = list.filter((s) => s.userId === userFilter);
|
||||
}
|
||||
|
||||
if (globalFilter.trim()) {
|
||||
const query = globalFilter.toLowerCase();
|
||||
list = list.filter((s) =>
|
||||
[s.ipAddress, s.userAgent ? parseUserAgent(s.userAgent) : null]
|
||||
.filter(Boolean)
|
||||
.some((field) => field?.toLowerCase().includes(query)),
|
||||
);
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [sessions, statusFilter, userFilter, isOwner, globalFilter]);
|
||||
|
||||
const columns = useMemo<ColumnDef<SessionRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "user",
|
||||
accessorFn: (row) => `${row.firstName} ${row.lastName} ${row.email}`,
|
||||
header: ({ column }) => <SortableHeader column={column} title="User" />,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>
|
||||
{row.original.firstName} {row.original.lastName}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
({row.original.email})
|
||||
</span>
|
||||
{row.original.isCurrent && (
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
Current
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "ipAddress",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="IP Address" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm">
|
||||
{row.original.ipAddress || "-"}
|
||||
</span>
|
||||
),
|
||||
meta: { className: "hidden md:table-cell" },
|
||||
},
|
||||
{
|
||||
id: "device",
|
||||
accessorFn: (row) =>
|
||||
row.userAgent ? parseUserAgent(row.userAgent) : "",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Device" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="max-w-[200px] truncate text-sm text-muted-foreground">
|
||||
{row.original.userAgent
|
||||
? parseUserAgent(row.original.userAgent)
|
||||
: "-"}
|
||||
</span>
|
||||
),
|
||||
meta: { className: "hidden lg:table-cell" },
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorFn: (row) => (new Date(row.expiresAt) <= new Date() ? 1 : 0),
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const isExpired = new Date(row.original.expiresAt) <= new Date();
|
||||
return isExpired ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Expired
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="green" className="text-xs">
|
||||
Active
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Active Since" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{format(new Date(row.original.createdAt), "MMM d, HH:mm")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "expiresAt",
|
||||
header: ({ column }) => (
|
||||
<SortableHeader column={column} title="Expires" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{format(new Date(row.original.expiresAt), "MMM d, HH:mm")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableSorting: false,
|
||||
header: () => <div className="text-right">Actions</div>,
|
||||
cell: ({ row }) =>
|
||||
!row.original.isCurrent && (
|
||||
<div className="flex justify-end">
|
||||
<DialogAction
|
||||
title="Revoke Session"
|
||||
description={`Force logout for ${row.original.firstName} ${row.original.lastName} (${row.original.email})?`}
|
||||
type="destructive"
|
||||
onClick={async () => handleRevoke(row.original.id)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={isRevoking}
|
||||
>
|
||||
<LogOut className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[isRevoking, handleRevoke],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Smartphone className="size-6 text-muted-foreground self-center" />
|
||||
Sessions
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{isOwner
|
||||
? "Manage active sessions across your organization. Revoke sessions to force logout."
|
||||
: "Manage your active sessions. Revoke sessions to force logout."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 py-8 border-t">
|
||||
{isPending ? (
|
||||
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
|
||||
<span>Loading...</span>
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
) : !sessions || sessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
|
||||
<Smartphone className="size-8 self-center text-muted-foreground" />
|
||||
<span className="text-base text-muted-foreground">
|
||||
No sessions found
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search by IP, device..."
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) =>
|
||||
setStatusFilter(v as "all" | "active" | "expired")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="expired">Expired</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{isOwner && (
|
||||
<Select value={userFilter} onValueChange={setUserFilter}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="User" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All users</SelectItem>
|
||||
{members?.map((m) => (
|
||||
<SelectItem key={m.user.id} value={m.user.id}>
|
||||
{m.user.firstName} {m.user.lastName} ({m.user.email}
|
||||
)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={
|
||||
(header.column.columnDef.meta as any)?.className
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={
|
||||
(cell.column.columnDef.meta as any)?.className
|
||||
}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
No sessions match your filters.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{table.getPageCount() > 1 && (
|
||||
<div className="flex items-center justify-end gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {table.getState().pagination.pageIndex + 1} of{" "}
|
||||
{table.getPageCount()}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function parseUserAgent(ua: string): string {
|
||||
if (ua.includes("Chrome/") && !ua.includes("Edg/")) return "Chrome";
|
||||
if (ua.includes("Edg/")) return "Edge";
|
||||
if (ua.includes("Firefox/")) return "Firefox";
|
||||
if (ua.includes("Safari/") && !ua.includes("Chrome/")) return "Safari";
|
||||
if (ua.includes("curl/") || ua.includes("wget/")) return "CLI";
|
||||
return ua.slice(0, 40) + (ua.length > 40 ? "..." : "");
|
||||
}
|
||||
@@ -106,6 +106,7 @@ export const AddInvitation = () => {
|
||||
const { mutateAsync: createUserWithCredentials, isPending: isCreating } =
|
||||
api.user.createUserWithCredentials.useMutation();
|
||||
const { data: customRoles } = api.customRole.all.useQuery();
|
||||
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<AddInvitation>({
|
||||
@@ -132,6 +133,16 @@ export const AddInvitation = () => {
|
||||
}
|
||||
}, [form, isCloud]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeOrganization?.defaultRole &&
|
||||
activeOrganization.defaultRole !== "owner" &&
|
||||
!form.formState.dirtyFields.role
|
||||
) {
|
||||
form.setValue("role", activeOrganization.defaultRole);
|
||||
}
|
||||
}, [form, activeOrganization?.defaultRole]);
|
||||
|
||||
const onSubmit = async (data: AddInvitation) => {
|
||||
setError(null);
|
||||
|
||||
@@ -267,10 +278,7 @@ export const AddInvitation = () => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a role" />
|
||||
|
||||
@@ -42,6 +42,7 @@ const providerLabels = {
|
||||
aws: "AWS Secrets Manager",
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
scaleway: "Scaleway Secret Manager",
|
||||
} as const;
|
||||
|
||||
type ProviderType = keyof typeof providerLabels;
|
||||
@@ -55,7 +56,14 @@ const VaultProviderSchema = z
|
||||
message:
|
||||
"Only letters, numbers, dashes and underscores (used in ${{vault.<name>.<secret>}})",
|
||||
}),
|
||||
providerType: z.enum(["hashicorp", "infisical", "aws", "doppler", "azure"]),
|
||||
providerType: z.enum([
|
||||
"hashicorp",
|
||||
"infisical",
|
||||
"aws",
|
||||
"doppler",
|
||||
"azure",
|
||||
"scaleway",
|
||||
]),
|
||||
url: z.string(),
|
||||
token: z.string(),
|
||||
namespace: z.string(),
|
||||
@@ -77,6 +85,10 @@ const VaultProviderSchema = z
|
||||
azureClientSecret: z.string(),
|
||||
dopplerProject: z.string(),
|
||||
dopplerConfig: z.string(),
|
||||
scalewayRegion: z.string(),
|
||||
scalewayProjectId: z.string(),
|
||||
scalewaySecretKey: z.string(),
|
||||
scalewayApiUrl: z.string(),
|
||||
assignments: z.array(
|
||||
z.object({
|
||||
projectId: z.string(),
|
||||
@@ -127,6 +139,17 @@ const VaultProviderSchema = z
|
||||
path: ["awsEndpoint"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "scaleway" &&
|
||||
data.scalewayApiUrl &&
|
||||
!isValidUrl(data.scalewayApiUrl)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Enter a valid URL (e.g. https://api.scaleway.com)",
|
||||
path: ["scalewayApiUrl"],
|
||||
});
|
||||
}
|
||||
if (
|
||||
data.providerType === "infisical" &&
|
||||
data.siteUrl &&
|
||||
@@ -166,6 +189,11 @@ const VaultProviderSchema = z
|
||||
["azureClientId", "Client ID is required"],
|
||||
["azureClientSecret", "Client Secret is required"],
|
||||
],
|
||||
scaleway: [
|
||||
["scalewayRegion", "Region is required"],
|
||||
["scalewayProjectId", "Project ID is required"],
|
||||
["scalewaySecretKey", "Secret Key is required"],
|
||||
],
|
||||
};
|
||||
|
||||
for (const [field, message] of required[data.providerType] ?? []) {
|
||||
@@ -222,6 +250,10 @@ const defaultValues: VaultProviderForm = {
|
||||
azureClientSecret: "",
|
||||
dopplerProject: "",
|
||||
dopplerConfig: "",
|
||||
scalewayRegion: "fr-par",
|
||||
scalewayProjectId: "",
|
||||
scalewaySecretKey: "",
|
||||
scalewayApiUrl: "https://api.scaleway.com",
|
||||
assignments: [],
|
||||
};
|
||||
|
||||
@@ -268,6 +300,14 @@ const buildConfig = (data: VaultProviderForm) => {
|
||||
clientId: data.azureClientId,
|
||||
clientSecret: data.azureClientSecret,
|
||||
};
|
||||
case "scaleway":
|
||||
return {
|
||||
providerType: "scaleway" as const,
|
||||
region: data.scalewayRegion || "fr-par",
|
||||
projectId: data.scalewayProjectId,
|
||||
secretKey: data.scalewaySecretKey,
|
||||
apiUrl: data.scalewayApiUrl || "https://api.scaleway.com",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -383,6 +423,12 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
azureClientId: provider.config.clientId,
|
||||
azureClientSecret: provider.config.clientSecret,
|
||||
}),
|
||||
...(provider.config.providerType === "scaleway" && {
|
||||
scalewayRegion: provider.config.region,
|
||||
scalewayProjectId: provider.config.projectId,
|
||||
scalewaySecretKey: provider.config.secretKey,
|
||||
scalewayApiUrl: provider.config.apiUrl,
|
||||
}),
|
||||
});
|
||||
} else if (!vaultProviderId) {
|
||||
form.reset(defaultValues);
|
||||
@@ -859,6 +905,97 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{providerType === "scaleway" && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scalewayRegion"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Region</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="fr-par" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{["fr-par", "nl-ams", "pl-waw"].map((region) => (
|
||||
<SelectItem key={region} value={region}>
|
||||
{region}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scalewayProjectId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Project ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scalewaySecretKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The secret key of an API key with the{" "}
|
||||
<code>SecretManagerReadOnly</code> permission set
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="scalewayApiUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://api.scaleway.com"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormDescription>
|
||||
Reference format:{" "}
|
||||
<code>{"${{vault.<name>.secret-name}}"}</code>,{" "}
|
||||
<code>{"${{vault.<name>.folder/secret-name}}"}</code> for
|
||||
secrets in a path, or{" "}
|
||||
<code>{"${{vault.<name>.secret-name:field}}"}</code> for JSON
|
||||
secrets
|
||||
</FormDescription>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||
<FormLabel>Access</FormLabel>
|
||||
<FormDescription>
|
||||
|
||||
@@ -20,6 +20,7 @@ const providerLabels: Record<string, string> = {
|
||||
aws: "AWS Secrets Manager",
|
||||
doppler: "Doppler",
|
||||
azure: "Azure Key Vault",
|
||||
scaleway: "Scaleway Secret Manager",
|
||||
};
|
||||
|
||||
export const ShowVaultProviders = () => {
|
||||
|
||||
@@ -41,11 +41,22 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
||||
});
|
||||
|
||||
const addonFit = new FitAddon();
|
||||
const clipboardAddon = new ClipboardAddon();
|
||||
term.loadAddon(clipboardAddon);
|
||||
fixMacOsAltKeys(term);
|
||||
|
||||
// @ts-ignore
|
||||
term.open(termRef.current);
|
||||
// @ts-ignore
|
||||
term.loadAddon(addonFit);
|
||||
addonFit.fit();
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set("serverId", serverId);
|
||||
urlParams.set("cols", term.cols.toString());
|
||||
urlParams.set("rows", term.rows.toString());
|
||||
|
||||
if (serverId === "local") {
|
||||
const { port, username } = getLocalServerData();
|
||||
@@ -57,17 +68,22 @@ export const Terminal: React.FC<Props> = ({ id, serverId }) => {
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const addonAttach = new AttachAddon(ws);
|
||||
const clipboardAddon = new ClipboardAddon();
|
||||
term.loadAddon(clipboardAddon);
|
||||
fixMacOsAltKeys(term);
|
||||
|
||||
// @ts-ignore
|
||||
term.open(termRef.current);
|
||||
// @ts-ignore
|
||||
term.loadAddon(addonFit);
|
||||
term.loadAddon(addonAttach);
|
||||
addonFit.fit();
|
||||
|
||||
const sendResize = (cols: number, rows: number) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||
}
|
||||
};
|
||||
term.onResize(({ cols, rows }) => sendResize(cols, rows));
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => addonFit.fit());
|
||||
if (termRef.current) {
|
||||
resizeObserver.observe(termRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
ws.readyState === WebSocket.OPEN && ws.close();
|
||||
};
|
||||
}, [id, serverId]);
|
||||
|
||||
@@ -35,7 +35,7 @@ export const DocLinks = () => (
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-xs text-primary underline underline-offset-4 inline-flex items-center gap-1"
|
||||
>
|
||||
Cluster Settings
|
||||
@@ -85,7 +85,7 @@ export const SwarmNotAvailable = ({
|
||||
<li>
|
||||
Check the{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
@@ -130,7 +130,7 @@ export const ServicesError = ({
|
||||
<li>
|
||||
Network connectivity issues to a remote server — check{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
@@ -183,7 +183,7 @@ export const NoServices = ({ nodeCount, onRefresh }: NoServicesProps) => (
|
||||
Worker nodes need to pull images from a shared registry. Configure one
|
||||
in{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
@@ -255,7 +255,7 @@ export const NoRunningContainers = ({
|
||||
<li>
|
||||
Images can't be pulled on worker nodes — verify your{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="text-primary underline underline-offset-4"
|
||||
>
|
||||
registry configuration
|
||||
|
||||
@@ -303,7 +303,7 @@ export const ShowSwarmContainers = ({ serverId }: Props) => {
|
||||
<p className="mt-2 text-xs">
|
||||
Manage nodes in{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/cluster"
|
||||
href="/dashboard/docker?tab=swarm&subtab=nodes"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
Cluster Settings
|
||||
|
||||
@@ -51,7 +51,9 @@ export default function SwarmMonitorCard({ serverId }: Props) {
|
||||
{!serverId && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
window.location.replace("/dashboard/settings/cluster")
|
||||
window.location.replace(
|
||||
"/dashboard/docker?tab=swarm&subtab=nodes",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
|
||||
@@ -76,7 +76,7 @@ export const MariadbIcon = ({ className }: Props) => {
|
||||
>
|
||||
<path
|
||||
d="M38.1637 8.19202C38.0639 8.10928 37.9372 8.0659 37.8075 8.07003C37.4539 8.07003 36.9964 8.314 36.7512 8.43598L36.6524 8.486C36.2404 8.68747 35.7917 8.80285 35.3337 8.8251C34.8629 8.83973 34.4567 8.8678 33.9309 8.92147C30.8009 9.2435 29.4115 11.6392 28.0685 13.9557C27.3365 15.2157 26.5827 16.5173 25.5472 17.5273C25.333 17.735 25.1052 17.9282 24.8652 18.1055C23.7942 18.9045 22.4487 19.468 21.3974 19.8705C20.3897 20.256 19.2894 20.6025 18.2257 20.9367C17.2499 21.2442 16.3338 21.5332 15.4885 21.8467C15.1067 21.9893 14.7822 22.0907 14.4968 22.1918C13.7271 22.4493 13.1708 22.6335 12.3609 23.1885C12.0449 23.4043 11.7278 23.6398 11.507 23.8155C10.8638 24.3293 10.2945 24.9295 9.81509 25.5988C9.40495 26.2163 8.93065 26.7888 8.4001 27.3067C8.22932 27.475 7.92559 27.5505 7.47059 27.5505C6.93754 27.5505 6.29102 27.4408 5.6067 27.3248C4.90287 27.2028 4.17342 27.081 3.54887 27.081C3.0402 27.081 2.6523 27.1627 2.36077 27.3322C2.36077 27.3322 1.87284 27.6177 1.66669 27.986L1.86917 28.0775C2.18275 28.2468 2.47374 28.455 2.73525 28.6972C3.00602 28.9482 3.3082 29.1632 3.63425 29.3363C3.7385 29.3782 3.83284 29.4413 3.91115 29.5218C3.82577 29.6438 3.70012 29.8072 3.5696 29.9792C2.84747 30.9233 2.42664 31.521 2.66815 31.8455C2.78092 31.9047 2.90685 31.934 3.0341 31.931C4.60767 31.931 5.45179 31.5223 6.52157 31.0038C6.83507 30.8623 7.15587 30.7075 7.52182 30.5488C8.14637 30.278 8.81972 29.845 9.53209 29.3877C10.4762 28.7777 11.4521 28.1532 12.3975 27.8507C13.1762 27.6123 13.9875 27.4978 14.8017 27.5115C15.8056 27.5115 16.8547 27.647 17.8695 27.7762C18.627 27.8738 19.4102 27.9738 20.1787 28.0202C20.4775 28.0373 20.7544 28.0458 21.0229 28.0458C21.3825 28.047 21.7422 28.0283 22.0999 27.9897L22.1854 27.9592C22.7245 27.6287 22.977 26.9175 23.2222 26.2295C23.3795 25.7867 23.5112 25.3903 23.7162 25.1317C23.729 25.1195 23.743 25.1085 23.7577 25.0987C23.7674 25.0933 23.7787 25.0913 23.7899 25.093C23.8009 25.0948 23.811 25.1003 23.8187 25.1085C23.8187 25.1085 23.8187 25.1085 23.8187 25.128C23.6894 27.8177 22.611 29.5193 21.5157 31.0417L20.7837 31.826C20.7837 31.826 21.8072 31.826 22.389 31.6015C24.5139 30.966 26.1192 29.5657 27.2865 27.3322C27.5745 26.7592 27.8319 26.1712 28.0575 25.5708C28.077 25.5208 28.2624 25.428 28.2442 25.6928C28.2442 25.7697 28.2332 25.855 28.227 25.9367C28.227 25.9892 28.2197 26.0428 28.2174 26.0965C28.1869 26.4685 28.0954 27.2652 28.0954 27.2652L28.7515 26.9127C30.3374 25.9098 31.5572 23.896 32.4794 20.7562C32.8649 19.4485 33.1465 18.1507 33.3954 17.004C33.693 15.6341 33.9505 14.4509 34.2494 13.9935C34.7104 13.2762 35.4142 12.7895 36.0949 12.3199L36.373 12.1259C37.2269 11.5258 38.0807 10.8305 38.2687 9.53748V9.50942C38.4102 8.55065 38.2942 8.30423 38.1637 8.19202Z"
|
||||
fill="#231F20"
|
||||
fill="#c2bfbf"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -211,6 +211,15 @@ export const LibsqlIcon = ({ className }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const DB_ENGINE_ICONS = {
|
||||
postgres: PostgresqlIcon,
|
||||
mariadb: MariadbIcon,
|
||||
mysql: MysqlIcon,
|
||||
mongo: MongodbIcon,
|
||||
redis: RedisIcon,
|
||||
libsql: LibsqlIcon,
|
||||
} as const;
|
||||
|
||||
export const GitlabIcon = ({ className }: Props) => {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CloudflareIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Route53Icon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 304 182"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m86 66 2 9c0 3 1 5 3 8v2l-1 3-7 4-2 1-3-1-4-5-3-6c-8 9-18 14-29 14-9 0-16-3-20-8-5-4-8-11-8-19s3-15 9-20c6-6 14-8 25-8a79 79 0 0 1 22 3v-7c0-8-2-13-5-16-3-4-8-5-16-5l-11 1a80 80 0 0 0-14 5h-2c-1 0-2-1-2-3v-5l1-3c0-1 1-2 3-2l12-5 16-2c12 0 20 3 26 8 5 6 8 14 8 25v32zM46 82l10-2c4-1 7-4 10-7l3-6 1-9v-4a84 84 0 0 0-19-2c-6 0-11 1-15 4-3 2-4 6-4 11s1 8 3 11c3 2 6 4 11 4zm80 10-4-1-2-3-23-78-1-4 2-2h10l4 1 2 4 17 66 15-66 2-4 4-1h8l4 1 2 4 16 67 17-67 2-4 4-1h9c2 0 3 1 3 2v2l-1 2-24 78-2 4-4 1h-9l-4-1-1-4-16-65-15 64-2 4-4 1h-9zm129 3a66 66 0 0 1-27-6l-3-3-1-2v-5c0-2 1-3 2-3h2l3 1a54 54 0 0 0 23 5c6 0 11-2 14-4 4-2 5-5 5-9l-2-7-10-5-15-5c-7-2-13-6-16-10a24 24 0 0 1 5-34l10-5a44 44 0 0 1 20-2 110 110 0 0 1 12 3l4 2 3 2 1 4v4c0 3-1 4-2 4l-4-2c-6-2-12-3-19-3-6 0-11 0-14 2s-4 5-4 9c0 3 1 5 3 7s5 4 11 6l14 4c7 3 12 6 15 10s5 9 5 14l-3 12-7 8c-3 3-7 5-11 6l-14 2z"
|
||||
/>
|
||||
<path
|
||||
d="M274 144A220 220 0 0 1 4 124c-4-3-1-6 2-4a300 300 0 0 0 263 16c5-2 10 4 5 8z"
|
||||
fill="#f90"
|
||||
/>
|
||||
<path
|
||||
d="M287 128c-4-5-28-3-38-1-4 0-4-3-1-5 19-13 50-9 53-5 4 5-1 36-18 51-3 2-6 1-5-2 5-10 13-33 9-38z"
|
||||
fill="#f90"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const dnsProviderIcons = {
|
||||
cloudflare: CloudflareIcon,
|
||||
route53: Route53Icon,
|
||||
} as const;
|
||||
@@ -571,10 +571,22 @@ export const AzureIcon = ({ className }: Props) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ScalewayIcon = ({ className }: Props) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path d="M16.605 11.11v5.72a1.77 1.77 0 01-1.54 1.69h-4a1.43 1.43 0 01-1.31-1.22 1.09 1.09 0 010-.18 1.37 1.37 0 011.37-1.36h1.74a1 1 0 001-1v-3.62a1.4 1.4 0 011.18-1.39h.17a1.37 1.37 0 011.39 1.36zm-6.46 1.74V9.26a1 1 0 011-1h1.85a1.37 1.37 0 001.37-1.37 1 1 0 000-.17 1.45 1.45 0 00-1.41-1.2h-3.96a1.81 1.81 0 00-1.58 1.66v5.7a1.37 1.37 0 001.37 1.37h.21a1.4 1.4 0 001.15-1.4zm12-4.29V20a4.53 4.53 0 01-4.15 4h-7.58a8.57 8.57 0 01-8.56-8.57V4.54A4.54 4.54 0 016.395 0h7.18a8.56 8.56 0 018.56 8.56zm-2.74 0a5.83 5.83 0 00-5.82-5.82h-7.19a1.79 1.79 0 00-1.8 1.8v10.89a5.83 5.83 0 005.82 5.8h7.44a1.79 1.79 0 001.54-1.48z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const vaultProviderIcons = {
|
||||
hashicorp: HashicorpVaultIcon,
|
||||
infisical: InfisicalIcon,
|
||||
aws: AwsIcon,
|
||||
doppler: DopplerIcon,
|
||||
azure: AzureIcon,
|
||||
scaleway: ScalewayIcon,
|
||||
} as const;
|
||||
|
||||
@@ -8,8 +8,10 @@ import { Button } from "../ui/button";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
/** Overrides the default quote in the left panel (desktop only). */
|
||||
leftPanel?: React.ReactNode;
|
||||
}
|
||||
export const OnboardingLayout = ({ children }: Props) => {
|
||||
export const OnboardingLayout = ({ children, leftPanel }: Props) => {
|
||||
const { config: whitelabeling } = useWhitelabelingPublic();
|
||||
const appName = whitelabeling?.appName || "Dokploy";
|
||||
const appDescription =
|
||||
@@ -30,9 +32,11 @@ export const OnboardingLayout = ({ children }: Props) => {
|
||||
{appName}
|
||||
</Link>
|
||||
<div className="relative z-20 mt-auto">
|
||||
<blockquote className="space-y-2">
|
||||
<p className="text-lg text-primary">{appDescription}</p>
|
||||
</blockquote>
|
||||
{leftPanel ?? (
|
||||
<blockquote className="space-y-2">
|
||||
<p className="text-lg text-primary">{appDescription}</p>
|
||||
</blockquote>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-svh w-full flex-col">
|
||||
|
||||
@@ -14,22 +14,24 @@ import {
|
||||
ClipboardList,
|
||||
Clock,
|
||||
CreditCard,
|
||||
Database,
|
||||
Folder,
|
||||
Forward,
|
||||
GalleryVerticalEnd,
|
||||
GitBranch,
|
||||
Globe,
|
||||
HardDrive,
|
||||
House,
|
||||
Key,
|
||||
KeyRound,
|
||||
LayoutGrid,
|
||||
Loader2,
|
||||
LogIn,
|
||||
type LucideIcon,
|
||||
Package,
|
||||
Palette,
|
||||
Rocket,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
Star,
|
||||
Tags,
|
||||
Trash2,
|
||||
@@ -53,14 +55,26 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
SIDEBAR_COOKIE_NAME,
|
||||
@@ -164,10 +178,11 @@ const MENU: Menu = {
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Deployments",
|
||||
url: "/dashboard/deployments",
|
||||
icon: Rocket,
|
||||
isEnabled: ({ permissions }) => !!permissions?.deployment.read,
|
||||
title: "Overview",
|
||||
url: "/dashboard/overview",
|
||||
icon: LayoutGrid,
|
||||
// Only enabled for users with access to services
|
||||
isEnabled: ({ permissions }) => !!permissions?.service.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
@@ -284,6 +299,12 @@ const MENU: Menu = {
|
||||
url: "/dashboard/settings/profile",
|
||||
icon: User,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Sessions",
|
||||
icon: Smartphone,
|
||||
url: "/dashboard/settings/sessions",
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Remote Servers",
|
||||
@@ -358,11 +379,18 @@ const MENU: Menu = {
|
||||
icon: Vault,
|
||||
isEnabled: ({ permissions }) => !!permissions?.vaultProvider.create,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "DNS Providers",
|
||||
url: "/dashboard/settings/dns",
|
||||
icon: Globe,
|
||||
isEnabled: ({ permissions }) => !!permissions?.dnsProvider.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "S3 Destinations",
|
||||
url: "/dashboard/settings/destinations",
|
||||
icon: Database,
|
||||
icon: HardDrive,
|
||||
isEnabled: ({ permissions }) => !!permissions?.destination.read,
|
||||
},
|
||||
|
||||
@@ -373,14 +401,6 @@ const MENU: Menu = {
|
||||
icon: ShieldCheck,
|
||||
isEnabled: ({ permissions }) => !!permissions?.certificate.read,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Cluster",
|
||||
url: "/dashboard/settings/cluster",
|
||||
icon: Boxes,
|
||||
// Only enabled for admins
|
||||
isEnabled: ({ permissions }) => !!permissions?.organization.update,
|
||||
},
|
||||
{
|
||||
isSingle: true,
|
||||
title: "Notifications",
|
||||
@@ -573,6 +593,8 @@ function SidebarLogo() {
|
||||
const [_activeTeam, setActiveTeam] = useState<
|
||||
typeof activeOrganization | null
|
||||
>(null);
|
||||
const [organizationSelectorOpen, setOrganizationSelectorOpen] =
|
||||
useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeOrganization) {
|
||||
@@ -595,8 +617,11 @@ function SidebarLogo() {
|
||||
>
|
||||
{/* Organization Logo and Selector */}
|
||||
<SidebarMenuItem className={"w-full"}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Popover
|
||||
open={organizationSelectorOpen}
|
||||
onOpenChange={setOrganizationSelectorOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size={isCollapsed ? "sm" : "lg"}
|
||||
className={cn(
|
||||
@@ -645,145 +670,156 @@ function SidebarLogo() {
|
||||
className={cn("ml-auto", isCollapsed && "hidden")}
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-64 rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-96 p-0"
|
||||
align="start"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground shrink-0">
|
||||
Organizations
|
||||
</DropdownMenuLabel>
|
||||
<div className="overflow-y-auto overflow-x-hidden min-h-0 -mx-1 px-1">
|
||||
{organizations?.map((org) => {
|
||||
const isDefault = org.members?.[0]?.isDefault ?? false;
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row items-center justify-between gap-1"
|
||||
key={org.name}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await authClient.organization.setActive({
|
||||
organizationId: org.id,
|
||||
});
|
||||
window.location.reload();
|
||||
}}
|
||||
className="flex min-w-0 flex-1 gap-2 p-2"
|
||||
>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center rounded-sm border">
|
||||
<Logo
|
||||
className={cn(
|
||||
"transition-all",
|
||||
state === "collapsed" ? "size-4" : "size-5",
|
||||
)}
|
||||
logoUrl={org.logo ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate">{org.name}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"group",
|
||||
isDefault
|
||||
? "hover:bg-yellow-500/10"
|
||||
: "hover:bg-blue-500/10",
|
||||
)}
|
||||
isLoading={isSettingDefault && !isDefault}
|
||||
disabled={isDefault}
|
||||
onClick={async (e) => {
|
||||
if (isDefault) return;
|
||||
e.stopPropagation();
|
||||
await setDefaultOrganization({
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search organizations..."
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandList className="max-h-[min(60vh,24rem)]">
|
||||
<CommandEmpty>No organizations found.</CommandEmpty>
|
||||
<CommandGroup heading="Organizations">
|
||||
{organizations?.map((org) => {
|
||||
const isDefault = org.members?.[0]?.isDefault ?? false;
|
||||
return (
|
||||
<CommandItem
|
||||
key={org.id}
|
||||
value={org.name}
|
||||
onSelect={async () => {
|
||||
setOrganizationSelectorOpen(false);
|
||||
await authClient.organization.setActive({
|
||||
organizationId: org.id,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Default organization updated");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
error?.message ||
|
||||
"Error setting default organization",
|
||||
);
|
||||
});
|
||||
});
|
||||
window.location.reload();
|
||||
}}
|
||||
title={
|
||||
isDefault
|
||||
? "Default organization"
|
||||
: "Set as default"
|
||||
}
|
||||
className="flex items-center justify-between gap-1"
|
||||
>
|
||||
{isDefault ? (
|
||||
<Star
|
||||
fill="#eab308"
|
||||
stroke="#eab308"
|
||||
className="size-4 text-yellow-500"
|
||||
/>
|
||||
) : (
|
||||
<Star
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{org.ownerId === session?.user?.id && (
|
||||
<>
|
||||
<AddOrganization organizationId={org.id} />
|
||||
<DialogAction
|
||||
title="Delete Organization"
|
||||
description="Are you sure you want to delete this organization?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteOrganization({
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<div className="flex size-6 shrink-0 items-center justify-center rounded-sm border">
|
||||
<Logo
|
||||
className={cn(
|
||||
"transition-all",
|
||||
state === "collapsed" ? "size-4" : "size-5",
|
||||
)}
|
||||
logoUrl={org.logo ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate">{org.name}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"group",
|
||||
isDefault
|
||||
? "hover:bg-yellow-500/10"
|
||||
: "hover:bg-blue-500/10",
|
||||
)}
|
||||
isLoading={isSettingDefault && !isDefault}
|
||||
disabled={isDefault}
|
||||
onClick={async (e) => {
|
||||
if (isDefault) return;
|
||||
e.stopPropagation();
|
||||
await setDefaultOrganization({
|
||||
organizationId: org.id,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Organization deleted successfully",
|
||||
"Default organization updated",
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
error?.message ||
|
||||
"Error deleting organization",
|
||||
"Error setting default organization",
|
||||
);
|
||||
});
|
||||
}}
|
||||
title={
|
||||
isDefault
|
||||
? "Default organization"
|
||||
: "Set as default"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(user?.role === "owner" ||
|
||||
user?.role === "admin" ||
|
||||
isCloud) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<AddOrganization />
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{isDefault ? (
|
||||
<Star
|
||||
fill="#eab308"
|
||||
stroke="#eab308"
|
||||
className="size-4 text-yellow-500"
|
||||
/>
|
||||
) : (
|
||||
<Star
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="size-4 text-gray-400 group-hover:text-blue-500 transition-colors"
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
{org.ownerId === session?.user?.id && (
|
||||
<>
|
||||
<AddOrganization organizationId={org.id} />
|
||||
<DialogAction
|
||||
title="Delete Organization"
|
||||
description="Are you sure you want to delete this organization?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteOrganization({
|
||||
organizationId: org.id,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Organization deleted successfully",
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
error?.message ||
|
||||
"Error deleting organization",
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group hover:bg-red-500/10"
|
||||
isLoading={isRemoving}
|
||||
>
|
||||
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
{(user?.role === "owner" ||
|
||||
user?.role === "admin" ||
|
||||
isCloud) && (
|
||||
<div className="border-t p-1">
|
||||
<AddOrganization />
|
||||
</div>
|
||||
)}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</SidebarMenuItem>
|
||||
|
||||
{/* Notification Bell */}
|
||||
|
||||
@@ -13,6 +13,9 @@ export function SignInWithGithub() {
|
||||
try {
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: "github",
|
||||
callbackURL: "/dashboard/home",
|
||||
newUserCallbackURL: "/dashboard/home?signup=github",
|
||||
errorCallbackURL: "/",
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
|
||||
@@ -13,6 +13,9 @@ export function SignInWithGoogle() {
|
||||
try {
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider: "google",
|
||||
callbackURL: "/dashboard/home",
|
||||
newUserCallbackURL: "/dashboard/home?signup=google",
|
||||
errorCallbackURL: "/",
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
Boxes,
|
||||
Cloud,
|
||||
Database,
|
||||
Layers,
|
||||
LayoutTemplate,
|
||||
Rocket,
|
||||
Shield,
|
||||
Terminal,
|
||||
Unlock,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ShowcaseItem {
|
||||
icon: typeof Users;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const SLIDES: ShowcaseItem[][] = [
|
||||
[
|
||||
{
|
||||
icon: Users,
|
||||
title: "Team access",
|
||||
description:
|
||||
"Anyone in your team can deploy simply and securely in a safe, governed environment.",
|
||||
},
|
||||
{
|
||||
icon: Layers,
|
||||
title: "Open source",
|
||||
description:
|
||||
"Deploy for free with the open-source alternative to Netlify, Vercel, and Heroku.",
|
||||
},
|
||||
{
|
||||
icon: Bot,
|
||||
title: "AI sandbox",
|
||||
description:
|
||||
"Unleash the power of AI and test AI-generated code in a sandbox before deploying to a live URL.",
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: "Enterprise ready",
|
||||
description:
|
||||
"Scale when you're ready with granular RBAC, SSO, audit logs, rollback and multi-tenancy.",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: Rocket,
|
||||
title: "Any stack",
|
||||
description:
|
||||
"Deploy any application using Nixpacks, Heroku Buildpacks, or your own Dockerfile.",
|
||||
},
|
||||
{
|
||||
icon: Boxes,
|
||||
title: "Docker Compose",
|
||||
description:
|
||||
"Deploy complex applications natively with full Docker Compose integration.",
|
||||
},
|
||||
{
|
||||
icon: Cloud,
|
||||
title: "Multi-server",
|
||||
description:
|
||||
"Effortlessly deploy your applications on remote servers, with zero configuration hassle.",
|
||||
},
|
||||
{
|
||||
icon: LayoutTemplate,
|
||||
title: "Ready templates",
|
||||
description:
|
||||
"Get started quickly with pre-configured templates for Supabase, Cal.com, PocketBase, and more.",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
icon: Database,
|
||||
title: "Managed databases",
|
||||
description:
|
||||
"Manage and back up MySQL, PostgreSQL, MongoDB, MariaDB, and Redis directly from Dokploy.",
|
||||
},
|
||||
{
|
||||
icon: Terminal,
|
||||
title: "API & CLI",
|
||||
description: "Full API and CLI access to fit any custom workflow.",
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
title: "Live monitoring",
|
||||
description:
|
||||
"Monitor CPU, memory, and network usage in real time across every deployment.",
|
||||
},
|
||||
{
|
||||
icon: Unlock,
|
||||
title: "No lock-in",
|
||||
description:
|
||||
"Modify, scale, and customize Dokploy however your project needs.",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
const AUTO_ADVANCE_MS = 6000;
|
||||
|
||||
export const SignupShowcase = () => {
|
||||
const [active, setActive] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [reduceMotion, setReduceMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
setReduceMotion(query.matches);
|
||||
const listener = (e: MediaQueryListEvent) => setReduceMotion(e.matches);
|
||||
query.addEventListener("change", listener);
|
||||
return () => query.removeEventListener("change", listener);
|
||||
}, []);
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (paused || reduceMotion) {
|
||||
return;
|
||||
}
|
||||
timerRef.current = setInterval(() => {
|
||||
setActive((prev) => (prev + 1) % SLIDES.length);
|
||||
}, AUTO_ADVANCE_MS);
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [paused, reduceMotion]);
|
||||
|
||||
const goTo = (index: number) => {
|
||||
setActive(((index % SLIDES.length) + SLIDES.length) % SLIDES.length);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setPaused(true)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onFocus={() => setPaused(true)}
|
||||
onBlur={() => setPaused(false)}
|
||||
>
|
||||
<div
|
||||
key={active}
|
||||
className={cn(
|
||||
"grid grid-cols-2 gap-4",
|
||||
!reduceMotion &&
|
||||
"animate-in fade-in slide-in-from-bottom-2 duration-500 ease-out",
|
||||
)}
|
||||
>
|
||||
{SLIDES[active]?.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="group flex flex-col gap-3 rounded-lg border border-primary/10 bg-background/40 p-4 transition-all duration-300 hover:-translate-y-0.5 hover:border-primary/25 hover:bg-background/60"
|
||||
>
|
||||
<div className="flex size-9 items-center justify-center rounded-md bg-primary/10 text-primary transition-colors duration-300 group-hover:bg-primary/15">
|
||||
<item.icon className="size-4.5" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-primary">{item.title}</p>
|
||||
<p className="text-sm text-muted-foreground leading-snug">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-2">
|
||||
{SLIDES.map((slide, index) => (
|
||||
<button
|
||||
key={slide.map((item) => item.title).join("-")}
|
||||
type="button"
|
||||
aria-label={`Show showcase slide ${index + 1}`}
|
||||
aria-current={index === active}
|
||||
onClick={() => goTo(index)}
|
||||
className={cn(
|
||||
"h-1.5 rounded-full transition-all duration-300",
|
||||
index === active
|
||||
? "w-6 bg-primary"
|
||||
: "w-1.5 bg-primary/20 hover:bg-primary/40",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Next showcase slide"
|
||||
onClick={() => goTo(active + 1)}
|
||||
className="absolute top-1/2 right-0 z-30 flex size-9 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full border border-primary/15 bg-background text-primary shadow-sm transition-all duration-300 hover:scale-105 hover:border-primary/30 hover:shadow-md"
|
||||
>
|
||||
<ArrowRight className="size-4" strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -45,6 +45,13 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
@@ -166,7 +173,12 @@ const RESOURCE_META: Record<string, { label: string; description: string }> = {
|
||||
vaultProvider: {
|
||||
label: "Secrets Providers",
|
||||
description:
|
||||
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler) and where their secrets can be referenced",
|
||||
"Manage external secret managers (HashiCorp Vault, AWS, Azure, Infisical, Doppler, Scaleway) and where their secrets can be referenced",
|
||||
},
|
||||
dnsProvider: {
|
||||
label: "DNS Providers",
|
||||
description:
|
||||
"Manage DNS providers (Cloudflare, AWS Route53) and create, update, or delete their DNS records",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -440,6 +452,25 @@ const ACTION_META: Record<
|
||||
},
|
||||
delete: { label: "Delete", description: "Remove secret providers" },
|
||||
},
|
||||
dnsProvider: {
|
||||
read: {
|
||||
label: "Read",
|
||||
description: "View configured DNS providers and their zones/records",
|
||||
},
|
||||
create: {
|
||||
label: "Create",
|
||||
description:
|
||||
"Connect new DNS providers, test their connection, and create records",
|
||||
},
|
||||
update: {
|
||||
label: "Update",
|
||||
description: "Edit provider credentials and update existing records",
|
||||
},
|
||||
delete: {
|
||||
label: "Delete",
|
||||
description: "Remove DNS providers and delete their records",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Resources that should be hidden from the custom role editor (better-auth internals) */
|
||||
@@ -780,6 +811,78 @@ function HandleCustomRole({
|
||||
);
|
||||
}
|
||||
|
||||
const DefaultRoleSection = ({ customRoles }: { customRoles: string[] }) => {
|
||||
const utils = api.useUtils();
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { data: activeOrganization } = api.organization.active.useQuery();
|
||||
const { mutateAsync: updateOrganization, isPending: isUpdating } =
|
||||
api.organization.update.useMutation();
|
||||
const [selectedRole, setSelectedRole] = useState<string>();
|
||||
|
||||
if (auth?.role !== "owner" || !activeOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentRole = activeOrganization.defaultRole ?? "member";
|
||||
const value = selectedRole ?? currentRole;
|
||||
|
||||
const onSave = async () => {
|
||||
await updateOrganization({
|
||||
organizationId: activeOrganization.id,
|
||||
name: activeOrganization.name,
|
||||
logo: activeOrganization.logo ?? undefined,
|
||||
defaultRole: value,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Default role updated");
|
||||
utils.organization.active.invalidate();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error updating default role",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-lg border bg-muted/20 p-4">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">Default role for new members</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Assigned automatically to users joining through SSO and preselected
|
||||
when creating invitations.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={value} onValueChange={setSelectedRole}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
{customRoles.map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{role}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={isUpdating}
|
||||
disabled={value === currentRole}
|
||||
onClick={onSave}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CustomRolesContent = () => {
|
||||
const {
|
||||
data: customRoles,
|
||||
@@ -821,6 +924,9 @@ const CustomRolesContent = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DefaultRoleSection
|
||||
customRoles={customRoles?.map((role) => role.role) ?? []}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<HandleCustomRole onSuccess={refetch} />
|
||||
</div>
|
||||
|
||||
@@ -50,6 +50,7 @@ export function SignInWithSSO({
|
||||
const { data, error } = await authClient.signIn.sso({
|
||||
email: values.email,
|
||||
callbackURL: "/dashboard/home",
|
||||
errorCallbackURL: "/",
|
||||
});
|
||||
if (error) {
|
||||
toast.error(error.message ?? "Failed to sign in with SSO");
|
||||
|
||||
@@ -45,10 +45,12 @@ type ServiceItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ServiceType;
|
||||
icon: string | null;
|
||||
};
|
||||
|
||||
type NamedService = {
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
};
|
||||
|
||||
type EnvironmentServiceCollections = {
|
||||
@@ -116,8 +118,20 @@ const getStringQueryParam = (value: string | string[] | undefined) =>
|
||||
const includesSearch = (value: string | null | undefined, search: string) =>
|
||||
value?.toLowerCase().includes(search.toLowerCase()) ?? false;
|
||||
|
||||
const getServiceIcon = (type: ServiceType, className = "size-4") => {
|
||||
const Icon = SERVICE_ICONS[type];
|
||||
const getServiceIcon = (
|
||||
service: Pick<ServiceItem, "type" | "icon">,
|
||||
className = "size-4",
|
||||
) => {
|
||||
if (service.icon) {
|
||||
return (
|
||||
<img
|
||||
src={service.icon}
|
||||
alt=""
|
||||
className={`${className} object-contain shrink-0`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Icon = SERVICE_ICONS[service.type];
|
||||
return <Icon className={className} />;
|
||||
};
|
||||
|
||||
@@ -127,7 +141,7 @@ const countEnvironmentServices = (environment: ServiceCollections): number =>
|
||||
0,
|
||||
);
|
||||
|
||||
const mapServices = <T extends { name: string }>(
|
||||
const mapServices = <T extends NamedService>(
|
||||
items: readonly T[],
|
||||
getId: (item: T) => string,
|
||||
type: ServiceType,
|
||||
@@ -136,6 +150,7 @@ const mapServices = <T extends { name: string }>(
|
||||
id: getId(item),
|
||||
name: item.name,
|
||||
type,
|
||||
icon: item.icon ?? null,
|
||||
}));
|
||||
|
||||
const extractServicesFromEnvironment = (
|
||||
@@ -550,7 +565,7 @@ export const AdvanceBreadcrumb = () => {
|
||||
aria-expanded={serviceOpen}
|
||||
className="h-auto px-2 py-1.5 hover:bg-accent gap-2"
|
||||
>
|
||||
{getServiceIcon(currentService.type)}
|
||||
{getServiceIcon(currentService)}
|
||||
<span className="font-medium max-w-[50px] md:max-w-[150px] truncate">
|
||||
{currentService.name}
|
||||
</span>
|
||||
@@ -589,7 +604,7 @@ export const AdvanceBreadcrumb = () => {
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-8 rounded-md bg-muted">
|
||||
{getServiceIcon(service.type)}
|
||||
{getServiceIcon(service)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
import { css } from "@codemirror/lang-css";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { yaml } from "@codemirror/lang-yaml";
|
||||
import { StreamLanguage } from "@codemirror/language";
|
||||
import {
|
||||
getIndentUnit,
|
||||
indentService,
|
||||
StreamLanguage,
|
||||
} from "@codemirror/language";
|
||||
import { properties } from "@codemirror/legacy-modes/mode/properties";
|
||||
import { shell } from "@codemirror/legacy-modes/mode/shell";
|
||||
import { search, searchKeymap } from "@codemirror/search";
|
||||
@@ -99,6 +103,27 @@ const dockerComposeServiceOptions = [
|
||||
},
|
||||
}));
|
||||
|
||||
// The indentNodeProp shipped with @codemirror/lang-yaml computes wrong
|
||||
// column-based indents on Enter (odd/inconsistent amounts, see #4650), so
|
||||
// indentation is resolved line-based here: keep the current indent, align
|
||||
// list-entry keys after the dash marker, and go one unit deeper after a
|
||||
// line that opens a block (ending in ":", "|" or ">").
|
||||
const yamlIndent = indentService.of((context, pos) => {
|
||||
const line = context.state.doc.lineAt(pos);
|
||||
const before = context.state.doc.sliceString(line.from, pos);
|
||||
const indent = /^ */.exec(before)?.[0].length ?? 0;
|
||||
const trimmed = before.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
return indent;
|
||||
}
|
||||
const base =
|
||||
trimmed.startsWith("- ") && /:\s/.test(trimmed) ? indent + 2 : indent;
|
||||
if (/[:|>]$/.test(trimmed)) {
|
||||
return base + getIndentUnit(context.state);
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
function dockerComposeComplete(
|
||||
context: CompletionContext,
|
||||
): CompletionResult | null {
|
||||
@@ -163,7 +188,7 @@ export const CodeEditor = ({
|
||||
search(),
|
||||
keymap.of(searchKeymap),
|
||||
language === "yaml"
|
||||
? yaml()
|
||||
? [yamlIndent, yaml()]
|
||||
: language === "json"
|
||||
? json()
|
||||
: language === "css"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "organization" ADD COLUMN "default_role" text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "domain" ADD COLUMN "enabled" boolean DEFAULT true NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."VaultProviderType" ADD VALUE 'scaleway';
|
||||
@@ -0,0 +1,12 @@
|
||||
CREATE TYPE "public"."DnsProviderType" AS ENUM('cloudflare', 'route53');--> statement-breakpoint
|
||||
CREATE TABLE "dns_provider" (
|
||||
"dnsProviderId" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"providerType" "DnsProviderType" NOT NULL,
|
||||
"config" jsonb NOT NULL,
|
||||
"organizationId" text NOT NULL,
|
||||
"createdAt" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "dns_provider" ADD CONSTRAINT "dns_provider_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "dns_provider_org_name_idx" ON "dns_provider" USING btree ("organizationId","name");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1275,6 +1275,34 @@
|
||||
"when": 1786400712839,
|
||||
"tag": "0181_dashing_havok",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 182,
|
||||
"version": "7",
|
||||
"when": 1786435879438,
|
||||
"tag": "0182_skinny_wild_pack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 183,
|
||||
"version": "7",
|
||||
"when": 1786463845579,
|
||||
"tag": "0183_tearful_groot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 184,
|
||||
"version": "7",
|
||||
"when": 1786500135945,
|
||||
"tag": "0184_wonderful_absorbing_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 185,
|
||||
"version": "7",
|
||||
"when": 1786526512677,
|
||||
"tag": "0185_needy_kingpin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Initializes from defaultValue (not localStorage) to avoid an SSR/client hydration mismatch, then syncs from localStorage after mount.
|
||||
export const useSortPreference = <T extends string>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
allowedValues: readonly T[],
|
||||
) => {
|
||||
const [value, setValue] = useState<T>(defaultValue);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored && (allowedValues as readonly string[]).includes(stored)) {
|
||||
setValue(stored as T);
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
const setPreference = (next: T) => {
|
||||
setValue(next);
|
||||
localStorage.setItem(key, next);
|
||||
};
|
||||
|
||||
return [value, setPreference] as const;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.29.14",
|
||||
"version": "v0.30.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -40,7 +40,6 @@
|
||||
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-secrets-manager": "^3.1097.0",
|
||||
"@ai-sdk/anthropic": "^3.0.44",
|
||||
"@ai-sdk/azure": "^3.0.30",
|
||||
"@ai-sdk/cohere": "^3.0.21",
|
||||
@@ -48,6 +47,8 @@
|
||||
"@ai-sdk/mistral": "^3.0.20",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@aws-sdk/client-route-53": "^3.1108.0",
|
||||
"@aws-sdk/client-secrets-manager": "^3.1108.0",
|
||||
"@better-auth/api-key": "1.6.23",
|
||||
"@better-auth/passkey": "1.6.23",
|
||||
"@better-auth/scim": "1.6.23",
|
||||
@@ -105,7 +106,7 @@
|
||||
"lucide-react": "^0.469.0",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.11",
|
||||
"next": "16.2.6",
|
||||
"next": "16.3.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"nextjs-toploader": "^3.9.17",
|
||||
"node-os-utils": "2.0.1",
|
||||
@@ -174,7 +175,7 @@
|
||||
"simple-icons": "^16.14.0",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite-tsconfig-paths": "4.3.2",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import NextTopLoader from "nextjs-toploader";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { SearchCommand } from "@/components/dashboard/search-command";
|
||||
import { WhitelabelingProvider } from "@/components/proprietary/whitelabeling/whitelabeling-provider";
|
||||
import { Analytics } from "@/components/shared/analytics";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
@@ -52,6 +53,7 @@ const MyApp = ({
|
||||
>
|
||||
<NextTopLoader color="hsl(var(--sidebar-ring))" />
|
||||
<WhitelabelingProvider />
|
||||
<Analytics />
|
||||
<Toaster richColors />
|
||||
<SearchCommand />
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
|
||||
@@ -53,7 +53,9 @@ export default async function handler(
|
||||
return res.status(400).json({ error: "Missing or invalid code" });
|
||||
}
|
||||
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + result.expires_in;
|
||||
const expiresAt = result.expires_in
|
||||
? Math.floor(Date.now() / 1000) + result.expires_in
|
||||
: null;
|
||||
await updateGitlab(gitlab.gitlabId, {
|
||||
accessToken: result.access_token,
|
||||
refreshToken: result.refresh_token,
|
||||
|
||||
@@ -1,113 +1,19 @@
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { hasPermission } from "@dokploy/server/services/permission";
|
||||
import { Rocket } from "lucide-react";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import type { ReactElement } from "react";
|
||||
import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table";
|
||||
import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
const TAB_VALUES = ["deployments", "queue"] as const;
|
||||
type TabValue = (typeof TAB_VALUES)[number];
|
||||
|
||||
function isValidTab(t: string): t is TabValue {
|
||||
return TAB_VALUES.includes(t as TabValue);
|
||||
export default function DeploymentsRedirect() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function DeploymentsPage() {
|
||||
const router = useRouter();
|
||||
const tab =
|
||||
router.query.tab && isValidTab(router.query.tab as string)
|
||||
? (router.query.tab as TabValue)
|
||||
: "deployments";
|
||||
|
||||
const setTab = (value: string) => {
|
||||
if (!isValidTab(value)) return;
|
||||
router.replace(
|
||||
{ pathname: "/dashboard/deployments", query: { tab: value } },
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[45vh]">
|
||||
<div className="rounded-xl bg-background shadow-md h-full">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||
<Rocket className="size-5" />
|
||||
Deployments
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
All application and compose deployments in one place.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Tabs value={tab} onValueChange={setTab} className="w-full min-w-0">
|
||||
<TabsList className="mt-2">
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
<TabsTrigger value="queue">Queue</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="deployments" className="mt-0 min-w-0 pt-4">
|
||||
<ShowDeploymentsTable />
|
||||
</TabsContent>
|
||||
<TabsContent value="queue" className="mt-0 pt-4">
|
||||
<ShowQueueTable />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardHeader>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeploymentsPage;
|
||||
|
||||
DeploymentsPage.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout>{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const canView = await hasPermission(
|
||||
{
|
||||
user: { id: user.id },
|
||||
session: { activeOrganizationId: session?.activeOrganizationId || "" },
|
||||
},
|
||||
{ deployment: ["read"] },
|
||||
);
|
||||
|
||||
if (!canView) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
const destination =
|
||||
ctx.query.tab === "queue"
|
||||
? "/dashboard/overview?tab=deployments&subtab=queue"
|
||||
: "/dashboard/overview?tab=deployments";
|
||||
|
||||
return {
|
||||
props: {},
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowDiskUsage } from "@/components/dashboard/docker/disk-usage/show-disk-usage";
|
||||
import { ShowDockerEvents } from "@/components/dashboard/docker/events/show-docker-events";
|
||||
import { ShowHealth } from "@/components/dashboard/docker/health/show-health";
|
||||
import { ShowImages } from "@/components/dashboard/docker/images/show-images";
|
||||
import { ShowContainers } from "@/components/dashboard/docker/show/show-containers";
|
||||
import { ShowVolumes } from "@/components/dashboard/docker/volumes/show-volumes";
|
||||
import { ShowNetworks } from "@/components/dashboard/networks/show-networks";
|
||||
import { ShowNodes } from "@/components/dashboard/settings/cluster/nodes/show-nodes";
|
||||
import { ShowSwarmContainers } from "@/components/dashboard/swarm/containers/show-swarm-containers";
|
||||
import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
@@ -17,17 +22,26 @@ import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const DEFAULT_TAB = "containers";
|
||||
const DEFAULT_SWARM_TAB = "overview";
|
||||
|
||||
const Dashboard = () => {
|
||||
const router = useRouter();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
// Host-level diagnostics, so this needs server.read on top of docker.read.
|
||||
const canSeeHealth = !!permissions?.docker.read && !!permissions?.server.read;
|
||||
|
||||
const queryTab =
|
||||
typeof router.query.tab === "string" ? router.query.tab : DEFAULT_TAB;
|
||||
const activeTab = isCloud && queryTab === "networks" ? DEFAULT_TAB : queryTab;
|
||||
const activeTab =
|
||||
queryTab === "health" && !canSeeHealth ? DEFAULT_TAB : queryTab;
|
||||
|
||||
const activeSwarmTab =
|
||||
typeof router.query.subtab === "string"
|
||||
? router.query.subtab
|
||||
: DEFAULT_SWARM_TAB;
|
||||
|
||||
const setTab = (value: string) => {
|
||||
const { tab: _current, ...query } = router.query;
|
||||
const { tab: _current, subtab: _subtab, ...query } = router.query;
|
||||
router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
@@ -38,6 +52,19 @@ const Dashboard = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const setSwarmTab = (value: string) => {
|
||||
const { subtab: _current, ...query } = router.query;
|
||||
router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query:
|
||||
value === DEFAULT_SWARM_TAB ? query : { ...query, subtab: value },
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ServerFilter>
|
||||
{(serverId) => (
|
||||
@@ -45,17 +72,22 @@ const Dashboard = () => {
|
||||
<TabsList>
|
||||
<TabsTrigger value="containers">Containers</TabsTrigger>
|
||||
<TabsTrigger value="swarm">Swarm</TabsTrigger>
|
||||
<TabsTrigger value="images">Images</TabsTrigger>
|
||||
<TabsTrigger value="volumes">Volumes</TabsTrigger>
|
||||
{!isCloud && <TabsTrigger value="networks">Networks</TabsTrigger>}
|
||||
<TabsTrigger value="networks">Networks</TabsTrigger>
|
||||
<TabsTrigger value="events">Events</TabsTrigger>
|
||||
<TabsTrigger value="disk-usage">Disk Usage</TabsTrigger>
|
||||
{canSeeHealth && <TabsTrigger value="health">Health</TabsTrigger>}
|
||||
</TabsList>
|
||||
<TabsContent value="containers">
|
||||
<ShowContainers serverId={serverId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="swarm">
|
||||
<Tabs defaultValue="overview">
|
||||
<Tabs value={activeSwarmTab} onValueChange={setSwarmTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="containers">Containers</TabsTrigger>
|
||||
<TabsTrigger value="nodes">Nodes</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">
|
||||
<SwarmMonitorCard serverId={serverId} />
|
||||
@@ -67,14 +99,29 @@ const Dashboard = () => {
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="nodes">
|
||||
<ShowNodes serverId={serverId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
<TabsContent value="images">
|
||||
<ShowImages serverId={serverId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="volumes">
|
||||
<ShowVolumes serverId={serverId} />
|
||||
</TabsContent>
|
||||
{!isCloud && (
|
||||
<TabsContent value="networks">
|
||||
<ShowNetworks serverId={serverId} />
|
||||
<TabsContent value="networks">
|
||||
<ShowNetworks serverId={serverId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="events">
|
||||
<ShowDockerEvents serverId={serverId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="disk-usage">
|
||||
<ShowDiskUsage serverId={serverId} />
|
||||
</TabsContent>
|
||||
{canSeeHealth && (
|
||||
<TabsContent value="health">
|
||||
<ShowHealth serverId={serverId} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { useRouter } from "next/router";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowOverviewBackups } from "@/components/dashboard/overview/show-overview-backups";
|
||||
import { ShowOverviewDeployments } from "@/components/dashboard/overview/show-overview-deployments";
|
||||
import { ShowOverviewDomains } from "@/components/dashboard/overview/show-overview-domains";
|
||||
import { ShowOverviewServices } from "@/components/dashboard/overview/show-overview-services";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const DEFAULT_TAB = "services";
|
||||
|
||||
const Overview = () => {
|
||||
const router = useRouter();
|
||||
const { data: permissions } = api.user.getPermissions.useQuery();
|
||||
const canSeeBackups =
|
||||
!!permissions?.backup.read && !!permissions?.volumeBackup.read;
|
||||
const canSeeDomains = !!permissions?.domain.read;
|
||||
const canSeeDeployments = !!permissions?.deployment.read;
|
||||
|
||||
const queryTab =
|
||||
typeof router.query.tab === "string" ? router.query.tab : DEFAULT_TAB;
|
||||
const activeTab =
|
||||
(queryTab === "backups" && !canSeeBackups) ||
|
||||
(queryTab === "domains" && !canSeeDomains) ||
|
||||
(queryTab === "deployments" && !canSeeDeployments)
|
||||
? DEFAULT_TAB
|
||||
: queryTab;
|
||||
|
||||
const setTab = (value: string) => {
|
||||
const { tab: _current, subtab: _subtab, ...query } = router.query;
|
||||
router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: value === DEFAULT_TAB ? query : { ...query, tab: value },
|
||||
},
|
||||
undefined,
|
||||
{ shallow: true },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="services">Services</TabsTrigger>
|
||||
{canSeeBackups && <TabsTrigger value="backups">Backups</TabsTrigger>}
|
||||
{canSeeDomains && <TabsTrigger value="domains">Domains</TabsTrigger>}
|
||||
{canSeeDeployments && (
|
||||
<TabsTrigger value="deployments">Deployments</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
<TabsContent value="services">
|
||||
<ShowOverviewServices />
|
||||
</TabsContent>
|
||||
{canSeeBackups && (
|
||||
<TabsContent value="backups">
|
||||
<ShowOverviewBackups />
|
||||
</TabsContent>
|
||||
)}
|
||||
{canSeeDomains && (
|
||||
<TabsContent value="domains">
|
||||
<ShowOverviewDomains />
|
||||
</TabsContent>
|
||||
)}
|
||||
{canSeeDeployments && (
|
||||
<TabsContent value="deployments">
|
||||
<ShowOverviewDeployments />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default Overview;
|
||||
|
||||
Overview.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout>{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { req, res } = ctx;
|
||||
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
try {
|
||||
const userPermissions = await helpers.user.getPermissions.fetch();
|
||||
|
||||
if (!userPermissions?.service.read) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
+8
-14
@@ -3,33 +3,26 @@ import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowNodes } from "@/components/dashboard/settings/cluster/nodes/show-nodes";
|
||||
import { ShowDnsProviders } from "@/components/dashboard/settings/dns/show-dns-providers";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { ServerFilter } from "@/components/shared/server-filter";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<ServerFilter>
|
||||
{(serverId) => (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<ShowNodes serverId={serverId} />
|
||||
</div>
|
||||
)}
|
||||
</ServerFilter>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<ShowDnsProviders />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="Nodes">{page}</DashboardLayout>;
|
||||
return <DashboardLayout metaName="DNS Providers">{page}</DashboardLayout>;
|
||||
};
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
const { req, res } = ctx;
|
||||
const { user, session } = await validateRequest(ctx.req);
|
||||
const { user, session } = await validateRequest(req);
|
||||
if (!user || user.role === "member") {
|
||||
return {
|
||||
redirect: {
|
||||
@@ -50,6 +43,7 @@ export async function getServerSideProps(
|
||||
transformer: superjson,
|
||||
});
|
||||
await helpers.user.get.prefetch();
|
||||
await helpers.settings.isCloud.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
@@ -0,0 +1,64 @@
|
||||
import { validateRequest } from "@dokploy/server";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowSessions } from "@/components/dashboard/settings/sessions/show-sessions";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
|
||||
const Page = () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<ShowSessions />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="Sessions">{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext<{ serviceId: string }>,
|
||||
) {
|
||||
const { req, res } = ctx;
|
||||
const { user, session } = await validateRequest(req);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const helpers = createServerSideHelpers({
|
||||
router: appRouter,
|
||||
ctx: {
|
||||
req: req as any,
|
||||
res: res as any,
|
||||
db: null as any,
|
||||
session: session as any,
|
||||
user: user as any,
|
||||
},
|
||||
transformer: superjson,
|
||||
});
|
||||
|
||||
try {
|
||||
await helpers.user.get.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
trpcState: helpers.dehydrate(),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
props: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { Fingerprint } from "lucide-react";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { type ReactElement, useState } from "react";
|
||||
import { type ReactElement, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
@@ -85,6 +85,29 @@ export default function Home({ IS_CLOUD, enforceSSO }: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const queryError = router.query.error;
|
||||
if (!queryError) return;
|
||||
|
||||
const raw = Array.isArray(queryError) ? queryError[0] : queryError;
|
||||
if (!raw) return;
|
||||
const normalized = raw.replace(/[+_]/g, " ").toLowerCase();
|
||||
|
||||
setError(
|
||||
normalized.includes("account not linked")
|
||||
? "This account already exists but isn't linked to that sign-in provider yet. Contact your administrator to link it."
|
||||
: normalized.includes("access denied")
|
||||
? "Access was denied by the identity provider."
|
||||
: "We couldn't complete sign-in. Please try again or contact your administrator.",
|
||||
);
|
||||
|
||||
const { error: _removed, ...rest } = router.query;
|
||||
router.replace({ pathname: router.pathname, query: rest }, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router.query.error]);
|
||||
|
||||
const onSubmit = async (values: LoginForm) => {
|
||||
setIsLoginLoading(true);
|
||||
try {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { pushToDataLayer } from "@/lib/analytics";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
@@ -139,6 +140,9 @@ const Invitation = ({
|
||||
});
|
||||
|
||||
toast.success("Account created successfully");
|
||||
if (isCloud) {
|
||||
pushToDataLayer("sign_up", { method: "invitation" });
|
||||
}
|
||||
router.push("/dashboard/home");
|
||||
} catch {
|
||||
toast.error("An error occurred while creating your account");
|
||||
|
||||
@@ -11,6 +11,7 @@ import { z } from "zod";
|
||||
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
|
||||
import { SignInWithGithub } from "@/components/proprietary/auth/sign-in-with-github";
|
||||
import { SignInWithGoogle } from "@/components/proprietary/auth/sign-in-with-google";
|
||||
import { SignupShowcase } from "@/components/proprietary/auth/signup-showcase";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Logo } from "@/components/shared/logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { pushToDataLayer } from "@/lib/analytics";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useWhitelabelingPublic } from "@/utils/hooks/use-whitelabeling";
|
||||
|
||||
@@ -116,6 +118,7 @@ const Register = ({ isCloud }: Props) => {
|
||||
if (!isCloud) {
|
||||
router.push("/");
|
||||
} else {
|
||||
pushToDataLayer("sign_up", { method: "email" });
|
||||
setData(data);
|
||||
}
|
||||
}
|
||||
@@ -294,7 +297,12 @@ const Register = ({ isCloud }: Props) => {
|
||||
export default Register;
|
||||
|
||||
Register.getLayout = (page: ReactElement) => {
|
||||
return <OnboardingLayout>{page}</OnboardingLayout>;
|
||||
const isCloud = (page.props as Props).isCloud;
|
||||
return (
|
||||
<OnboardingLayout leftPanel={isCloud ? <SignupShowcase /> : undefined}>
|
||||
{page}
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
if (IS_CLOUD) {
|
||||
|
||||
@@ -9,7 +9,10 @@ import { clusterRouter } from "./routers/cluster";
|
||||
import { composeRouter } from "./routers/compose";
|
||||
import { deploymentRouter } from "./routers/deployment";
|
||||
import { destinationRouter } from "./routers/destination";
|
||||
import { dnsProviderRouter } from "./routers/dns-provider";
|
||||
import { dockerRouter } from "./routers/docker";
|
||||
import { dockerDiskUsageRouter } from "./routers/docker-disk-usage";
|
||||
import { dockerImageRouter } from "./routers/docker-image";
|
||||
import { dockerVolumeRouter } from "./routers/docker-volume";
|
||||
import { domainRouter } from "./routers/domain";
|
||||
import { environmentRouter } from "./routers/environment";
|
||||
@@ -25,6 +28,7 @@ import { mysqlRouter } from "./routers/mysql";
|
||||
import { networkRouter } from "./routers/network";
|
||||
import { notificationRouter } from "./routers/notification";
|
||||
import { organizationRouter } from "./routers/organization";
|
||||
import { overviewRouter } from "./routers/overview";
|
||||
import { patchRouter } from "./routers/patch";
|
||||
import { portRouter } from "./routers/port";
|
||||
import { postgresRouter } from "./routers/postgres";
|
||||
@@ -69,7 +73,10 @@ export const appRouter = createTRPCRouter({
|
||||
compose: composeRouter,
|
||||
deployment: deploymentRouter,
|
||||
destination: destinationRouter,
|
||||
dnsProvider: dnsProviderRouter,
|
||||
docker: dockerRouter,
|
||||
dockerDiskUsage: dockerDiskUsageRouter,
|
||||
dockerImage: dockerImageRouter,
|
||||
dockerVolume: dockerVolumeRouter,
|
||||
domain: domainRouter,
|
||||
gitea: giteaRouter,
|
||||
@@ -112,6 +119,7 @@ export const appRouter = createTRPCRouter({
|
||||
environment: environmentRouter,
|
||||
tag: tagRouter,
|
||||
patch: patchRouter,
|
||||
overview: overviewRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user