Merge branch 'Dokploy:canary' into canary

This commit is contained in:
Shuvo
2026-08-31 22:22:09 +06:00
committed by GitHub
59 changed files with 19556 additions and 117 deletions
-13
View File
@@ -1,13 +0,0 @@
{
"mcpServers": {
"dokploy": {
"type": "http",
"url": "${DOKPLOY_BASE_URL}/api/mcp",
"headers": { "Authorization": "Bearer ${DOKPLOY_TOKEN}" }
},
"playwright": {
"command": "npx",
"args": ["-y", "@playwright/mcp@latest", "--headless", "--isolated"]
}
}
}
+6
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.
@@ -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");
});
});
@@ -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", () => {
@@ -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({
@@ -16,6 +16,30 @@ describe("shouldDeploy", () => {
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
});
it("should apply multiple negated watch paths as one pattern set", () => {
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
expect(shouldDeploy(watchPaths, ["CHANGELOG.md"])).toBe(false);
expect(shouldDeploy(watchPaths, ["VERSION"])).toBe(false);
expect(shouldDeploy(watchPaths, ["tests/unit/example.test.ts"])).toBe(
false,
);
});
it("should deploy when a changed file remains after exclusions", () => {
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
expect(shouldDeploy(watchPaths, ["VERSION", "src/index.ts"])).toBe(true);
});
it("should combine positive and negated watch paths", () => {
const watchPaths = ["src/**", "!src/**/*.test.ts"];
expect(shouldDeploy(watchPaths, ["src/index.ts"])).toBe(true);
expect(shouldDeploy(watchPaths, ["src/index.test.ts"])).toBe(false);
expect(shouldDeploy(watchPaths, ["docs/readme.md"])).toBe(false);
});
it("should not throw when modified files contain non-string values", () => {
expect(() =>
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
@@ -126,6 +126,13 @@ describe("member is denied org-level enterprise resources (CVE: bypass via stati
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
});
it("member is denied server.terminal", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).rejects.toThrow();
});
it("member is denied registry.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
@@ -105,6 +105,7 @@ describe("enterprise resources for static roles", () => {
const perms = await resolvePermissions(ctx);
expect(perms.server.read).toBe(false);
expect(perms.server.terminal).toBe(false);
expect(perms.registry.read).toBe(false);
expect(perms.certificate.read).toBe(false);
expect(perms.destination.read).toBe(false);
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockMemberData = (role: string) => ({
id: "member-1",
role,
userId: "user-1",
organizationId: "org-1",
accessedProjects: [] as string[],
accessedServices: [] as string[],
accessedEnvironments: [] as string[],
accessedServers: [] as string[],
canCreateProjects: false,
canDeleteProjects: false,
canCreateServices: false,
canDeleteServices: false,
canCreateEnvironments: false,
canDeleteEnvironments: false,
canAccessToTraefikFiles: false,
canAccessToDocker: false,
canAccessToAPI: false,
canAccessToSSHKeys: false,
canAccessToGitProviders: false,
user: { id: "user-1", email: "test@test.com" },
});
let memberToReturn = mockMemberData("deployer");
let rolesToReturn: { permission: string }[] = [];
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
member: {
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
findMany: vi.fn(() => Promise.resolve([])),
},
organizationRole: {
findFirst: vi.fn(),
findMany: vi.fn(() => Promise.resolve(rolesToReturn)),
},
},
},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: vi.fn(() => Promise.resolve(true)),
}));
const { checkPermission, resolvePermissions } = await import(
"@dokploy/server/services/permission"
);
const ctx = {
user: { id: "user-1" },
session: { activeOrganizationId: "org-1" },
};
const withPermissions = (permissions: Record<string, string[]>) => {
rolesToReturn = [{ permission: JSON.stringify(permissions) }];
};
beforeEach(() => {
vi.clearAllMocks();
memberToReturn = mockMemberData("deployer");
rolesToReturn = [];
});
describe("server.terminal on custom roles", () => {
it("a role with server.read alone cannot open a terminal", async () => {
withPermissions({ server: ["read"] });
await expect(
checkPermission(ctx, { server: ["read"] }),
).resolves.toBeUndefined();
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).rejects.toThrow();
const perms = await resolvePermissions(ctx);
expect(perms.server.read).toBe(true);
expect(perms.server.terminal).toBe(false);
});
it("a role with server.terminal can open a terminal", async () => {
withPermissions({ server: ["read", "terminal"] });
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).resolves.toBeUndefined();
const perms = await resolvePermissions(ctx);
expect(perms.server.terminal).toBe(true);
});
it("owner and admin keep terminal access", async () => {
for (const role of ["owner", "admin"]) {
memberToReturn = mockMemberData(role);
await expect(
checkPermission(ctx, { server: ["terminal"] }),
).resolves.toBeUndefined();
const perms = await resolvePermissions(ctx);
expect(perms.server.terminal).toBe(true);
}
});
});
@@ -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,
);
},
);
@@ -0,0 +1,116 @@
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`)",
service: `${appName}-service`,
},
},
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`)",
service: "with-domain-app-service",
},
},
services: {},
},
},
"with-domain-app",
"server-id",
);
expect(mocks.execAsyncRemote).toHaveBeenCalledOnce();
const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? [];
expect(command).toMatch(/^echo /);
});
});
+26 -1
View File
@@ -95,10 +95,35 @@ describe("canAccessTerminalOverWss", () => {
});
it("gates a remote server terminal on server access", async () => {
mockHasPermission.mockResolvedValue(true);
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
// the remote path must never fall through to the owner/admin local branch
expect(mockFindMember).not.toHaveBeenCalled();
});
it("denies a remote server terminal without the server.terminal permission", async () => {
// Reaching a server (to deploy on it) must not imply a root shell on it.
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
mockHasPermission.mockResolvedValue(false);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(false);
expect(mockHasPermission).toHaveBeenCalledWith(
{ user: { id: USER.id }, session: { activeOrganizationId: "org-1" } },
{ server: ["terminal"] },
);
});
it("allows a remote server terminal with the server.terminal permission", async () => {
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
mockHasPermission.mockResolvedValue(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
});
it("does not check permissions for a server the caller cannot access", async () => {
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
mockHasPermission.mockResolvedValue(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
expect(mockHasPermission).not.toHaveBeenCalled();
});
});
@@ -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);
});
});
@@ -191,8 +191,7 @@ export const ShowDomains = ({ id, type }: Props) => {
try {
const result = await validateDomain({
domain: host,
serverIp:
application?.server?.ipAddress?.toString() || ip?.toString() || "",
serverId: application?.serverId ?? undefined,
});
setValidationStates((prev) => ({
@@ -102,7 +102,7 @@ export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-row justify-between items-center gap-2">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-2">
<Label>Select a container to view logs</Label>
<div className="flex flex-row gap-2 items-center">
<span className="text-sm text-muted-foreground">
@@ -55,6 +55,7 @@ export const ShowDockerLogsStack = ({
},
{
enabled: !!appName && option === "swarm",
refetchInterval: 5000,
},
);
@@ -67,6 +68,7 @@ export const ShowDockerLogsStack = ({
},
{
enabled: !!appName && option === "native",
refetchInterval: 5000,
},
);
@@ -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,
@@ -52,14 +53,15 @@ export const ShowDockerLogsCompose = ({
},
{
enabled: !!appName,
refetchInterval: 5000,
},
);
const [containerId, setContainerId] = useState<string | undefined>();
useEffect(() => {
if (data && data?.length > 0) {
setContainerId(data[0]?.containerId);
}
setContainerId((currentContainerId) =>
resolveContainerSelection(currentContainerId, data),
);
}, [data]);
return (
@@ -352,11 +352,11 @@ export const DockerLogsId: React.FC<Props> = ({
/>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
className="h-9"
className="h-9 w-full sm:w-auto"
onClick={handlePauseResume}
title={isPaused ? "Resume logs" : "Pause logs"}
>
@@ -372,7 +372,7 @@ export const DockerLogsId: React.FC<Props> = ({
<Button
variant="outline"
size="sm"
className="h-9"
className="h-9 w-full sm:w-auto"
onClick={handleCopy}
disabled={filteredLogs.length === 0}
title="Copy logs to clipboard"
@@ -389,7 +389,7 @@ export const DockerLogsId: React.FC<Props> = ({
<Button
variant="outline"
size="sm"
className="h-9 sm:w-auto w-full"
className="h-9 w-full sm:w-auto"
onClick={handleDownload}
disabled={filteredLogs.length === 0 || !data?.Name}
title="Download logs as text file"
@@ -418,7 +418,7 @@ export const DockerLogsId: React.FC<Props> = ({
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
className="h-[50vh] sm:h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
>
{filteredLogs.length > 0 ? (
filteredLogs.map((filteredLog: LogLine, index: number) => (
@@ -64,7 +64,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
const tooltip = (color: string, timestamp: string | null) => {
const square = (
<div className={cn("w-2 h-full shrink-0 rounded-[3px]", color)} />
<div className={cn("w-2 min-h-4 h-full flex-shrink-0 rounded-[3px]", color)} />
);
return timestamp ? (
<TooltipProvider delayDuration={0} disableHoverableContent>
@@ -88,7 +88,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
return (
<div
className={cn(
"font-mono text-xs flex flex-row gap-3 py-2 sm:py-0.5 group",
"font-mono text-xs flex flex-col sm:flex-row gap-1 sm:gap-3 py-2 sm:py-0.5 group",
type === "error"
? "bg-red-500/10 hover:bg-red-500/15"
: type === "warning"
@@ -104,7 +104,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
{tooltip(color, rawTimestamp)}
{!noTimestamp && (
<span className="select-none pl-2 text-muted-foreground w-full sm:w-40 shrink-0">
<span className="select-none pl-2 text-muted-foreground w-auto sm:w-40 flex-shrink-0">
{formattedTime}
</span>
)}
@@ -1,5 +1,6 @@
import { formatMb } from "@dokploy/server/monitoring/units";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { api } from "@/utils/api";
@@ -167,6 +168,8 @@ export const ContainerFreeMonitoring = ({
}, [data]);
useEffect(() => {
if (!appName) return;
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/listen-docker-stats-monitoring?appName=${appName}&appType=${appType}`;
const ws = new WebSocket(wsUrl);
@@ -196,7 +199,9 @@ export const ContainerFreeMonitoring = ({
};
ws.onclose = (e) => {
console.log(e.reason);
if (e.reason) {
toast.error(e.reason);
}
};
return () => ws.close();
@@ -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>
@@ -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 />
@@ -48,6 +48,7 @@ export function AddOrganization({ organizationId }: Props) {
},
{
enabled: !!organizationId,
refetchOnWindowFocus: false,
},
);
const { mutateAsync, isPending } = organizationId
@@ -250,29 +250,30 @@ export const ShowServers = () => {
</div>
<TooltipProvider>
{server.sshKeyId && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<TerminalModal
serverId={server.serverId}
asButton={true}
>
<Button
variant="outline"
size="icon"
className="h-9 w-9"
{server.sshKeyId &&
permissions?.server.terminal && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<TerminalModal
serverId={server.serverId}
asButton={true}
>
<Terminal className="h-4 w-4" />
</Button>
</TerminalModal>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Terminal</p>
</TooltipContent>
</Tooltip>
)}
<Button
variant="outline"
size="icon"
className="h-9 w-9"
>
<Terminal className="h-4 w-4" />
</Button>
</TerminalModal>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Terminal</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
@@ -35,7 +35,7 @@ import { ChangeRole } from "./change-role";
export const ShowUsers = () => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data, isPending, refetch } = api.user.all.useQuery();
const { mutateAsync } = api.user.remove.useMutation();
const { mutateAsync, isPending: isRemoving } = api.user.remove.useMutation();
const { data: permissions } = api.user.getPermissions.useQuery();
const { data: hasValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
@@ -237,6 +237,7 @@ export const ShowUsers = () => {
title="Delete User"
description="Are you sure you want to delete this user?"
type="destructive"
disabled={isRemoving}
onClick={async () => {
await mutateAsync({
userId: member.user.id,
@@ -269,6 +270,7 @@ export const ShowUsers = () => {
title="Unlink User"
description="Are you sure you want to unlink this user?"
type="destructive"
disabled={isRemoving}
onClick={async () => {
if (!isCloud) {
const orgCount =
@@ -328,6 +328,10 @@ const ACTION_META: Record<
label: "Delete",
description: "Remove servers from the organization",
},
terminal: {
label: "Terminal",
description: "Open an SSH root shell on remote servers",
},
},
registry: {
read: { label: "Read", description: "View configured Docker registries" },
@@ -569,7 +573,7 @@ const ROLE_PRESETS: {
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
server: ["read", "create", "delete", "terminal"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "delete"],
backup: ["read", "create", "delete", "restore"],
+13 -3
View File
@@ -1,3 +1,4 @@
import { OpenPanelComponent } from "@openpanel/nextjs";
import { useRouter } from "next/router";
import Script from "next/script";
import { useEffect } from "react";
@@ -5,9 +6,10 @@ import { GTM_ID, pushToDataLayer } from "@/lib/analytics";
import { api } from "@/utils/api";
/**
* Loads Google Tag Manager and the HubSpot marketing tag on the cloud
* version only, and translates tracking query params appended by the
* auth/billing flows (?signup=..., ?subscription=new) into dataLayer events.
* Loads Google Tag Manager, the HubSpot marketing tag, and OpenPanel
* product analytics on the cloud version only, and translates tracking
* query params appended by the auth/billing flows (?signup=...,
* ?subscription=new) into dataLayer events.
*/
export const Analytics = () => {
const router = useRouter();
@@ -40,6 +42,14 @@ export const Analytics = () => {
return (
<>
<OpenPanelComponent
apiUrl="https://openpanel.dokploy.com/api"
clientId="bf5a178b-7f28-4461-bf47-d63feff15922"
trackScreenViews={true}
trackOutgoingLinks={true}
trackAttributes={true}
globalProperties={{ site: "app" }}
/>
<Script id="analytics-init" strategy="afterInteractive">
{`
window.hsConversationsSettings = { loadImmediately: false };
@@ -0,0 +1 @@
ALTER TABLE "network" ADD COLUMN "dockerId" text;
@@ -0,0 +1,13 @@
-- Grant the new "server.terminal" permission to existing custom roles that already have
-- "server.read", which is the permission that surfaces the terminal in the UI today.
-- Roles without a "server" entry are deliberately left alone: they never saw the terminal in the
-- UI, so from now on they are denied at the websocket too.
UPDATE "organization_role" AS r
SET "permission" = jsonb_set(
r."permission"::jsonb,
'{server}',
(r."permission"::jsonb->'server') || '["terminal"]'::jsonb
)::text
WHERE jsonb_typeof(r."permission"::jsonb->'server') = 'array'
AND r."permission"::jsonb->'server' @> '["read"]'::jsonb
AND NOT r."permission"::jsonb->'server' @> '["terminal"]'::jsonb;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -1303,6 +1303,20 @@
"when": 1786526512677,
"tag": "0185_needy_kingpin",
"breakpoints": true
},
{
"idx": 186,
"version": "7",
"when": 1787813846067,
"tag": "0186_tearful_dragon_man",
"breakpoints": true
},
{
"idx": 187,
"version": "7",
"when": 1787937580323,
"tag": "0187_grant_terminal_permission_to_read_roles",
"breakpoints": true
}
]
}
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.30.2",
"version": "v0.30.3",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -67,6 +67,7 @@
"@hookform/resolvers": "^5.2.2",
"@octokit/auth-app": "^6.1.3",
"@octokit/webhooks": "^13.9.0",
"@openpanel/nextjs": "^1.5.1",
"@react-email/components": "^1.0.12",
"@stepperize/react": "4.0.1",
"@stripe/stripe-js": "4.8.0",
+32 -1
View File
@@ -1,4 +1,8 @@
import { validateRequest } from "@dokploy/server";
import {
OPENAPI_MAX_JSON_BODY_SIZE,
OPENAPI_MAX_UPLOAD_SIZE,
validateRequest,
} from "@dokploy/server";
import { createOpenApiNextHandler } from "@dokploy/trpc-openapi";
import type { NextApiRequest, NextApiResponse } from "next";
import { appRouter } from "@/server/api/root";
@@ -12,10 +16,31 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return;
}
// getMultipartBody doesn't accept maxBodySize, so we cap it here instead.
const contentLength = Number(req.headers["content-length"]);
const isMultipart = req.headers["content-type"]?.startsWith(
"multipart/form-data",
);
if (isMultipart && !Number.isFinite(contentLength)) {
res.status(411).json({ message: "Content-Length required" });
return;
}
const limit = isMultipart
? OPENAPI_MAX_UPLOAD_SIZE
: OPENAPI_MAX_JSON_BODY_SIZE;
if (Number.isFinite(contentLength) && contentLength > limit) {
res.status(413).json({ message: "Payload too large" });
return;
}
// @ts-ignore
return createOpenApiNextHandler({
router: appRouter,
createContext: createTRPCContext,
maxBodySize: OPENAPI_MAX_JSON_BODY_SIZE,
onError:
process.env.NODE_ENV === "development"
? ({ path, error }: { path: string | undefined; error: Error }) => {
@@ -28,3 +53,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
};
export default handler;
export const config = {
api: {
bodyParser: false,
},
};
+5 -1
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
+15 -3
View File
@@ -7,6 +7,7 @@ import {
findPreviewDeploymentById,
findServerById,
generateTraefikMeDomain,
getServerIpCandidates,
getWebServerSettings,
manageDomain,
removeDomain,
@@ -248,10 +249,21 @@ export const domainRouter = createTRPCRouter({
.input(
z.object({
domain: z.string(),
serverIp: z.string().optional(),
serverId: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
return validateDomain(input.domain, input.serverIp);
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this server",
});
}
}
const expectedIps = await getServerIpCandidates(input.serverId);
return validateDomain(input.domain, expectedIps);
}),
});
@@ -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 }) => {
+7 -2
View File
@@ -62,7 +62,8 @@ export const canAccessDockerOverWss = async (
// Authorizes the host/server SSH terminal opened over a WebSocket. The local
// host terminal is a root shell on the control-plane host, so it is restricted
// to owner/admin. A remote server terminal is gated on server access.
// to owner/admin. A remote server terminal needs server access plus
// server.terminal.
export const canAccessTerminalOverWss = async (
user: WssUser,
session: WssSession,
@@ -75,7 +76,11 @@ export const canAccessTerminalOverWss = async (
userId: user.id,
activeOrganizationId: session.activeOrganizationId,
});
return accessible.has(serverId);
if (!accessible.has(serverId)) return false;
return await hasPermission(buildCtx(user, session.activeOrganizationId), {
server: ["terminal"],
});
}
try {
@@ -182,6 +182,10 @@ export const setupDockerContainerTerminalWebSocketServer = (
ptyProcess.onData((data) => {
ws.send(data);
});
ptyProcess.onExit(({ exitCode }) => {
ws.send(`\nContainer closed with code: ${exitCode}\n`);
ws.close();
});
ws.on("close", () => {
ptyProcess.kill();
});
+55 -1
View File
@@ -8,9 +8,57 @@ import {
recordAdvancedStats,
validateRequest,
} from "@dokploy/server";
import { quote } from "shell-quote";
import { WebSocketServer } from "ws";
import { canAccessDockerOverWss } from "./authorize";
type AppType = "application" | "stack" | "docker-compose";
// Swarm task names are "<service>.<slot>.<taskId>"; the manager's local
// `docker ps` only sees containers scheduled on this host, so a task running
// on another node looks identical to a stopped one. `docker service ps` is
// swarm-aggregated and answers from the manager regardless of which node the
// task landed on, so we use it here to tell the two cases apart.
const findRemoteSwarmNode = async (
appName: string,
appType: AppType,
): Promise<string | null> => {
if (appType === "docker-compose") {
return null;
}
// `docker service ps --format {{.Name}}` only prints "<service>.<slot>",
// never the taskId, so we match on the task ID instead — the last segment
// of the stack task name — rather than trying to reconstruct the full name.
const [serviceName, taskId] =
appType === "stack"
? [appName.split(".").slice(0, -2).join("."), appName.split(".").pop()]
: [appName, null];
if (!serviceName) {
return null;
}
try {
const { stdout } = await execAsync(
`docker service ps ${quote([serviceName])} --filter "desired-state=running" --no-trunc --format '{"ID":"{{.ID}}","Node":"{{.Node}}","CurrentState":"{{.CurrentState}}"}'`,
);
for (const line of stdout.trim().split("\n")) {
if (!line) continue;
const task = JSON.parse(line);
const isMatch = taskId ? task.ID === taskId : true;
if (isMatch && task.CurrentState?.startsWith("Running")) {
return task.Node;
}
}
} catch {
// Not a swarm service (or the swarm CLI isn't available) — fall back to "not running".
}
return null;
};
export const setupDockerStatsMonitoringSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
) => {
@@ -98,7 +146,13 @@ export const setupDockerStatsMonitoringSocketServer = (
const container = containers[0];
if (!container || container?.State !== "running") {
ws.close(4000, "Container not running");
const remoteNode = await findRemoteSwarmNode(appName, appType);
ws.close(
4000,
remoteNode
? `Container running on remote node "${remoteNode}"`.slice(0, 123)
: "Container not running",
);
return;
}
const { stdout, stderr } = await execAsync(
+22
View File
@@ -13,6 +13,28 @@ export const DOKPLOY_DOCKER_PORT = process.env.DOKPLOY_DOCKER_PORT
export const CLEANUP_CRON_JOB = "50 23 * * *";
// Body size limits for the OpenAPI catch-all route (pages/api/[...trpc].ts).
const parseByteSize = (envVar: string, fallback: number): number => {
const raw = process.env[envVar];
if (!raw) return fallback;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) {
console.warn(`Invalid ${envVar}="${raw}", using default ${fallback}`);
return fallback;
}
return parsed;
};
export const OPENAPI_MAX_JSON_BODY_SIZE = parseByteSize(
"OPENAPI_MAX_JSON_BODY_SIZE",
10 * 1024 * 1024, // 10mb
);
export const OPENAPI_MAX_UPLOAD_SIZE = parseByteSize(
"OPENAPI_MAX_UPLOAD_SIZE",
1024 * 1024 * 1024, // 1gb
);
type DockerSocketCandidate = {
label: string;
path: string;
+1
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),
+3 -3
View File
@@ -35,7 +35,7 @@ export const statements = {
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
server: ["read", "create", "delete", "terminal"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
@@ -104,7 +104,7 @@ export const ownerRole = ac.newRole({
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
server: ["read", "create", "delete", "terminal"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
@@ -143,7 +143,7 @@ export const adminRole = ac.newRole({
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
server: ["read", "create", "delete", "terminal"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
+52 -6
View File
@@ -3,7 +3,9 @@ import { promisify } from "node:util";
import { db } from "@dokploy/server/db";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { generateRandomDomain } from "@dokploy/server/templates";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
import { getPublicIpWithFallback } from "@dokploy/server/wss/utils";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
@@ -154,7 +156,7 @@ const resolveDns = promisify(dns.resolve4);
export const validateDomain = async (
domain: string,
expectedIp?: string,
expectedIps?: string[],
): Promise<{
isValid: boolean;
resolvedIp?: string;
@@ -186,13 +188,13 @@ export const validateDomain = async (
};
}
// If we have an expected IP, validate against it
if (expectedIp) {
if (expectedIps && expectedIps.length > 0) {
const isValid = resolvedIps.some((ip) => expectedIps.includes(ip));
return {
isValid: resolvedIps.includes(expectedIp),
isValid,
resolvedIp: resolvedIps.join(", "),
error: !resolvedIps.includes(expectedIp)
? `Domain resolves to ${resolvedIps.join(", ")} but should point to ${expectedIp}`
error: !isValid
? `Domain resolves to ${resolvedIps.join(", ")} but should point to ${expectedIps.join(" or ")}`
: undefined,
};
}
@@ -210,3 +212,47 @@ export const validateDomain = async (
};
}
};
export const getServerIpCandidates = async (
serverId?: string | null,
): Promise<string[]> => {
const candidates = new Set<string>();
if (serverId) {
const server = await findServerById(serverId);
if (server.ipAddress) {
candidates.add(server.ipAddress);
}
const publicIp = await withTimeout(
execAsyncRemote(
serverId,
"curl -s -m 5 https://ifconfig.me || curl -s -m 5 https://icanhazip.com",
),
7000,
);
const detectedIp = publicIp?.stdout?.trim();
if (detectedIp) {
candidates.add(detectedIp);
}
} else {
const settings = await getWebServerSettings();
if (settings?.serverIp) {
candidates.add(settings.serverIp);
}
const publicIp = await withTimeout(getPublicIpWithFallback(), 7000);
if (publicIp) {
candidates.add(publicIp);
}
}
return Array.from(candidates);
};
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
return Promise.race([
promise,
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)),
]).catch(() => null);
};
+13 -3
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,
});
}
};
+80 -8
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",
@@ -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();
+5 -5
View File
@@ -6,7 +6,7 @@ const validateUfw = () => `
if command -v ufw >/dev/null 2>&1; then
isInstalled=true
isActive=$(sudo ufw status | grep -q "Status: active" && echo true || echo false)
defaultIncoming=$(sudo ufw status verbose | grep "Default:" | grep "incoming" | awk '{print $2}')
defaultIncoming=$(sudo ufw status verbose | grep "Default:" | grep "incoming" | awk '{print $2}' | head -n1 | tr -d '\\r')
echo "{\\"installed\\": $isInstalled, \\"active\\": $isActive, \\"defaultIncoming\\": \\"$defaultIncoming\\"}"
else
echo "{\\"installed\\": false, \\"active\\": false, \\"defaultIncoming\\": \\"unknown\\"}"
@@ -27,7 +27,7 @@ const validateSsh = () => `
# Check for key authentication
# SSH key auth is enabled by default unless explicitly disabled
pubkey_line=$(sudo grep -i "^PubkeyAuthentication" "$sshd_config" 2>/dev/null | grep -v "#")
pubkey_line=$(sudo grep -i "^PubkeyAuthentication" "$sshd_config" 2>/dev/null | grep -v "#" | head -n1)
if [ -z "$pubkey_line" ] || echo "$pubkey_line" | grep -q -i "yes"; then
keyAuth=true
else
@@ -36,21 +36,21 @@ const validateSsh = () => `
# Get the exact PermitRootLogin value from config
# This preserves values like "prohibit-password" without normalization
permitRootLogin=$(sudo grep -i "^PermitRootLogin" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}')
permitRootLogin=$(sudo grep -i "^PermitRootLogin" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}' | head -n1 | tr -d '\\r')
if [ -z "$permitRootLogin" ]; then
# Default is prohibit-password in newer versions
permitRootLogin="prohibit-password"
fi
# Get the exact PasswordAuthentication value from config
passwordAuth=$(sudo grep -i "^PasswordAuthentication" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}')
passwordAuth=$(sudo grep -i "^PasswordAuthentication" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}' | head -n1 | tr -d '\\r')
if [ -z "$passwordAuth" ]; then
# Default is yes
passwordAuth="yes"
fi
# Get the exact UsePAM value from config
usePam=$(sudo grep -i "^UsePAM" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}')
usePam=$(sudo grep -i "^UsePAM" "$sshd_config" 2>/dev/null | grep -v "#" | awk '{print $2}' | head -n1 | tr -d '\\r')
if [ -z "$usePam" ]; then
# Default is yes in most distros
usePam="yes"
@@ -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,
},
@@ -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`;
}
@@ -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..." ;
+1 -1
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}"`;
});
};
@@ -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,
@@ -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);
};
@@ -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 = (
@@ -8,5 +8,5 @@ export const shouldDeploy = (
const files = (modifiedFiles ?? []).filter(
(file): file is string => typeof file === "string",
);
return micromatch.some(files, watchPaths);
return micromatch(files, watchPaths).length > 0;
};
+1 -1
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;
}
+90
View File
@@ -175,6 +175,9 @@ importers:
'@octokit/webhooks':
specifier: ^13.9.0
version: 13.9.1
'@openpanel/nextjs':
specifier: ^1.5.1
version: 1.5.1(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@react-email/components':
specifier: ^1.0.12
version: 1.0.12(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -2170,6 +2173,19 @@ packages:
resolution: {integrity: sha512-Nss2b4Jyn4wB3EAqAPJypGuCJFalz/ZujKBQQ5934To7Xw9xjf4hkr/EAByxQY7hp7MKd790bWGz7XYSTsHmaw==}
engines: {node: '>= 18'}
'@openpanel/nextjs@1.5.1':
resolution: {integrity: sha512-60vhKw29weFWmwntx/cSIYU1TcwNzRykeg8P70onr3jzeyMGsEd78d/wgF5Mp/NIxQDN0IT4LneS6hIDIyrTpw==}
peerDependencies:
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@openpanel/sdk@1.3.1':
resolution: {integrity: sha512-mQ5xaBUGXnyRg3qYxwnbVaUYyW7w8u+munrWk/ClnqxEeR2j+FYPiA6noHbHMCFC2aZutYD5OoRzEvw3FBNFxw==}
'@openpanel/web@1.4.1':
resolution: {integrity: sha512-DgUWeocZw+b57YiJjefZPg+6sceR7TdOF8x7Ut75lu3CwcwqGi5jZNPXjikNemGL37aNWXCB4buhcoNan2j7RA==}
'@opentelemetry/api-logs@0.57.2':
resolution: {integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==}
engines: {node: '>=14'}
@@ -3856,6 +3872,12 @@ packages:
cpu: [x64]
os: [win32]
'@rrweb/types@2.0.0-alpha.20':
resolution: {integrity: sha512-RbnDgKxA/odwB1R4gF7eUUj+rdSrq6ROQJsnMw7MIsGzlbSYvJeZN8YY4XqU0G6sKJvXI6bSzk7w/G94jNwzhw==}
'@rrweb/utils@2.1.1':
resolution: {integrity: sha512-x2SgJAD3YJ9eVcLZ5l6OqWMtczdA3VfS5XqPeqpZdQgV9oh6Id5GK2cDN4bimnkqryOcIJdz8cTvV0yNvqQ+nA==}
'@rvf/set-get@7.0.1':
resolution: {integrity: sha512-GkTSn9K1GrTYoTUqlUs36k6nJnzjQaFBTTEIqUYmzBcsGsoJM8xG7EAx2WLHWAA4QzFjcwWUSHQ3vM3Fbw50Tg==}
@@ -4236,6 +4258,9 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/css-font-loading-module@0.0.7':
resolution: {integrity: sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -4604,6 +4629,9 @@ packages:
engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
'@xstate/fsm@1.6.5':
resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==}
'@xterm/addon-attach@0.10.0':
resolution: {integrity: sha512-ES/XO8pC1tPHSkh4j7qzM8ajFt++u8KMvfRc9vKIbjHTDOxjl9IUVo+vcQgLn3FTCM3w2czTvBss8nMWlD83Cg==}
peerDependencies:
@@ -4808,6 +4836,10 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -7076,6 +7108,9 @@ packages:
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
engines: {node: '>= 8'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
@@ -8142,6 +8177,15 @@ packages:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
rrdom@2.1.1:
resolution: {integrity: sha512-VBkTF3bGNqcZjqnbo/gKi9GVQPIxiG0dofw7CQdAZQFQ2juJdt2YKIf3oS9QudL8HTpGh9bz5TUN+3amBn1Geg==}
rrweb-snapshot@2.1.1:
resolution: {integrity: sha512-al4Kx7Am7YlIn/5Yb2EMr7cdmjGiK+cLhdilJEXqkFXgpnys8t/yMJumXy2Ormgirpg3MBnFha0GTgny+iIL2w==}
rrweb@2.0.0-alpha.20:
resolution: {integrity: sha512-CZKDlm+j1VA50Ko3gnMbpvguCAleljsTNXPnVk9aeNP8o6T6kolRbISHyDZpqZ4G+bdDLlQOignPP3jEsXs8Gg==}
run-applescript@7.1.0:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
@@ -10697,6 +10741,21 @@ snapshots:
'@octokit/request-error': 6.1.8
'@octokit/webhooks-methods': 5.1.1
'@openpanel/nextjs@1.5.1(next@16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@openpanel/web': 1.4.1
next: 16.3.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@openpanel/sdk@1.3.1': {}
'@openpanel/web@1.4.1':
dependencies:
'@openpanel/sdk': 1.3.1
'@rrweb/types': 2.0.0-alpha.20
rrweb: 2.0.0-alpha.20
'@opentelemetry/api-logs@0.57.2':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -12688,6 +12747,10 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.59.0':
optional: true
'@rrweb/types@2.0.0-alpha.20': {}
'@rrweb/utils@2.1.1': {}
'@rvf/set-get@7.0.1': {}
'@scarf/scarf@1.4.0': {}
@@ -13367,6 +13430,8 @@ snapshots:
dependencies:
'@types/node': 24.10.13
'@types/css-font-loading-module@0.0.7': {}
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
@@ -13711,6 +13776,8 @@ snapshots:
'@xmldom/xmldom@0.8.11': {}
'@xstate/fsm@1.6.5': {}
'@xterm/addon-attach@0.10.0(@xterm/xterm@5.5.0)':
dependencies:
'@xterm/xterm': 5.5.0
@@ -13895,6 +13962,8 @@ snapshots:
balanced-match@4.0.4: {}
base64-arraybuffer@1.0.2: {}
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.0: {}
@@ -16154,6 +16223,8 @@ snapshots:
minipass: 3.3.6
yallist: 4.0.0
mitt@3.0.1: {}
mkdirp-classic@0.5.3: {}
mkdirp@1.0.4: {}
@@ -17331,6 +17402,25 @@ snapshots:
transitivePeerDependencies:
- supports-color
rrdom@2.1.1:
dependencies:
rrweb-snapshot: 2.1.1
rrweb-snapshot@2.1.1:
dependencies:
postcss: 8.5.23
rrweb@2.0.0-alpha.20:
dependencies:
'@rrweb/types': 2.0.0-alpha.20
'@rrweb/utils': 2.1.1
'@types/css-font-loading-module': 0.0.7
'@xstate/fsm': 1.6.5
base64-arraybuffer: 1.0.2
mitt: 3.0.1
rrdom: 2.1.1
rrweb-snapshot: 2.1.1
run-applescript@7.1.0: {}
run-parallel@1.2.0:
+4 -1
View File
@@ -18,6 +18,9 @@ async function findFreePort(start) {
return port;
}
const start = Number.parseInt(process.argv[2] || process.env.PORT || "3000", 10);
const start = Number.parseInt(
process.argv[2] || process.env.PORT || "3000",
10,
);
const port = await findFreePort(start);
process.stdout.write(String(port));