Merge branch 'canary' into feat/add-terminal-permission

This commit is contained in:
Mauricio Siu 2026-08-28 11:19:02 -06:00
commit e2d2dbb00a
40 changed files with 10206 additions and 58 deletions

22
.claude/settings.json Normal file
View File

@ -0,0 +1,22 @@
{
"worktree": {
"baseRef": "fresh"
},
"hooks": {
"PostToolUse": [
{
"matcher": "EnterWorktree",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\""
},
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\""
}
]
}
]
}
}

View File

@ -0,0 +1,55 @@
---
name: fix-issue
description: Implement a GitHub issue with reproduction and verification
allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__*
---
The issue number is passed as $1.
## Instance
No instance is running yet — start your own, isolated to this worktree:
1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already).
2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the
background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT`
until it answers (usually ~10-15s).
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
Note: `mcp__dokploy__*` (this repo's `.mcp.json`) resolves its URL from
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
discovered mid-session. If those tools are unavailable or point at the wrong
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
to relaunch with `DOKPLOY_BASE_URL` exported first.
## Tools
- `mcp__dokploy__*` — the Dokploy API of the running instance. Use it to set up
state (create a project, an app, an env var) and to verify backend behavior.
Search for the tool you need; they are not all loaded upfront.
- `mcp__playwright__*` — the browser at $DOKPLOY_BASE_URL. Use it for anything
a user would see or click.
Pick by where the bug lives, not by convenience:
- Bug in the UI (rendering, forms, navigation, state) → reproduce in Playwright.
The API returning correct data proves nothing here.
- Bug in the API, deploy logic, or data → reproduce with the Dokploy MCP.
A green screenshot proves nothing here.
- Unclear → do both.
Use the MCP to reach the state you need quickly, then verify in the UI. Do not
click through ten screens to create a project the API can create in one call.
## Steps
1. Run `gh issue view $1` and read the full issue, including comments.
2. Reproduce the bug with the appropriate tool. If you cannot reproduce it,
comment on the issue explaining what you tried and STOP.
Do not implement anything.
3. Implement the fix. Keep the change minimal and scoped to the issue.
4. Run `pnpm test`, then re-run the same reproduction from step 2.
5. Only if both pass: commit and run `gh pr create`. The PR description must
include the before/after reproduction steps and reference the issue.
Never skip step 2. A fix you cannot reproduce and then verify is not a fix.

2
.worktreeinclude Normal file
View File

@ -0,0 +1,2 @@
.env
.env.local

6
CLAUDE.md Normal file
View File

@ -0,0 +1,6 @@
## Code style
- Don't write comments that restate what the code already says.
- Comment only the "why" when something isn't obvious: workarounds,
counterintuitive decisions, constraints from an external API.
- No section-divider comments like `// --- Helpers ---`.
- Don't leave comments describing the change you just made.

View File

@ -0,0 +1,58 @@
import { createCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it } from "vitest";
const base = {
composeType: "docker-compose" as const,
appName: "compose-app",
sourceType: "github" as const,
command: "",
};
describe("compose createCommand --project-directory", () => {
it("pins --project-directory to the code dir when composePath is nested", () => {
const cmd = createCommand(
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain(
"--project-directory /etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("-f ./deploy/docker-compose.yml");
});
it("omits --project-directory when no projectPath is passed", () => {
const cmd = createCommand({
...base,
composePath: "./deploy/docker-compose.yml",
} as any);
expect(cmd).not.toContain("--project-directory");
});
it("does not add --project-directory to stack deploy (unsupported flag)", () => {
const cmd = createCommand(
{
...base,
composeType: "stack",
composePath: "./deploy/docker-compose.yml",
} as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).not.toContain("--project-directory");
expect(cmd.startsWith("stack deploy")).toBe(true);
});
it("keeps raw sourceType resolving from the code dir (root docker-compose.yml)", () => {
const cmd = createCommand(
{ ...base, sourceType: "raw", composePath: "docker-compose.yml" } as any,
"/etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain(
"--project-directory /etc/dokploy/compose/compose-app/code",
);
expect(cmd).toContain("-f docker-compose.yml");
});
});

View File

@ -32,6 +32,9 @@ const cases: Record<string, string> = {
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
APP_URL: "https://example.com",
ASSET_URL: "https://example.com",
DB_HOST: "localhost",
};
// How each value must be typed in the UI so the (unchanged) dotenv input
@ -46,6 +49,9 @@ const inputEncoding: Record<string, string> = {
APOSTROPHE: "it's a test",
UNICODE: "héllo wörld 日本語 🚀",
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
APP_URL: "https://example.com",
ASSET_URL: '"${APP_URL}"',
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
};
describe("getCreateEnvFileCommand", () => {

View File

@ -50,6 +50,15 @@ describe("getRailpackCommand", () => {
expect(command).toContain("--build-arg cache-key=");
});
it("installs Railpack through sudo for non-root users", () => {
const command = getRailpackCommand(createApplication());
expect(command).toContain(
'$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"',
);
expect(command).toContain("sudo -n true 2>/dev/null");
});
it("changes secrets-hash when an environment value changes", () => {
const firstCommand = getRailpackCommand(
createApplication({

View File

@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
const containers = [
{ containerId: "first-container" },
{ containerId: "selected-container" },
];
describe("resolveContainerSelection", () => {
it("selects the first container when no container is selected", () => {
expect(resolveContainerSelection(undefined, containers)).toBe(
"first-container",
);
});
it("preserves a manual selection when refreshed data contains it", () => {
const refreshedContainers = containers.map((container) => ({
...container,
}));
expect(
resolveContainerSelection("selected-container", refreshedContainers),
).toBe("selected-container");
});
it("falls back to the first container when the selection disappears", () => {
expect(resolveContainerSelection("removed-container", containers)).toBe(
"first-container",
);
});
it("keeps the current selection while container data is loading", () => {
expect(resolveContainerSelection("selected-container", undefined)).toBe(
"selected-container",
);
});
it("clears the selection when no containers are available", () => {
expect(resolveContainerSelection("selected-container", [])).toBeUndefined();
});
});

View File

@ -0,0 +1,236 @@
import { execSync } from "node:child_process";
import { docker } from "@dokploy/server/constants";
import { setupMonitoring } from "@dokploy/server/setup/monitoring-setup";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
const REAL_TEST_TIMEOUT = 120000;
const SERVICE_NAME = "dokploy-monitoring";
const TEST_IMAGE = "busybox:latest";
// Mock ONLY db-backed lookups and remote I/O. Dockerode stays real and talks to
// the local daemon, so the legacy-container cleanup is exercised for real.
vi.mock("@dokploy/server/services/server", () => ({
findServerById: vi.fn().mockResolvedValue({
serverId: "test-server",
serverType: "deploy",
sshKeyId: null, // -> getRemoteDocker returns the local docker instance
metricsConfig: {
server: {
type: "Remote",
port: 4500,
token: "test-token",
urlCallback: "http://localhost/callback",
cronJob: "0 0 * * *",
retentionDays: 2,
refreshRate: 60,
thresholds: { cpu: 0, memory: 0 },
},
containers: { refreshRate: 60, services: { include: [], exclude: [] } },
},
}),
}));
vi.mock("@dokploy/server/services/settings", () => ({
getDokployImageTag: vi.fn(() => "latest"),
}));
vi.mock("@dokploy/server/utils/docker/utils", () => ({
pullImage: vi.fn().mockResolvedValue(undefined),
pullRemoteImage: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
execAsyncRemote: vi.fn().mockResolvedValue({ stdout: "", stderr: "" }),
}));
const containerExists = async (name: string) => {
try {
await docker.getContainer(name).inspect();
return true;
} catch (error: any) {
if (error.statusCode === 404) return false;
throw error;
}
};
const serviceExists = async (name: string) => {
try {
await docker.getService(name).inspect();
return true;
} catch (error: any) {
if (error.statusCode === 404) return false;
throw error;
}
};
const swarmTaskNames = async () => {
const list = await docker.listContainers({ all: true });
return list
.flatMap((c) => c.Names.map((n) => n.replace(/^\//, "")))
.filter((n) => n.startsWith(`${SERVICE_NAME}.`));
};
const cleanup = async () => {
try {
await docker.getService(SERVICE_NAME).remove();
} catch {}
try {
await docker.getContainer(SERVICE_NAME).remove({ force: true });
} catch {}
for (const name of await swarmTaskNames()) {
try {
await docker.getContainer(name).remove({ force: true });
} catch {}
}
};
// Recreates the pre-v0.30.0 standalone agent stuck in a crash loop.
const createLegacyZombie = async () => {
const container = await docker.createContainer({
name: SERVICE_NAME,
Image: TEST_IMAGE,
Cmd: [
"sh",
"-c",
"echo 'Error starting metrics cleanup system: empty spec string'; exit 1",
],
HostConfig: { RestartPolicy: { Name: "always" }, NetworkMode: "host" },
});
await container.start();
};
const pullTestImage = async () => {
try {
await docker.getImage(TEST_IMAGE).inspect();
return;
} catch {}
await new Promise<void>((resolve, reject) =>
docker.pull(TEST_IMAGE, (err: any, stream: any) =>
err
? reject(err)
: docker.modem.followProgress(stream, (e: any) =>
e ? reject(e) : resolve(),
),
),
);
};
// The code under test hardcodes the production name, so this suite cannot
// namespace its fixtures. Skip rather than wipe a real agent on a Dokploy host.
const hasRealMonitoring = () => {
const query = (cmd: string) => {
try {
return execSync(cmd, { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
} catch {
return "";
}
};
return (
query(
`docker service ls --filter name=${SERVICE_NAME} --format '{{.Name}}'`,
) !== "" ||
query(
`docker ps -a --filter name=^${SERVICE_NAME}$ --format '{{.Names}}'`,
) !== ""
);
};
describe.skipIf(hasRealMonitoring())(
"setupMonitoring - legacy container cleanup (real docker)",
() => {
beforeEach(async () => {
await pullTestImage();
await cleanup();
}, REAL_TEST_TIMEOUT);
afterAll(async () => {
await cleanup();
}, REAL_TEST_TIMEOUT);
it(
"removes the legacy standalone container left behind by the swarm migration",
async () => {
await createLegacyZombie();
expect(await containerExists(SERVICE_NAME)).toBe(true);
await setupMonitoring("test-server");
expect(await containerExists(SERVICE_NAME)).toBe(false);
expect(await serviceExists(SERVICE_NAME)).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"removes the legacy container without touching swarm tasks, which are named dokploy-monitoring.<slot>.<id>",
async () => {
const taskName = `${SERVICE_NAME}.1.qh3ldvg2h9x0test`;
const task = await docker.createContainer({
name: taskName,
Image: TEST_IMAGE,
Cmd: ["sleep", "3600"],
});
await task.start();
await createLegacyZombie();
await setupMonitoring("test-server");
expect(await containerExists(SERVICE_NAME)).toBe(false);
expect(await containerExists(taskName)).toBe(true);
const inspect = await docker.getContainer(taskName).inspect();
expect(inspect.State.Running).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"is idempotent when no legacy container exists",
async () => {
expect(await containerExists(SERVICE_NAME)).toBe(false);
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
expect(await serviceExists(SERVICE_NAME)).toBe(true);
},
REAL_TEST_TIMEOUT,
);
it(
"deploys the service even when removing the legacy container fails",
async () => {
const failingDocker = {
getContainer: () => ({
remove: async () => {
const error: any = new Error("device or resource busy");
error.statusCode = 500;
throw error;
},
}),
getService: docker.getService.bind(docker),
createService: docker.createService.bind(docker),
};
const remoteDocker = await import(
"@dokploy/server/utils/servers/remote-docker"
);
const spy = vi
.spyOn(remoteDocker, "getRemoteDocker")
.mockResolvedValue(failingDocker as any);
try {
await expect(setupMonitoring("test-server")).resolves.not.toThrow();
expect(spy).toHaveBeenCalled(); // guards against the spy silently not intercepting
expect(await serviceExists(SERVICE_NAME)).toBe(true);
} finally {
spy.mockRestore();
}
},
REAL_TEST_TIMEOUT,
);
},
);

View File

@ -0,0 +1,71 @@
import { reconnectServicesToTraefik } from "@dokploy/server/services/settings";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
findMany: vi.fn(),
execAsync: vi.fn(),
execAsyncRemote: vi.fn(),
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
compose: {
findMany: mocks.findMany,
},
},
},
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: mocks.execAsync,
execAsyncRemote: mocks.execAsyncRemote,
}));
describe("reconnectServicesToTraefik", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.findMany.mockResolvedValue([]);
});
it("does not execute an empty local command when no isolated deployments exist", async () => {
await reconnectServicesToTraefik();
expect(mocks.execAsync).not.toHaveBeenCalled();
});
it("does not execute an empty remote command when no isolated deployments exist", async () => {
await reconnectServicesToTraefik("server-id");
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
});
it("reconnects isolated deployments to the local Traefik network", async () => {
mocks.findMany.mockResolvedValue([
{ appName: "first-compose" },
{ appName: "second-compose" },
]);
await reconnectServicesToTraefik();
expect(mocks.execAsync).toHaveBeenCalledOnce();
expect(mocks.execAsync).toHaveBeenCalledWith(
'docker network connect first-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n' +
'docker network connect second-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n',
);
expect(mocks.execAsyncRemote).not.toHaveBeenCalled();
});
it("reconnects isolated deployments on a remote server", async () => {
mocks.findMany.mockResolvedValue([{ appName: "remote-compose" }]);
await reconnectServicesToTraefik("server-id");
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
"server-id",
'docker network connect remote-compose $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1\n',
);
expect(mocks.execAsync).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,104 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { writeAppTraefikConfig } from "@dokploy/server/utils/traefik/application";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execAsyncRemote: vi.fn(),
}));
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@dokploy/server/utils/process/execAsync")>();
return {
...actual,
execAsyncRemote: mocks.execAsyncRemote,
};
});
describe("writeAppTraefikConfig", () => {
let cwd: string;
let dynamicPath: string;
beforeEach(() => {
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dokploy-traefik-"));
dynamicPath = path.join(cwd, ".docker", "traefik", "dynamic");
fs.mkdirSync(dynamicPath, { recursive: true });
vi.spyOn(process, "cwd").mockReturnValue(cwd);
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(cwd, { recursive: true, force: true });
});
// Regression test for #5189: Traefik's file provider rejects a standalone
// `routers: {}` / `services: {}` map and aborts its watcher for every
// dynamic config once it hits one, so an app with no domains must never
// get an on-disk config file at all.
it("removes the file instead of writing empty routers/services", async () => {
const appName = "no-domain-app";
const configPath = path.join(dynamicPath, `${appName}.yml`);
fs.writeFileSync(configPath, "stale content", "utf8");
await writeAppTraefikConfig(
{ http: { routers: {}, services: {} } },
appName,
);
expect(fs.existsSync(configPath)).toBe(false);
});
it("writes the file when routers/services are present", async () => {
const appName = "with-domain-app";
const configPath = path.join(dynamicPath, `${appName}.yml`);
await writeAppTraefikConfig(
{
http: {
routers: { [`${appName}-router-1`]: { rule: "Host(`x`)" } },
services: {},
},
},
appName,
);
expect(fs.existsSync(configPath)).toBe(true);
});
it("removes the remote file instead of writing empty routers/services", async () => {
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
await writeAppTraefikConfig(
{ http: { routers: {}, services: {} } },
"no-domain-app",
"server-id",
);
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
const [, command] = mocks.execAsyncRemote.mock.calls[0];
expect(command).toMatch(/^rm -f /);
expect(command).toContain("no-domain-app.yml");
});
it("writes the remote file when routers/services are present", async () => {
mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" });
await writeAppTraefikConfig(
{
http: {
routers: { "with-domain-app-router-1": { rule: "Host(`x`)" } },
services: {},
},
},
"with-domain-app",
"server-id",
);
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
const [, command] = mocks.execAsyncRemote.mock.calls[0];
expect(command).toMatch(/^echo /);
});
});

View File

@ -94,4 +94,32 @@ describe("readValidDirectory (path traversal)", () => {
),
).toBe(true);
});
it("returns true for SvelteKit routes with + prefix and @ symbols", () => {
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+page.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+layout.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+server.ts`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/src/routes/+error.svelte`,
),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/node_modules/@types/node/index.d.ts`,
),
).toBe(true);
});
});

View File

@ -1,6 +1,7 @@
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
import { Badge } from "@/components/ui/badge";
import {
Card,
@ -79,17 +80,13 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
},
);
const availableContainers = option === "native" ? containers : services;
useEffect(() => {
if (option === "native") {
if (containers && containers?.length > 0) {
setContainerId(containers[0]?.containerId);
}
} else {
if (services && services?.length > 0) {
setContainerId(services[0]?.containerId);
}
}
}, [option, services, containers]);
setContainerId((currentContainerId) =>
resolveContainerSelection(currentContainerId, availableContainers),
);
}, [availableContainers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLength =
@ -114,6 +111,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setContainerId(undefined);
setOption(checked ? "native" : "swarm");
}}
/>

View File

@ -2,6 +2,7 @@ import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { resolveContainerSelection } from "@/components/dashboard/docker/logs/utils";
import { Badge } from "@/components/ui/badge";
import {
Card,
@ -70,18 +71,13 @@ export const ShowDockerLogsStack = ({
);
const containers = data?.filter((container) => container.containerId);
const availableContainers = option === "native" ? containers : services;
useEffect(() => {
if (option === "native") {
if (containers && containers?.length > 0) {
setContainerId(containers[0]?.containerId);
}
} else {
if (services && services?.length > 0) {
setContainerId(services[0]?.containerId);
}
}
}, [option, services, containers]);
setContainerId((currentContainerId) =>
resolveContainerSelection(currentContainerId, availableContainers),
);
}, [availableContainers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLength =
@ -106,6 +102,7 @@ export const ShowDockerLogsStack = ({
<Switch
checked={option === "native"}
onCheckedChange={(checked) => {
setContainerId(undefined);
setOption(checked ? "native" : "swarm");
}}
/>

View File

@ -7,6 +7,28 @@ export interface LogLine {
message: string;
}
interface ContainerOption {
containerId: string;
}
export const resolveContainerSelection = (
currentContainerId: string | undefined,
containers: readonly ContainerOption[] | undefined,
) => {
if (!containers) {
return currentContainerId;
}
if (
currentContainerId &&
containers.some(({ containerId }) => containerId === currentContainerId)
) {
return currentContainerId;
}
return containers[0]?.containerId;
};
interface LogStyle {
type: LogType;
variant: LogVariant;

View File

@ -19,6 +19,11 @@ interface NetworkChartProps {
data: any[];
}
function formatNetworkGB(valueInMB: number) {
if (Number.isNaN(valueInMB)) return "0";
return (valueInMB / 1024).toFixed(1);
}
const chartConfig = {
networkIn: {
label: "Network In",
@ -38,8 +43,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
<CardHeader className="border-b py-5">
<CardTitle>Network</CardTitle>
<CardDescription>
Network Traffic: {latestData.networkOut} KB/s {" "}
{latestData.networkIn} KB/s
Network Total: {formatNetworkGB(latestData.networkOut)} GB {" "}
{formatNetworkGB(latestData.networkIn)} GB (since Boot)
</CardDescription>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
@ -83,7 +88,7 @@ export function NetworkChart({ data }: NetworkChartProps) {
minTickGap={32}
tickFormatter={(value) => formatTimestamp(value)}
/>
<YAxis tickFormatter={(value) => `${value} KB/s`} />
<YAxis tickFormatter={(value) => `${formatNetworkGB(value)} GB`} />
<ChartTooltip
cursor={false}
content={({ active, payload, label }) => {
@ -105,8 +110,8 @@ export function NetworkChart({ data }: NetworkChartProps) {
Network
</span>
<span className="font-bold">
{data.networkOut} KB/s
<br /> {data.networkIn} KB/s
{formatNetworkGB(data.networkOut)} GB
<br /> {formatNetworkGB(data.networkIn)} GB
</span>
</div>
</div>

View File

@ -34,6 +34,7 @@ export const SyncNetworks = ({ serverId }: Props) => {
const importMutation = api.network.import.useMutation();
const removeMutation = api.network.remove.useMutation();
const recreateMutation = api.network.recreate.useMutation();
const resyncMutation = api.network.resync.useMutation();
const toggleSelected = (name: string) => {
setSelected((prev) => {
@ -102,6 +103,20 @@ export const SyncNetworks = ({ serverId }: Props) => {
}
};
const onResync = async (networkId: string, name: string) => {
try {
await resyncMutation.mutateAsync({ networkId });
toast.success(`Network "${name}" updated from Docker`);
await utils.network.all.invalidate();
await utils.network.networksToSync.invalidate();
await refetch();
} catch (error) {
toast.error("Error updating network", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Dialog
open={open}
@ -177,6 +192,45 @@ export const SyncNetworks = ({ serverId }: Props) => {
)}
</div>
{!!data?.changed.length && (
<>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Changed ({data.changed.length})
</span>
<span className="text-xs text-muted-foreground">
These networks were deleted and recreated in Docker under
the same name attributes in Dokploy are outdated.
</span>
{data.changed.map((changed) => (
<div
key={changed.networkId}
className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3"
>
<div className="flex items-center gap-3">
<span className="text-sm">{changed.name}</span>
{changed.driver && (
<Badge variant="outline">{changed.driver}</Badge>
)}
</div>
<Button
variant="outline"
size="xs"
isLoading={resyncMutation.isPending}
onClick={() =>
onResync(changed.networkId, changed.name)
}
>
<RotateCcw className="size-3.5" />
Update
</Button>
</div>
))}
</div>
</>
)}
{!!data?.missing.length && (
<>
<Separator />

View File

@ -48,6 +48,7 @@ export function AddOrganization({ organizationId }: Props) {
},
{
enabled: !!organizationId,
refetchOnWindowFocus: false,
},
);
const { mutateAsync, isPending } = organizationId

View File

@ -0,0 +1 @@
ALTER TABLE "network" ADD COLUMN "dockerId" text;

File diff suppressed because it is too large Load Diff

View File

@ -1303,6 +1303,13 @@
"when": 1786526512677,
"tag": "0185_needy_kingpin",
"breakpoints": true
},
{
"idx": 186,
"version": "7",
"when": 1787813846067,
"tag": "0186_tearful_dragon_man",
"breakpoints": true
}
]
}

View File

@ -1,3 +1,4 @@
import { join } from "node:path";
import {
addDomainToCompose,
clearOldDeployments,
@ -32,6 +33,7 @@ import {
updateCompose,
updateDeploymentStatus,
} from "@dokploy/server";
import { paths } from "@dokploy/server/constants";
import { db } from "@dokploy/server/db";
import { canEditDeployGitSource } from "@dokploy/server/services/git-provider";
import {
@ -547,7 +549,9 @@ export const composeRouter = createTRPCRouter({
service: ["create"],
});
const compose = await findComposeById(input.composeId);
const command = createCommand(compose);
const { COMPOSE_PATH } = paths(!!compose.serverId);
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const command = createCommand(compose, projectPath);
return `docker ${command}`;
}),
refreshToken: protectedProcedure

View File

@ -6,6 +6,7 @@ import {
inspectNetwork,
recreateNetwork,
removeNetwork,
resyncNetwork,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, isNull } from "drizzle-orm";
@ -129,6 +130,29 @@ export const networkRouter = createTRPCRouter({
return recreated;
}),
resync: protectedProcedure
.input(apiFindOneNetwork)
.mutation(async ({ ctx, input }) => {
const network = await findNetworkById(input.networkId);
if (network.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Network not found",
});
}
const resynced = await resyncNetwork(
input.networkId,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "update",
resourceType: "network",
resourceId: resynced.networkId,
resourceName: resynced.name,
});
return resynced;
}),
remove: protectedProcedure
.input(apiRemoveNetwork)
.mutation(async ({ ctx, input }) => {

View File

@ -21,6 +21,7 @@ export const network = pgTable("network", {
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
dockerId: text("dockerId"),
driver: networkDriver("driver").notNull().default("bridge"),
internal: boolean("internal").notNull().default(false),
attachable: boolean("attachable").notNull().default(false),

View File

@ -262,18 +262,28 @@ export const updateFileMount = async (mountId: string) => {
if (!mount || !mount.filePath) return;
const basePath = await getBaseFilesPath(mountId);
const fullPath = path.join(basePath, mount.filePath);
const directory = path.dirname(fullPath);
try {
const serverId = await getServerId(mount);
const encodedContent = encodeBase64(mount.content || "");
const command = `echo "${encodedContent}" | base64 -d > ${quote([fullPath])}`;
const command = `
mkdir -p ${quote([directory])};
if [ -d ${quote([fullPath])} ]; then rm -rf ${quote([fullPath])}; fi;
echo "${encodedContent}" | base64 -d > ${quote([fullPath])};
`;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
} catch {
console.log("Error updating file mount");
} catch (error) {
console.log(`Error updating the file mount: ${error}`);
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error updating the mount ${error instanceof Error ? error.message : error}`,
cause: error,
});
}
};

View File

@ -1,6 +1,7 @@
import { db } from "@dokploy/server/db";
import { type apiCreateNetwork, network } from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import type Dockerode from "dockerode";
import { and, eq, inArray, isNull } from "drizzle-orm";
import type { z } from "zod";
import { IS_CLOUD } from "../constants";
@ -17,6 +18,7 @@ const RESERVED_NETWORKS = [
];
type DockerNetworkInfo = {
Id?: string;
Name: string;
Driver: string;
Internal?: boolean;
@ -35,6 +37,12 @@ type DockerNetworkInfo = {
};
};
// EnableIPv4 is missing from dockerode's NetworkCreateOptions but supported
// by the daemon (API >= 1.47); the body is sent as-is
type NetworkCreateOptions = Dockerode.NetworkCreateOptions & {
EnableIPv4?: boolean;
};
const parseMtu = (value: string | undefined) => {
const mtu = Number.parseInt(value ?? "", 10);
return Number.isNaN(mtu) ? null : mtu;
@ -51,6 +59,7 @@ const mapDockerNetworkToRow = (
serverId: string | null,
) => ({
name: dockerNetwork.Name,
dockerId: dockerNetwork.Id ?? null,
driver: dockerNetwork.Driver as "bridge" | "overlay",
internal: dockerNetwork.Internal ?? false,
attachable: dockerNetwork.Attachable ?? false,
@ -110,7 +119,7 @@ export const findNetworksToSync = async (
const existing = await findNetworksByServer(organizationId, serverId);
const existingNames = new Set(existing.map((row) => row.name));
const dockerNames = new Set(dockerNetworks.map((d) => d.Name));
const dockerByName = new Map(dockerNetworks.map((d) => [d.Name, d] as const));
const importable = dockerNetworks
.filter(
@ -128,12 +137,28 @@ export const findNetworksToSync = async (
.filter((s): s is string => !!s),
}));
// Rows in Dokploy whose network no longer exists in Docker
const missing = existing
.filter((row) => !dockerNames.has(row.name))
.filter((row) => !dockerByName.has(row.name))
.map((row) => ({ networkId: row.networkId, name: row.name }));
return { importable, missing };
const changed = existing
.filter((row) => {
if (!row.dockerId) return false;
const dockerNetwork = dockerByName.get(row.name);
return !!dockerNetwork?.Id && dockerNetwork.Id !== row.dockerId;
})
.map((row) => {
const dockerNetwork = dockerByName.get(row.name);
return {
networkId: row.networkId,
name: row.name,
driver: dockerNetwork?.Driver,
internal: dockerNetwork?.Internal ?? false,
attachable: dockerNetwork?.Attachable ?? false,
};
});
return { importable, missing, changed };
};
export const importDockerNetworks = async (
@ -184,6 +209,49 @@ export const importDockerNetworks = async (
return { imported, errors };
};
export const resyncNetwork = async (
networkId: string,
organizationId: string,
) => {
const row = await findNetworkById(networkId);
if (row.organizationId !== organizationId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Network not found",
});
}
const docker = await getRemoteDocker(row.serverId ?? null);
let info: DockerNetworkInfo;
try {
info = (await docker.getNetwork(row.name).inspect()) as DockerNetworkInfo;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Failed to inspect Docker network",
cause: error,
});
}
const [updated] = await db
.update(network)
.set(mapDockerNetworkToRow(info, organizationId, row.serverId))
.where(eq(network.networkId, networkId))
.returning();
if (!updated) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Network not found",
});
}
return updated;
};
export const findNetworkById = async (networkId: string) => {
const [row] = await db
.select()
@ -252,14 +320,12 @@ const createDockerNetworkFromRow = async (row: typeof network.$inferSelect) => {
const docker = await getRemoteDocker(row.serverId ?? null);
try {
await docker.createNetwork({
const createOptions: NetworkCreateOptions = {
Name: row.name,
Driver: row.driver,
CheckDuplicate: true,
Internal: row.internal,
Attachable: row.attachable,
// EnableIPv4 is missing from dockerode's types but supported by
// the daemon (API >= 1.47); the body is sent as-is
EnableIPv4: row.enableIPv4,
EnableIPv6: row.enableIPv6,
Options: row.mtu
@ -269,7 +335,13 @@ const createDockerNetworkFromRow = async (row: typeof network.$inferSelect) => {
Driver: ipam.driver || "default",
Config: ipamConfig.length > 0 ? ipamConfig : undefined,
},
} as Parameters<typeof docker.createNetwork>[0]);
};
const created = await docker.createNetwork(createOptions);
await db
.update(network)
.set({ dockerId: created.id })
.where(eq(network.networkId, row.networkId));
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",

View File

@ -485,7 +485,7 @@ export const reconnectServicesToTraefik = async (serverId?: string) => {
),
});
if (!composeResult) {
if (composeResult.length === 0) {
return;
}
let commands = "";

View File

@ -21,11 +21,31 @@ const getMonitoringImage = () => {
return imageName;
};
// Swarm tasks are dokploy-monitoring.<slot>.<id>, so this only matches the
// pre-v0.30.0 standalone container. A cleanup failure must not block the deploy.
const removeLegacyContainer = async (
docker: Awaited<ReturnType<typeof getRemoteDocker>>,
serviceName: string,
) => {
try {
await docker.getContainer(serviceName).remove({ force: true });
console.log("Removed legacy monitoring container ✅");
} catch (error: any) {
if (error?.statusCode !== 404) {
console.warn(
`Could not remove legacy monitoring container: ${error?.message ?? error}`,
);
}
}
};
const deployMonitoringService = async (
docker: Awaited<ReturnType<typeof getRemoteDocker>>,
serviceName: string,
settings: CreateServiceOptions,
) => {
await removeLegacyContainer(docker, serviceName);
try {
const service = docker.getService(serviceName);
const inspect = await service.inspect();

View File

@ -86,6 +86,7 @@ export const serverSetup = async (
...server.metricsConfig.server,
token: token,
urlCallback: urlCallback,
cronJob: server.metricsConfig.server.cronJob || "0 0 * * *",
},
containers: server.metricsConfig.containers,
},

View File

@ -21,11 +21,11 @@ export const getBuildComposeCommand = async (rawCompose: ComposeNested) => {
const compose = await withResolvedVaultRefs(rawCompose);
const { COMPOSE_PATH } = paths(!!compose.serverId);
const { sourceType, appName, mounts, composeType, domains } = compose;
const command = createCommand(compose);
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const command = createCommand(compose, projectPath);
const envCommand = compose.createEnvFile
? getCreateEnvFileCommand(compose)
: "";
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
const exportEnvCommand = getExportEnvCommand(compose);
const newCompose = await writeDomainsToCompose(compose, domains);
@ -119,7 +119,7 @@ const sanitizeCommand = (command: string) => {
return restCommand.join(" ");
};
export const createCommand = (compose: ComposeNested) => {
export const createCommand = (compose: ComposeNested, projectPath?: string) => {
const { composeType, appName, sourceType } = compose;
if (compose.command) {
return `${sanitizeCommand(compose.command)}`;
@ -130,7 +130,10 @@ export const createCommand = (compose: ComposeNested) => {
let command = "";
if (composeType === "docker-compose") {
command = `compose -p ${quote([appName])} -f ${quote([path])} up -d --build --remove-orphans`;
const projectDirectoryFlag = projectPath
? `--project-directory ${quote([projectPath])} `
: "";
command = `compose -p ${quote([appName])} ${projectDirectoryFlag}-f ${quote([path])} up -d --build --remove-orphans`;
} else if (composeType === "stack") {
command = `stack deploy -c ${quote([path])} ${quote([appName])} --prune --with-registry-auth`;
}

View File

@ -87,7 +87,15 @@ export const getRailpackCommand = (application: ApplicationNested) => {
# Ensure we have a builder with containerd (isolated per build)
export RAILPACK_VERSION=${application.railpackVersion}
bash -c "$(curl -fsSL https://railpack.com/install.sh)"
# use sudo for non-root so the install can write to /usr/local/bin
if [ "$(id -u)" -eq 0 ]; then
SUDO_CMD=""
elif sudo -n true 2>/dev/null; then
SUDO_CMD="sudo"
else
SUDO_CMD=""
fi
$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"
docker buildx create --name ${builderName} --driver docker-container || true
echo "Preparing Railpack build plan..." ;

View File

@ -548,7 +548,7 @@ export const prepareEnvironmentVariablesForFile = (
const escapedValue = value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\$/g, "\\$");
.replace(/\$(?!\{[A-Za-z_][A-Za-z0-9_]*(?::?[-+?][^{}]*)?\})/g, "\\$");
return `${key}="${escapedValue}"`;
});
};

View File

@ -292,6 +292,26 @@ export const writeTraefikConfigRemote = async (
}
};
const isEmptyHttpRoutersAndServices = (traefikConfig: FileConfig) =>
Object.keys(traefikConfig.http?.routers || {}).length === 0 &&
Object.keys(traefikConfig.http?.services || {}).length === 0;
export const writeAppTraefikConfig = async (
traefikConfig: FileConfig,
appName: string,
serverId?: string | null,
) => {
if (isEmptyHttpRoutersAndServices(traefikConfig)) {
await removeTraefikConfig(appName, serverId);
return;
}
if (serverId) {
await writeTraefikConfigRemote(traefikConfig, appName, serverId);
} else {
writeTraefikConfig(traefikConfig, appName);
}
};
export const createServiceConfig = (
appName: string,
domain: Domain,

View File

@ -3,7 +3,7 @@ import type { ApplicationNested } from "../builders";
import {
loadOrCreateConfig,
loadOrCreateConfigRemote,
writeTraefikConfig,
writeAppTraefikConfig,
writeTraefikConfigRemote,
} from "./application";
import type { FileConfig } from "./file-types";
@ -89,11 +89,10 @@ export const createRedirectMiddleware = async (
if (serverId) {
await writeTraefikConfigRemote(config, "middlewares", serverId);
await writeTraefikConfigRemote(appConfig, appName, serverId);
} else {
writeMiddleware(config);
writeTraefikConfig(appConfig, appName);
}
await writeAppTraefikConfig(appConfig, appName, serverId);
};
export const removeRedirectMiddleware = async (
@ -124,9 +123,8 @@ export const removeRedirectMiddleware = async (
if (serverId) {
await writeTraefikConfigRemote(config, "middlewares", serverId);
await writeTraefikConfigRemote(appConfig, appName, serverId);
} else {
writeTraefikConfig(appConfig, appName);
writeMiddleware(config);
}
await writeAppTraefikConfig(appConfig, appName, serverId);
};

View File

@ -4,7 +4,7 @@ import type { ApplicationNested } from "../builders";
import {
loadOrCreateConfig,
loadOrCreateConfigRemote,
writeTraefikConfig,
writeAppTraefikConfig,
writeTraefikConfigRemote,
} from "./application";
import type {
@ -62,11 +62,10 @@ export const createSecurityMiddleware = async (
addMiddleware(appConfig, middlewareName);
if (serverId) {
await writeTraefikConfigRemote(config, "middlewares", serverId);
await writeTraefikConfigRemote(appConfig, appName, serverId);
} else {
writeTraefikConfig(appConfig, appName);
writeMiddleware(config);
}
await writeAppTraefikConfig(appConfig, appName, serverId);
};
export const removeSecurityMiddleware = async (
@ -89,6 +88,7 @@ export const removeSecurityMiddleware = async (
appConfig = loadOrCreateConfig(appName);
}
const middlewareName = `auth-${appName}`;
let removedLastUser = false;
if (config.http?.middlewares) {
const currentMiddleware = config.http.middlewares[middlewareName];
@ -106,11 +106,7 @@ export const removeSecurityMiddleware = async (
delete config.http.middlewares[middlewareName];
}
deleteMiddleware(appConfig, middlewareName);
if (serverId) {
await writeTraefikConfigRemote(appConfig, appName, serverId);
} else {
writeTraefikConfig(appConfig, appName);
}
removedLastUser = true;
}
}
}
@ -120,6 +116,9 @@ export const removeSecurityMiddleware = async (
} else {
writeMiddleware(config);
}
if (removedLastUser) {
await writeAppTraefikConfig(appConfig, appName, serverId);
}
};
const isBasicAuthMiddleware = (

View File

@ -40,7 +40,7 @@ export const readValidDirectory = (
directory: string,
serverId?: string | null,
) => {
if (!/^[\w/. :[\]-]{1,500}$/.test(directory)) {
if (!/^[\w/. :[\]+@~(),=%-]{1,500}$/.test(directory)) {
return false;
}

22
scripts/assign-worktree-port.sh Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
PAYLOAD=$(cat)
WORKTREE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_response.worktreePath // empty')
if [ -z "$WORKTREE_PATH" ]; then
exit 0
fi
ENV_FILE="$WORKTREE_PATH/apps/dokploy/.env"
if [ ! -f "$ENV_FILE" ]; then
exit 0
fi
FREE_PORT=$(node "$CLAUDE_PROJECT_DIR/scripts/find-free-port.mjs")
sed -i.bak "s/^PORT=.*/PORT=$FREE_PORT/" "$ENV_FILE"
sed -i.bak -E "s#^(BETTER_AUTH_URL=https?://[^:/]+):[0-9]+#\1:$FREE_PORT#" "$ENV_FILE"
rm -f "$ENV_FILE.bak"
echo "Worktree $WORKTREE_PATH -> dokploy dev PORT=$FREE_PORT"

View File

@ -0,0 +1,26 @@
import { createServer } from "node:net";
function isFree(port) {
return new Promise((resolve) => {
const server = createServer();
server.once("error", () => resolve(false));
server.listen(port, "0.0.0.0", () => {
server.close(() => resolve(true));
});
});
}
async function findFreePort(start) {
let port = start;
while (!(await isFree(port))) {
port++;
}
return port;
}
const start = Number.parseInt(
process.argv[2] || process.env.PORT || "3000",
10,
);
const port = await findFreePort(start);
process.stdout.write(String(port));

View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -euo pipefail
PAYLOAD=$(cat)
WORKTREE_PATH=$(echo "$PAYLOAD" | jq -r '.tool_response.worktreePath // empty')
if [ -z "$WORKTREE_PATH" ]; then
exit 0
fi
# Turbopack refuses to resolve through anything outside its detected
# workspace root, so a symlinked node_modules (whole dir or per-entry)
# doesn't work for apps/dokploy. A real `pnpm install` is required, but
# since pnpm's global content-addressable store is already warm, this
# only links locally — no network fetch, a few seconds.
cd "$WORKTREE_PATH"
pnpm install --prefer-offline

55
scripts/spawn-agent-worktree.sh Executable file
View File

@ -0,0 +1,55 @@
#!/usr/bin/env bash
set -euo pipefail
NAME="${1:?usage: spawn-agent-worktree.sh <name>}"
# --show-toplevel would return the CURRENT worktree's own root if this is
# run from inside one (e.g. another agent's worktree) instead of the main
# checkout. --git-common-dir always points at the shared .git regardless of
# which worktree you're standing in.
REPO_ROOT="$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")"
WORKTREE_PATH="$REPO_ROOT/.claude/worktrees/$NAME"
BRANCH="worktree-$NAME"
if [ -e "$WORKTREE_PATH" ]; then
echo "Worktree already exists: $WORKTREE_PATH" >&2
exit 1
fi
git -C "$REPO_ROOT" fetch origin canary --quiet
git -C "$REPO_ROOT" worktree add -b "$BRANCH" "$WORKTREE_PATH" origin/canary >&2
# .worktreeinclude lists gitignored files (.env, .env.local) that a plain
# `git worktree add` won't check out on its own - copy them in.
while IFS= read -r pattern; do
[ -z "$pattern" ] && continue
find "$REPO_ROOT" \( -path "$REPO_ROOT/.claude/worktrees" -o -path "$REPO_ROOT/node_modules" \) -prune -o -name "$pattern" -print 2>/dev/null
done < "$REPO_ROOT/.worktreeinclude" | while read -r src; do
rel="${src#"$REPO_ROOT"/}"
dest="$WORKTREE_PATH/$rel"
mkdir -p "$(dirname "$dest")"
cp "$src" "$dest"
done
cd "$WORKTREE_PATH"
pnpm install --prefer-offline >&2
FREE_PORT=$(node "$REPO_ROOT/scripts/find-free-port.mjs")
sed -i.bak "s/^PORT=.*/PORT=$FREE_PORT/" apps/dokploy/.env
sed -i.bak -E "s#^(BETTER_AUTH_URL=https?://[^:/]+):[0-9]+#\1:$FREE_PORT#" apps/dokploy/.env
rm -f apps/dokploy/.env.bak
pnpm --filter=dokploy run dev > "$WORKTREE_PATH/dev-server.log" 2>&1 &
echo $! > "$WORKTREE_PATH/dev-server.pid"
BASE_URL="http://localhost:$FREE_PORT"
for _ in $(seq 1 30); do
CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 "$BASE_URL/" || true)
if [ "$CODE" != "000" ]; then
break
fi
sleep 1
done
echo "export WORKTREE_PATH=$WORKTREE_PATH"
echo "export DOKPLOY_BASE_URL=$BASE_URL"
echo "export PORT=$FREE_PORT"