Merge branch 'Dokploy:canary' into fix/chaining-canary

This commit is contained in:
Philipp Gasser 2026-08-07 13:00:57 +02:00 committed by GitHub
commit fe0a84dde8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 335 additions and 27 deletions

View File

@ -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");
});

View File

@ -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");
});
});
});

View File

@ -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 && (
<FormField
control={form.control}
name="isStaticSpa"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-x-2 p-2">
<Checkbox
id="checkboxIsStaticSpaNixpacks"
value={String(field.value)}
checked={field.value}
onCheckedChange={field.onChange}
/>
<FormLabel htmlFor="checkboxIsStaticSpaNixpacks">
Single Page Application (SPA)
</FormLabel>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{buildType === BuildType.static && (
<FormField
control={form.control}

View File

@ -72,8 +72,68 @@ export function parseLogs(logString: string): LogLine[] {
.filter((log) => log !== null);
}
const LEVEL_NAME_TO_TYPE: Record<string, LogType> = {
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;
}

View File

@ -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];
}}
/>
}

View File

@ -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];
}}
/>
}

View File

@ -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 = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
{`Read: ${formatMb(currentData.block.value.readMb)} / Write: ${formatMb(currentData.block.value.writeMb)}`}
</span>
<DockerBlockChart accumulativeData={accumulativeData.block} />
</div>
@ -318,7 +319,7 @@ export const ContainerFreeMonitoring = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
{`In: ${formatMb(currentData.network.value.inputMb)} / Out: ${formatMb(currentData.network.value.outputMb)}`}
</span>
<DockerNetworkChart accumulativeData={accumulativeData.network} />
</div>

View File

@ -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

View File

@ -32,7 +32,9 @@ export const TerminalModal = ({
serverId,
asButton = false,
}: Props) => {
const [terminalKey, setTerminalKey] = useState<string>(getTerminalKey());
const [terminalKey, setTerminalKey] = useState<string>(() =>
getTerminalKey(),
);
const [isOpen, setIsOpen] = useState(false);
const isLocalServer = serverId === "local";

View File

@ -0,0 +1,41 @@
const IO_UNIT_FACTORS: Record<string, number> = {
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`;
};

View File

@ -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") {

View File

@ -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

View File

@ -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}`);
}
}
}
}

View File

@ -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};