diff --git a/apps/dokploy/__test__/logs/log-classification.test.ts b/apps/dokploy/__test__/logs/log-classification.test.ts new file mode 100644 index 000000000..edb6b9e51 --- /dev/null +++ b/apps/dokploy/__test__/logs/log-classification.test.ts @@ -0,0 +1,42 @@ +import { getLogType } from "@/components/dashboard/docker/logs/utils"; +import { expect, test } from "vitest"; + +test("classifies real failures as error", () => { + expect(getLogType("Error: connection refused at db:5432").type).toBe("error"); + expect(getLogType("[ERROR] something went wrong").type).toBe("error"); + expect(getLogType("Deployment failed").type).toBe("error"); + expect( + getLogType( + 'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326ms", failed: true, skipped: false, error: exit code 1', + ).type, + ).toBe("error"); +}); + +test("does not classify explicit non-error key/values as error (#4538)", () => { + // ofelia job-completion summary for a successful run + expect( + getLogType( + 'NOTICE [Job "sync-1m" (e1305c5b54b1)] Finished in "326.16795ms", failed: false, skipped: false, error: none', + ).type, + ).not.toBe("error"); + + expect(getLogType("request done, error: null").type).not.toBe("error"); + expect(getLogType("checks passed, failures=0").type).not.toBe("error"); + expect(getLogType('shutdown clean, error=""').type).not.toBe("error"); + expect(getLogType('job done failed=false error=""').type).not.toBe("error"); +}); + +test("keeps errors whose value merely starts with a no-error word", () => { + expect(getLogType("connect failed: no route to host").type).toBe("error"); + expect(getLogType("connection failed: no such host").type).toBe("error"); + expect( + getLogType("error: none of the configured nodes are available").type, + ).toBe("error"); + expect(getLogType("error: nil pointer dereference").type).toBe("error"); + expect(getLogType("request failed: 0 bytes received").type).toBe("error"); +}); + +test("keeps statusCode-based classification", () => { + expect(getLogType('{"statusCode": "500"}').type).toBe("error"); + expect(getLogType('{"statusCode": "204"}').type).toBe("success"); +}); diff --git a/apps/dokploy/__test__/utils/log-type.test.ts b/apps/dokploy/__test__/utils/log-type.test.ts new file mode 100644 index 000000000..b2edfd58c --- /dev/null +++ b/apps/dokploy/__test__/utils/log-type.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "vitest"; +import { getLogType } from "@/components/dashboard/docker/logs/utils"; + +describe("getLogType", () => { + describe("explicit level declared by structured loggers", () => { + test("JSON string levels (pino, winston, zap)", () => { + expect(getLogType('{"level":"trace","msg":"x"}').type).toBe("debug"); + expect(getLogType('{"level":"debug","msg":"x"}').type).toBe("debug"); + expect(getLogType('{"level":"info","msg":"x"}').type).toBe("info"); + expect(getLogType('{"level":"warn","msg":"x"}').type).toBe("warning"); + expect(getLogType('{"level":"warning","msg":"x"}').type).toBe("warning"); + expect(getLogType('{"level":"error","msg":"x"}').type).toBe("error"); + expect(getLogType('{"level":"fatal","msg":"x"}').type).toBe("error"); + }); + + test("JSON numeric levels (pino, bunyan)", () => { + expect(getLogType('{"level":20,"msg":"x"}').type).toBe("debug"); + expect(getLogType('{"level":30,"msg":"x"}').type).toBe("info"); + expect(getLogType('{"level":40,"msg":"x"}').type).toBe("warning"); + expect(getLogType('{"level":50,"msg":"x"}').type).toBe("error"); + expect(getLogType('{"level":60,"msg":"x"}').type).toBe("error"); + }); + + test("syslog/GELF numeric levels", () => { + expect(getLogType('{"level":3,"msg":"x"}').type).toBe("error"); + expect(getLogType('{"level":4,"msg":"x"}').type).toBe("warning"); + expect(getLogType('{"level":6,"msg":"x"}').type).toBe("info"); + expect(getLogType('{"level":7,"msg":"x"}').type).toBe("debug"); + }); + + test("GCP-style severity and ECS log.level", () => { + expect(getLogType('{"severity":"ERROR","message":"x"}').type).toBe( + "error", + ); + expect(getLogType('{"severity":"WARNING","message":"x"}').type).toBe( + "warning", + ); + expect(getLogType('{"log.level":"error","message":"x"}').type).toBe( + "error", + ); + }); + + test("logfmt levels", () => { + expect( + getLogType('ts=2026-06-12T10:00:00Z level=error msg="boom"').type, + ).toBe("error"); + expect( + getLogType('ts=2026-06-12T10:00:00Z level=info msg="ok"').type, + ).toBe("info"); + expect(getLogType("level=warn msg=careful").type).toBe("warning"); + }); + + test("declared level wins over keywords in the message (#4589, #1996)", () => { + // "version"/"GET" in this pino line would otherwise match the debug keywords + const pinoError = + '{"level":"error","version":"72b4450","method":"GET","path":"/api/campaigns","err":{"type":"ForbiddenError","stack":"ForbiddenError: at requireRole (/app/src/plugins/campaign.plugin.ts:166:15)"},"msg":"Forbidden"}'; + expect(getLogType(pinoError).type).toBe("error"); + + // info line containing error-like keywords (#4589) + expect( + getLogType( + '{"level":"info","msg":"Failed to open mempool file. Continuing anyway."}', + ).type, + ).toBe("info"); + + // successful job summary with "failed: false, error: none" (#4538) + expect( + getLogType( + 'level=info msg="Finished job, failed: false, skipped: false, error: none"', + ).type, + ).toBe("info"); + }); + + test("declared level wins over statusCode", () => { + expect(getLogType('{"level":"info","statusCode":500}').type).toBe("info"); + }); + + test("unknown level names fall back to keyword detection", () => { + expect( + getLogType('{"level":"verbose","msg":"connection failed"}').type, + ).toBe("error"); + }); + + test("env-var-like text is not treated as logfmt level", () => { + expect(getLogType("LOG_LEVEL=error NODE_ENV=production").type).not.toBe( + "error", + ); + }); + }); + + describe("fallback detection for unstructured logs (unchanged)", () => { + test("statusCode classification", () => { + expect(getLogType('{"statusCode":500,"msg":"x"}').type).toBe("error"); + expect(getLogType('{"statusCode":404,"msg":"x"}').type).toBe("warning"); + expect(getLogType('{"statusCode":200,"msg":"x"}').type).toBe("success"); + }); + + test("keyword classification", () => { + expect(getLogType("error: something broke").type).toBe("error"); + expect(getLogType("warning: disk almost full").type).toBe("warning"); + expect(getLogType("Server listening on port 8080").type).toBe("success"); + }); + + test("defaults to info", () => { + expect(getLogType("hello world").type).toBe("info"); + }); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/build/show.tsx b/apps/dokploy/components/dashboard/application/build/show.tsx index 32aee23d3..ac2531f94 100644 --- a/apps/dokploy/components/dashboard/application/build/show.tsx +++ b/apps/dokploy/components/dashboard/application/build/show.tsx @@ -88,6 +88,7 @@ const mySchema = z.discriminatedUnion("buildType", [ z.object({ buildType: z.literal(BuildType.nixpacks), publishDirectory: z.string().optional(), + isStaticSpa: z.boolean().default(false), }), z.object({ buildType: z.literal(BuildType.railpack), @@ -138,6 +139,7 @@ const resetData = (data: ApplicationData): AddTemplate => { return { buildType: BuildType.nixpacks, publishDirectory: data.publishDirectory || undefined, + isStaticSpa: data.isStaticSpa ?? false, }; case BuildType.paketo_buildpacks: return { @@ -179,6 +181,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => { const buildType = form.watch("buildType"); const railpackVersion = form.watch("railpackVersion"); + const publishDirectory = form.watch("publishDirectory"); const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false); useEffect(() => { @@ -224,7 +227,10 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => { ? data.herokuVersion : null, isStaticSpa: - data.buildType === BuildType.static ? data.isStaticSpa : null, + data.buildType === BuildType.static || + data.buildType === BuildType.nixpacks + ? data.isStaticSpa + : null, railpackVersion: data.buildType === BuildType.railpack ? data.railpackVersion || "0.15.4" @@ -419,6 +425,30 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => { )} /> )} + {buildType === BuildType.nixpacks && publishDirectory && ( + ( + + +
+ + + Single Page Application (SPA) + +
+
+ +
+ )} + /> + )} {buildType === BuildType.static && ( log !== null); } +const LEVEL_NAME_TO_TYPE: Record = { + trace: "debug", + debug: "debug", + info: "info", + information: "info", + notice: "info", + warn: "warning", + warning: "warning", + error: "error", + err: "error", + fatal: "error", + critical: "error", + panic: "error", + alert: "error", + emergency: "error", +}; + +const numericLevelToType = (level: number): LogType => { + // pino/bunyan scale: 10=trace 20=debug 30=info 40=warn 50=error 60=fatal + if (level >= 50) return "error"; + if (level >= 40) return "warning"; + if (level >= 30) return "info"; + if (level >= 10) return "debug"; + // syslog/GELF scale: 0=emergency ... 7=debug + if (level <= 3) return "error"; + if (level === 4) return "warning"; + if (level <= 6) return "info"; + return "debug"; +}; + +// Extract the log level explicitly declared by structured loggers +// (pino, bunyan, winston, zap, slog, logfmt, GCP severity) +const getExplicitLevelType = (message: string): LogType | null => { + // JSON string levels: {"level":"error"} / {"severity":"ERROR"} / {"log.level":"warn"} + const jsonStringMatch = message.match( + /"(?:level|severity|log\.level|loglevel)"\s*:\s*"([a-z]+)"/i, + ); + if (jsonStringMatch?.[1]) { + return LEVEL_NAME_TO_TYPE[jsonStringMatch[1].toLowerCase()] ?? null; + } + + // JSON numeric levels: {"level":50} + const jsonNumericMatch = message.match(/"level"\s*:\s*(\d{1,2})\b/); + if (jsonNumericMatch?.[1]) { + return numericLevelToType(Number(jsonNumericMatch[1])); + } + + // logfmt: level=error + const logfmtMatch = message.match(/(?:^|\s)(?:level|severity)=([a-z]+)\b/i); + if (logfmtMatch?.[1]) { + return LEVEL_NAME_TO_TYPE[logfmtMatch[1].toLowerCase()] ?? null; + } + + return null; +}; + // Detect log type based on message content export const getLogType = (message: string): LogStyle => { + // A level explicitly declared by the logger wins over any inference + const explicitType = getExplicitLevelType(message); + if (explicitType) return LOG_STYLES[explicitType]; + // Detect HTTP statusCode const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/); @@ -97,17 +157,27 @@ export const getLogType = (message: string): LogStyle => { return LOG_STYLES.info; } + // Key/value pairs that explicitly report a non-error (e.g. "error: none", + // "failed: false") must not trigger the error keyword patterns below + const nonErrorColonPairs = + /\b(?:error|err|errors|failed|failure|failures)\s*:\s*(?:none|null|nil|false|0|no|-|""|'')(?=[,;.)\]]|$)/gi; + const nonErrorLogfmtPairs = + /\b(?:error|err|errors|failed|failure|failures)\s*=\s*(?:none|null|nil|false|0|no|-|""|'')(?=[\s,;.)\]]|$)/gi; + const errorScope = lowerMessage + .replace(nonErrorColonPairs, "") + .replace(nonErrorLogfmtPairs, ""); + if ( - /(?:^|\s)(?:error|err):?\s/i.test(lowerMessage) || - /\b(?:exception|failed|failure)\b/i.test(lowerMessage) || - /(?:stack\s?trace):\s*$/i.test(lowerMessage) || - /^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(lowerMessage) || - /\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(lowerMessage) || - /Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(lowerMessage) || - /\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(lowerMessage) || - /\[(?:error|err|fatal)\]/i.test(lowerMessage) || - /\b(?:crash|critical|fatal)\b/i.test(lowerMessage) || - /\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(lowerMessage) + /(?:^|\s)(?:error|err):?\s/i.test(errorScope) || + /\b(?:exception|failed|failure)\b/i.test(errorScope) || + /(?:stack\s?trace):\s*$/i.test(errorScope) || + /^\s*at\s+[\w.]+\s*\(?.+:\d+:\d+\)?/.test(errorScope) || + /\b(?:uncaught|unhandled)\s+(?:exception|error)\b/i.test(errorScope) || + /Error:\s.*(?:in|at)\s+.*:\d+(?::\d+)?/.test(errorScope) || + /\b(?:errno|code):\s*(?:\d+|[A-Z_]+)\b/i.test(errorScope) || + /\[(?:error|err|fatal)\]/i.test(errorScope) || + /\b(?:crash|critical|fatal)\b/i.test(errorScope) || + /\b(?:fail(?:ed|ure)?|broken|dead)\b/i.test(errorScope) ) { return LOG_STYLES.error; } diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx index 91db49869..01989753a 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/docker-block-chart.tsx @@ -1,3 +1,4 @@ +import { formatMb, toMb } from "@dokploy/server/monitoring/units"; import { format } from "date-fns"; import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"; import { @@ -29,8 +30,8 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => { const transformedData = accumulativeData.map((item, index) => ({ time: item.time, name: `Point ${index + 1}`, - readMb: item.value.readMb, - writeMb: item.value.writeMb, + readMb: toMb(item.value.readMb), + writeMb: toMb(item.value.writeMb), })); return ( @@ -77,7 +78,7 @@ export const DockerBlockChart = ({ accumulativeData }: Props) => { }} formatter={(value, name) => { const label = name === "readMb" ? "Read" : "Write"; - return [`${value} MB`, label]; + return [formatMb(Number(value)), label]; }} /> } diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/docker-network-chart.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/docker-network-chart.tsx index 56e512d52..92f23e75b 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/docker-network-chart.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/docker-network-chart.tsx @@ -1,3 +1,4 @@ +import { formatMb, toMb } from "@dokploy/server/monitoring/units"; import { format } from "date-fns"; import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"; import { @@ -29,8 +30,8 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => { const transformedData = accumulativeData.map((item, index) => ({ time: item.time, name: `Point ${index + 1}`, - inMB: item.value.inputMb, - outMB: item.value.outputMb, + inMB: toMb(item.value.inputMb), + outMB: toMb(item.value.outputMb), })); return ( @@ -73,7 +74,7 @@ export const DockerNetworkChart = ({ accumulativeData }: Props) => { }} formatter={(value, name) => { const label = name === "inMB" ? "In" : "Out"; - return [`${value} MB`, label]; + return [formatMb(Number(value)), label]; }} /> } diff --git a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx index 782b19134..54b7bace4 100644 --- a/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx +++ b/apps/dokploy/components/dashboard/monitoring/free/container/show-free-container-monitoring.tsx @@ -1,3 +1,4 @@ +import { formatMb } from "@dokploy/server/monitoring/units"; import { useEffect, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; @@ -305,7 +306,7 @@ export const ContainerFreeMonitoring = ({
- {`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `} + {`Read: ${formatMb(currentData.block.value.readMb)} / Write: ${formatMb(currentData.block.value.writeMb)}`}
@@ -318,7 +319,7 @@ export const ContainerFreeMonitoring = ({
- {`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `} + {`In: ${formatMb(currentData.network.value.inputMb)} / Out: ${formatMb(currentData.network.value.outputMb)}`}
diff --git a/apps/dokploy/components/dashboard/requests/show-requests.tsx b/apps/dokploy/components/dashboard/requests/show-requests.tsx index 83b5d2bf5..b1a3cfca2 100644 --- a/apps/dokploy/components/dashboard/requests/show-requests.tsx +++ b/apps/dokploy/components/dashboard/requests/show-requests.tsx @@ -63,7 +63,7 @@ export const ShowRequests = () => { const [dateRange, setDateRange] = useState<{ from: Date | undefined; to: Date | undefined; - }>(getDefaultDateRange()); + }>(() => getDefaultDateRange()); // Check if logs exist to determine if traefik has been reloaded // Only fetch when active to minimize network calls diff --git a/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx b/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx index 2647e1dc0..e90ba51f8 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/terminal-modal.tsx @@ -32,7 +32,9 @@ export const TerminalModal = ({ serverId, asButton = false, }: Props) => { - const [terminalKey, setTerminalKey] = useState(getTerminalKey()); + const [terminalKey, setTerminalKey] = useState(() => + getTerminalKey(), + ); const [isOpen, setIsOpen] = useState(false); const isLocalServer = serverId === "local"; diff --git a/packages/server/src/monitoring/units.ts b/packages/server/src/monitoring/units.ts new file mode 100644 index 000000000..880e8cdb8 --- /dev/null +++ b/packages/server/src/monitoring/units.ts @@ -0,0 +1,41 @@ +const IO_UNIT_FACTORS: Record = { + B: 1 / 1e6, + kB: 1e-3, + KB: 1e-3, + MB: 1, + GB: 1e3, + TB: 1e6, + KiB: 1024 / 1e6, + MiB: 1024 ** 2 / 1e6, + GiB: 1024 ** 3 / 1e6, + TiB: 1024 ** 4 / 1e6, +}; + +export const toMb = (value: number | string | undefined): number => { + if (typeof value === "number") { + return value; + } + if (!value) { + return 0; + } + const parsed = Number.parseFloat(value); + if (Number.isNaN(parsed)) { + return 0; + } + const unit = value.replace(/[0-9.\s]/g, ""); + return parsed * (IO_UNIT_FACTORS[unit] ?? 1); +}; + +export const parseIoToMb = (raw: string | undefined): number => + Number(toMb(raw).toFixed(6)); + +export const formatMb = (value: number | string | undefined): string => { + const mb = toMb(value); + if (mb >= 1000) { + return `${(mb / 1000).toFixed(2)} GB`; + } + if (mb < 0.01 && mb > 0) { + return `${(mb * 1000).toFixed(2)} kB`; + } + return `${mb.toFixed(2)} MB`; +}; diff --git a/packages/server/src/monitoring/utils.ts b/packages/server/src/monitoring/utils.ts index 2c42b99a6..c2eefc8ae 100644 --- a/packages/server/src/monitoring/utils.ts +++ b/packages/server/src/monitoring/utils.ts @@ -1,6 +1,7 @@ import { promises } from "node:fs"; import { OSUtils } from "node-os-utils"; import { paths } from "../constants"; +import { parseIoToMb } from "./units"; export interface Container { BlockIO: string; @@ -28,13 +29,13 @@ export const recordAdvancedStats = async ( }); await updateStatsFile(appName, "block", { - readMb: stats.BlockIO.split(" ")[0], - writeMb: stats.BlockIO.split(" ")[2], + readMb: parseIoToMb(stats.BlockIO.split(" ")[0]), + writeMb: parseIoToMb(stats.BlockIO.split(" ")[2]), }); await updateStatsFile(appName, "network", { - inputMb: stats.NetIO.split(" ")[0], - outputMb: stats.NetIO.split(" ")[2], + inputMb: parseIoToMb(stats.NetIO.split(" ")[0]), + outputMb: parseIoToMb(stats.NetIO.split(" ")[2]), }); if (appName === "dokploy") { diff --git a/packages/server/src/setup/server-setup.ts b/packages/server/src/setup/server-setup.ts index 7181880a4..b972d7d85 100644 --- a/packages/server/src/setup/server-setup.ts +++ b/packages/server/src/setup/server-setup.ts @@ -482,7 +482,7 @@ const installUtilities = () => ` case "$OS_TYPE" in arch) - $SUDO_CMD pacman -Sy --noconfirm --needed curl wget git git-lfs jq openssl >/dev/null || true + $SUDO_CMD pacman -Sy --noconfirm --needed unzip curl wget git git-lfs jq openssl >/dev/null || true ;; alpine) $SUDO_CMD sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 26acf2986..2204a71f9 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -220,6 +220,17 @@ export const addDomainToCompose = async ( labels.unshift("traefik.swarm.network=dokploy-network"); } } + } else { + const isolatedNetwork = compose.suffix || compose.appName; + if (compose.composeType === "docker-compose") { + if (!labels.includes(`traefik.docker.network=${isolatedNetwork}`)) { + labels.unshift(`traefik.docker.network=${isolatedNetwork}`); + } + } else { + if (!labels.includes(`traefik.swarm.network=${isolatedNetwork}`)) { + labels.unshift(`traefik.swarm.network=${isolatedNetwork}`); + } + } } } diff --git a/packages/server/src/utils/schedules/utils.ts b/packages/server/src/utils/schedules/utils.ts index e81f9a582..65d01afed 100644 --- a/packages/server/src/utils/schedules/utils.ts +++ b/packages/server/src/utils/schedules/utils.ts @@ -174,7 +174,7 @@ export const runCommand = async (scheduleId: string) => { const { SCHEDULES_PATH } = paths(true); const fullPath = path.join(SCHEDULES_PATH, appName || ""); const command = ` - set -e + set -euo pipefail echo "Running script" >> ${deployment.logPath}; bash -c ${fullPath}/script.sh 2>&1 | tee -a ${deployment.logPath} || { echo "❌ Command failed" >> ${deployment.logPath};