Merge pull request #5072 from Dokploy/feat/docker-health-dashboard

feat: add Docker Health diagnostics tab
This commit is contained in:
Mauricio Siu
2026-08-13 15:07:01 -06:00
committed by GitHub
8 changed files with 951 additions and 2 deletions
@@ -0,0 +1,24 @@
import { getSubnetCapacity } from "@dokploy/server/services/server-health";
import { describe, expect, test } from "vitest";
describe("getSubnetCapacity", () => {
test("returns null for missing/invalid input", () => {
expect(getSubnetCapacity(undefined)).toBeNull();
expect(getSubnetCapacity("")).toBeNull();
expect(getSubnetCapacity("10.0.0.0")).toBeNull();
expect(getSubnetCapacity("not-a-subnet")).toBeNull();
expect(getSubnetCapacity("10.0.0.0/33")).toBeNull();
expect(getSubnetCapacity("10.0.0.0/-1")).toBeNull();
});
test("excludes network and broadcast addresses", () => {
expect(getSubnetCapacity("10.0.1.0/24")).toBe(254);
expect(getSubnetCapacity("10.0.0.0/16")).toBe(65534);
expect(getSubnetCapacity("10.99.99.0/30")).toBe(2);
});
test("returns 0 for subnets too small to hold a host", () => {
expect(getSubnetCapacity("10.0.0.0/31")).toBe(0);
expect(getSubnetCapacity("10.0.0.0/32")).toBe(0);
});
});
@@ -0,0 +1,454 @@
import {
AlertTriangle,
Cpu,
Download,
HardDrive,
Loader2,
Network,
RefreshCw,
Server as ServerIcon,
} from "lucide-react";
import { useState } from "react";
import { CodeEditor } from "@/components/shared/code-editor";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
interface Props {
serverId?: string;
}
const bytesToGb = (bytes: number) => (bytes / 1024 ** 3).toFixed(1);
const nanoCpusToCores = (nanoCpus: number) => (nanoCpus / 1e9).toFixed(2);
const pct = (used: number, total: number) =>
Math.min(100, Math.round((used / (total || 1)) * 100));
const SINCE_HOURS_OPTIONS = [
{ label: "Last hour", value: 1 },
{ label: "Last 6 hours", value: 6 },
{ label: "Last 24 hours", value: 24 },
{ label: "Last 7 days", value: 168 },
];
export const ShowHealth = ({ serverId }: Props) => {
const [sinceHours, setSinceHours] = useState(24);
const {
data: health,
isFetching,
refetch,
isFetched,
} = api.docker.getServerHealth.useQuery(
{ serverId, sinceHours },
{ refetchOnMount: false, refetchOnWindowFocus: false },
);
const memUsedPct = health
? pct(health.resources.memUsedBytes, health.resources.memTotalBytes)
: 0;
const diskUsedPct = health
? pct(health.disk.usedBytes, health.disk.totalBytes)
: 0;
const inotifyUsedPct = health
? pct(health.inotify.currentInstances, health.inotify.maxInstances)
: 0;
const daemonLogsWindowText = health?.daemonLogsWindow
? `Logs from ${new Date(health.daemonLogsWindow.fromEpoch * 1000).toLocaleString()} to ${new Date(health.daemonLogsWindow.toEpoch * 1000).toLocaleString()}`
: null;
const daemonErrorsText = [
daemonLogsWindowText,
health && health.daemonErrors.length > 0
? health.daemonErrors.join("\n")
: "(no daemon errors matched in this window)",
]
.filter(Boolean)
.join("\n\n");
const buildHealthReport = () => {
if (!health) return "";
const lines: string[] = [];
lines.push("# Dokploy server health report");
lines.push(`Generated: ${new Date(health.checkedAt).toLocaleString()}`);
lines.push(
`Window: ${SINCE_HOURS_OPTIONS.find((o) => o.value === sinceHours)?.label ?? `${sinceHours}h`}`,
);
lines.push("");
lines.push("## Containers & services");
lines.push(`Containers: ${health.containers.containerCount}`);
lines.push(`Swarm services: ${health.containers.serviceCount}`);
lines.push("");
lines.push("## Host resources");
lines.push(
`Memory: ${bytesToGb(health.resources.memUsedBytes)} / ${bytesToGb(health.resources.memTotalBytes)} GB`,
);
lines.push(`CPU cores: ${health.resources.cpuCount}`);
lines.push("");
lines.push("## Disk (/)");
lines.push(
`${bytesToGb(health.disk.usedBytes)} / ${bytesToGb(health.disk.totalBytes)} GB`,
);
lines.push("");
lines.push("## Inotify");
lines.push(
`max_user_instances: ${health.inotify.currentInstances} / ${health.inotify.maxInstances}`,
);
lines.push(`max_user_watches: ${health.inotify.maxWatches}`);
lines.push(`max_queued_events: ${health.inotify.maxQueuedEvents}`);
lines.push(
`Persisted in sysctl: ${health.inotify.persisted ? "yes" : "no"}`,
);
lines.push("");
lines.push("## Docker networks");
lines.push(`Total networks: ${health.dockerNetworks.count}`);
if (health.dockerNetworks.addressPools) {
lines.push(
`default-address-pools: ${JSON.stringify(health.dockerNetworks.addressPools)}`,
);
}
if (health.dockerNetworks.usageError) {
lines.push(
`Could not read network IP usage: ${health.dockerNetworks.usageError}`,
);
} else if (health.dockerNetworks.usage.length > 0) {
lines.push("");
lines.push(
"| Network | Driver | Subnet | IPs in use | Capacity | Usage |",
);
lines.push("|---|---|---|---|---|---|");
for (const n of health.dockerNetworks.usage) {
lines.push(
`| ${n.name} | ${n.driver} | ${n.subnet ?? "auto"} | ${n.containersInUse} | ${n.capacity ?? "—"} | ${n.percentUsed ?? "—"}% |`,
);
}
}
lines.push("");
if (health.reservation) {
lines.push("## Memory/CPU reservation");
lines.push(
`Reserved memory: ${bytesToGb(health.reservation.memoryReservedBytes)} GB`,
);
lines.push(
`Reserved CPU: ${nanoCpusToCores(health.reservation.cpuReservedNanoCpus)} cores`,
);
lines.push(`Across ${health.reservation.appCount} Application services`);
if (health.reservation.unsupportedComposeCount > 0) {
lines.push(
`${health.reservation.unsupportedComposeCount} Compose service(s) not included (not tracked per-service)`,
);
}
lines.push("");
}
lines.push("## Docker daemon errors (raw)");
lines.push("```");
lines.push(daemonErrorsText);
lines.push("```");
return lines.join("\n");
};
const handleDownload = () => {
const report = buildHealthReport();
if (!report) return;
const blob = new Blob([report], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `dokploy-health-report-${new Date().toISOString().replace(/[:.]/g, "-")}.md`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="flex flex-col gap-4 w-full">
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h3 className="text-lg font-medium">Server diagnostics</h3>
<p className="text-sm text-muted-foreground max-w-xl">
Runs a read-only check over SSH (inotify limits, disk, Docker
network pool, daemon errors). Runs automatically when you open
this tab — click Re-check to refresh.
</p>
</div>
<div className="flex items-center gap-2">
<Select
value={String(sinceHours)}
onValueChange={(v) => setSinceHours(Number(v))}
>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SINCE_HOURS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => refetch()} disabled={isFetching}>
{isFetching ? (
<Loader2 className="size-4 animate-spin mr-2" />
) : (
<RefreshCw className="size-4 mr-2" />
)}
{isFetched ? "Re-check" : "Check server health"}
</Button>
{health && !health.error && (
<Button variant="outline" onClick={handleDownload}>
<Download className="size-4 mr-2" />
Download report
</Button>
)}
</div>
</div>
{isFetching && !health && (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground border border-dashed rounded-xl">
<Loader2 className="size-8 animate-spin" />
<span>Checking server health…</span>
</div>
)}
{health?.error && (
<div className="text-sm text-destructive border border-destructive/30 bg-destructive/10 rounded-lg p-3">
Couldn't read server health: {health.error}
</div>
)}
{health && !health.error && (
<div
className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 transition-opacity ${isFetching ? "opacity-50" : ""}`}
>
<Card className="p-4">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<ServerIcon className="size-4 text-muted-foreground" />
Containers &amp; services
</div>
<div className="text-2xl font-semibold">
{health.containers.containerCount}
</div>
<div className="text-xs text-muted-foreground">
containers · {health.containers.serviceCount} swarm services
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<Cpu className="size-4 text-muted-foreground" />
Host resources
</div>
<div className="text-sm">
Memory: {bytesToGb(health.resources.memUsedBytes)} /{" "}
{bytesToGb(health.resources.memTotalBytes)} GB
</div>
<Progress value={memUsedPct} className="h-2 mt-1" />
<div className="text-xs text-muted-foreground mt-2">
{health.resources.cpuCount} CPU cores
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<HardDrive className="size-4 text-muted-foreground" />
Disk (/)
</div>
<div className="text-sm">
{bytesToGb(health.disk.usedBytes)} /{" "}
{bytesToGb(health.disk.totalBytes)} GB
</div>
<Progress value={diskUsedPct} className="h-2 mt-1" />
</Card>
<Card className="p-4">
<div className="flex items-center justify-between gap-2 text-sm font-medium mb-2">
<span className="flex items-center gap-2">
<AlertTriangle className="size-4 text-muted-foreground" />
Inotify
</span>
<Badge
variant={health.inotify.persisted ? "default" : "secondary"}
>
{health.inotify.persisted ? "persisted" : "runtime only"}
</Badge>
</div>
<div className="text-sm">
max_user_instances: {health.inotify.currentInstances} /{" "}
{health.inotify.maxInstances.toLocaleString()} (
{inotifyUsedPct}%)
</div>
<Progress value={inotifyUsedPct} className="h-2 mt-1" />
<div className="text-xs text-muted-foreground mt-2">
max_user_watches: {health.inotify.maxWatches.toLocaleString()}{" "}
· max_queued_events:{" "}
{health.inotify.maxQueuedEvents.toLocaleString()}
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<Network className="size-4 text-muted-foreground" />
Docker networks
</div>
<div className="text-2xl font-semibold">
{health.dockerNetworks.count}
</div>
<div className="text-xs text-muted-foreground">
The default Docker address pool is exhausted around ~30
networks unless <code>default-address-pools</code> is
configured in <code>/etc/docker/daemon.json</code>.
{health.dockerNetworks.addressPools
? " Custom pool detected on this server."
: ""}
</div>
</Card>
{health.reservation && (
<Card className="p-4">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<Cpu className="size-4 text-muted-foreground" />
Memory/CPU reservation
</div>
<div className="text-sm">
{bytesToGb(health.reservation.memoryReservedBytes)} GB
reserved ·{" "}
{nanoCpusToCores(health.reservation.cpuReservedNanoCpus)}{" "}
CPUs reserved
</div>
<div className="text-xs text-muted-foreground mt-2">
Across {health.reservation.appCount} Application services.
{health.reservation.unsupportedComposeCount > 0
? ` ${health.reservation.unsupportedComposeCount} Compose service(s) on this server aren't included — reservations aren't tracked per-service for Compose.`
: ""}
</div>
</Card>
)}
</div>
)}
{health &&
!health.error &&
(health.dockerNetworks.usage.length > 0 ||
health.dockerNetworks.usageError) && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium flex items-center gap-2">
<Network className="size-4 text-muted-foreground" />
Network IP usage
</h4>
<p className="text-xs text-muted-foreground">
IPs assigned to containers vs. the subnet's usable capacity,
per network — including reserved networks like{" "}
<code>dokploy-network</code>. A network stuck near 100% blocks
new containers from starting even when the host has plenty of
other resources free.
</p>
{health.dockerNetworks.usageError ? (
<div className="text-sm text-destructive border border-destructive/30 bg-destructive/10 rounded-lg p-3">
Couldn't read network IP usage:{" "}
{health.dockerNetworks.usageError}
</div>
) : (
<div className="rounded-lg border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Network</TableHead>
<TableHead>Driver</TableHead>
<TableHead>Subnet</TableHead>
<TableHead>IPs in use</TableHead>
<TableHead>Usage</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{health.dockerNetworks.usage.map((n) => (
<TableRow key={n.name}>
<TableCell className="font-medium">
{n.name}
</TableCell>
<TableCell className="text-muted-foreground">
{n.driver}
</TableCell>
<TableCell className="text-muted-foreground">
{n.subnet ?? "Auto"}
</TableCell>
<TableCell className="text-muted-foreground">
{n.capacity !== null
? `${n.containersInUse} / ${n.capacity}`
: n.containersInUse}
</TableCell>
<TableCell>
{n.percentUsed !== null ? (
<Badge
variant={
n.percentUsed >= 90
? "red"
: n.percentUsed >= 70
? "yellow"
: "green"
}
>
{n.percentUsed}%
</Badge>
) : (
<span className="text-muted-foreground text-xs">
—
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
{health && !health.error && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium">
Docker daemon errors (
{SINCE_HOURS_OPTIONS.find(
(o) => o.value === sinceHours,
)?.label.toLowerCase()}
)
</h4>
<CodeEditor
value={daemonErrorsText}
language="shell"
disabled
lineWrapping
wrapperClassName="h-64 rounded-lg border"
/>
</div>
)}
</div>
</Card>
</div>
);
};
@@ -0,0 +1,28 @@
import { Stethoscope } from "lucide-react";
import { useState } from "react";
import { ShowHealth } from "@/components/dashboard/docker/health/show-health";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
interface Props {
serverId: string;
}
export const ShowHealthModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Stethoscope className="h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-5xl max-h-[85vh] overflow-y-auto">
<div className="flex gap-4 py-4 w-full">
<ShowHealth serverId={serverId} />
</div>
</DialogContent>
</Dialog>
);
};
@@ -35,6 +35,7 @@ import { TerminalModal } from "../web-server/terminal-modal";
import { ShowServerActions } from "./actions/show-server-actions";
import { HandleServers } from "./handle-servers";
import { SetupServer } from "./setup-server";
import { ShowHealthModal } from "./show-health-modal";
import { ShowMonitoringModal } from "./show-monitoring-modal";
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
@@ -324,6 +325,24 @@ export const ShowServers = () => {
</Tooltip>
)}
{permissions?.docker.read &&
permissions?.server.read &&
server.sshKeyId &&
!isBuildServer && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ShowHealthModal
serverId={server.serverId}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Health</p>
</TooltipContent>
</Tooltip>
)}
<div className="flex-1" />
{permissions?.server.delete && (
+14 -1
View File
@@ -6,6 +6,7 @@ import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowDiskUsage } from "@/components/dashboard/docker/disk-usage/show-disk-usage";
import { ShowDockerEvents } from "@/components/dashboard/docker/events/show-docker-events";
import { ShowHealth } from "@/components/dashboard/docker/health/show-health";
import { ShowImages } from "@/components/dashboard/docker/images/show-images";
import { ShowContainers } from "@/components/dashboard/docker/show/show-containers";
import { ShowVolumes } from "@/components/dashboard/docker/volumes/show-volumes";
@@ -18,15 +19,21 @@ import { ServerFilter } from "@/components/shared/server-filter";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
const DEFAULT_TAB = "containers";
const DEFAULT_SWARM_TAB = "overview";
const Dashboard = () => {
const router = useRouter();
const { data: permissions } = api.user.getPermissions.useQuery();
// Host-level diagnostics, so this needs server.read on top of docker.read.
const canSeeHealth = !!permissions?.docker.read && !!permissions?.server.read;
const activeTab =
const queryTab =
typeof router.query.tab === "string" ? router.query.tab : DEFAULT_TAB;
const activeTab =
queryTab === "health" && !canSeeHealth ? DEFAULT_TAB : queryTab;
const activeSwarmTab =
typeof router.query.subtab === "string"
@@ -70,6 +77,7 @@ const Dashboard = () => {
<TabsTrigger value="networks">Networks</TabsTrigger>
<TabsTrigger value="events">Events</TabsTrigger>
<TabsTrigger value="disk-usage">Disk Usage</TabsTrigger>
{canSeeHealth && <TabsTrigger value="health">Health</TabsTrigger>}
</TabsList>
<TabsContent value="containers">
<ShowContainers serverId={serverId} />
@@ -111,6 +119,11 @@ const Dashboard = () => {
<TabsContent value="disk-usage">
<ShowDiskUsage serverId={serverId} />
</TabsContent>
{canSeeHealth && (
<TabsContent value="health">
<ShowHealth serverId={serverId} />
</TabsContent>
)}
</Tabs>
)}
</ServerFilter>
+26 -1
View File
@@ -11,6 +11,7 @@ import {
getContainersByAppLabel,
getContainersByAppNameMatch,
getDockerEvents,
getServerHealth as getServerHealthData,
getServiceContainersByAppName,
getStackContainersByAppName,
listContainerFiles,
@@ -18,11 +19,12 @@ import {
uploadFileToContainer,
writeContainerFile,
} from "@dokploy/server";
import { checkPermission } from "@dokploy/server/services/permission";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { uploadFileToContainerSchema } from "@/utils/schema";
import { createTRPCRouter, withPermission } from "../trpc";
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
export const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
@@ -52,6 +54,29 @@ export const dockerRouter = createTRPCRouter({
return await getContainers(input.serverId);
}),
// Host-level diagnostics, so this requires docker.read AND server.read.
getServerHealth: protectedProcedure
.input(
z.object({
serverId: z.string().optional(),
sinceHours: z.number().int().min(1).max(168).optional(),
}),
)
.query(async ({ input, ctx }) => {
await checkPermission(ctx, { docker: ["read"], server: ["read"] });
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session?.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
return await getServerHealthData(
ctx.session.activeOrganizationId,
input.serverId,
input.sinceHours,
);
}),
restartContainer: withPermission("docker", "read")
.input(
z.object({
+1
View File
@@ -50,6 +50,7 @@ export * from "./services/rollbacks";
export * from "./services/schedule";
export * from "./services/security";
export * from "./services/server";
export * from "./services/server-health";
export * from "./services/settings";
export * from "./services/ssh-key";
export * from "./services/user";
@@ -0,0 +1,385 @@
import { db } from "@dokploy/server/db";
import {
applications,
compose,
environments,
projects,
} from "@dokploy/server/db/schema";
import {
execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { and, eq, isNull } from "drizzle-orm";
import { IS_CLOUD } from "../constants";
import { getRemoteDocker } from "../utils/servers/remote-docker";
export interface NetworkIpUsage {
name: string;
driver: string;
subnet: string | null;
containersInUse: number;
capacity: number | null;
percentUsed: number | null;
}
export interface ServerHealthResult {
checkedAt: string;
containers: {
containerCount: number;
serviceCount: number;
};
resources: {
memTotalBytes: number;
memUsedBytes: number;
cpuCount: number;
};
inotify: {
maxWatches: number;
maxInstances: number;
maxQueuedEvents: number;
currentInstances: number;
persisted: boolean;
};
disk: {
totalBytes: number;
usedBytes: number;
};
dockerNetworks: {
count: number;
addressPools: unknown | null;
/** Per-network IP utilization, including reserved networks like dokploy-network */
usage: NetworkIpUsage[];
/** Set when `usage` couldn't be read, so the UI can tell that apart from "no networks" */
usageError?: string;
};
daemonErrors: string[];
/** The window actually passed to `journalctl --since`, computed on the target machine's clock */
daemonLogsWindow: { fromEpoch: number; toEpoch: number } | null;
reservation: {
memoryReservedBytes: number;
cpuReservedNanoCpus: number;
appCount: number;
unsupportedComposeCount: number;
} | null;
error?: string;
}
interface RawHealthOutput {
containerCount?: number | string;
serviceCount?: number | string;
memTotalBytes?: number | string;
memUsedBytes?: number | string;
cpuCount?: number | string;
inotifyMaxWatches?: number | string;
inotifyMaxInstances?: number | string;
inotifyMaxQueuedEvents?: number | string;
inotifyCurrentInstances?: number | string;
inotifyPersistedCount?: number | string;
diskTotalBytes?: number | string;
diskUsedBytes?: number | string;
networkCount?: number | string;
daemonConfigBase64?: string;
daemonErrorsBase64?: string;
daemonLogsFromEpoch?: number | string;
daemonLogsToEpoch?: number | string;
}
const toInt = (value: unknown): number => {
const n = Number.parseInt(String(value ?? "0"), 10);
return Number.isFinite(n) ? n : 0;
};
const b64Decode = (value?: string): string => {
if (!value) return "";
try {
return Buffer.from(value, "base64").toString("utf-8");
} catch {
return "";
}
};
// Single read-only script, one SSH/exec round-trip; each check falls back to 0/empty on its own.
const buildHealthScript = (sinceHours: number) => `
containerCount=$(docker ps -a -q 2>/dev/null | wc -l | tr -d ' ')
serviceCount=$(docker service ls --format '{{.Name}}' 2>/dev/null | wc -l | tr -d ' ')
memTotal=$(free -b 2>/dev/null | awk '/^Mem:/{print $2}'); [ -z "$memTotal" ] && memTotal=0
memUsed=$(free -b 2>/dev/null | awk '/^Mem:/{print $3}'); [ -z "$memUsed" ] && memUsed=0
cpuCount=$(nproc 2>/dev/null); [ -z "$cpuCount" ] && cpuCount=0
inotifyMaxWatches=$(cat /proc/sys/fs/inotify/max_user_watches 2>/dev/null); [ -z "$inotifyMaxWatches" ] && inotifyMaxWatches=0
inotifyMaxInstances=$(cat /proc/sys/fs/inotify/max_user_instances 2>/dev/null); [ -z "$inotifyMaxInstances" ] && inotifyMaxInstances=0
inotifyMaxQueued=$(cat /proc/sys/fs/inotify/max_queued_events 2>/dev/null); [ -z "$inotifyMaxQueued" ] && inotifyMaxQueued=0
inotifyCurrentInstances=$(find /proc/*/fd -lname 'anon_inode:inotify*' 2>/dev/null | wc -l | tr -d ' ')
inotifyPersistedCount=$(grep -rl inotify /etc/sysctl.conf /etc/sysctl.d/ 2>/dev/null | wc -l | tr -d ' ')
diskTotal=$(df -B1 / 2>/dev/null | awk 'NR==2{print $2}'); [ -z "$diskTotal" ] && diskTotal=0
diskUsed=$(df -B1 / 2>/dev/null | awk 'NR==2{print $3}'); [ -z "$diskUsed" ] && diskUsed=0
networkCount=$(docker network ls -q 2>/dev/null | wc -l | tr -d ' ')
daemonConfigB64=$(cat /etc/docker/daemon.json 2>/dev/null | base64 2>/dev/null | tr -d '\\n')
daemonLogsToEpoch=$(date +%s 2>/dev/null); [ -z "$daemonLogsToEpoch" ] && daemonLogsToEpoch=0
daemonLogsFromEpoch=$((daemonLogsToEpoch - ${sinceHours} * 3600))
daemonErrorsB64=$(journalctl -u docker --no-pager --since "${sinceHours} hours ago" 2>/dev/null | grep -iE "inotify|too many open|cannot allocate|oom|conntrack|no space|pids.max|fork:|resource temporarily unavailable|could not find an available ip|no available ip|task allocation failure|address already in use" | tail -n 50 | base64 2>/dev/null | tr -d '\\n')
printf '{"containerCount":%s,"serviceCount":%s,"memTotalBytes":%s,"memUsedBytes":%s,"cpuCount":%s,"inotifyMaxWatches":%s,"inotifyMaxInstances":%s,"inotifyMaxQueuedEvents":%s,"inotifyCurrentInstances":%s,"inotifyPersistedCount":%s,"diskTotalBytes":%s,"diskUsedBytes":%s,"networkCount":%s,"daemonConfigBase64":"%s","daemonErrorsBase64":"%s","daemonLogsFromEpoch":%s,"daemonLogsToEpoch":%s}' "$containerCount" "$serviceCount" "$memTotal" "$memUsed" "$cpuCount" "$inotifyMaxWatches" "$inotifyMaxInstances" "$inotifyMaxQueued" "$inotifyCurrentInstances" "$inotifyPersistedCount" "$diskTotal" "$diskUsed" "$networkCount" "$daemonConfigB64" "$daemonErrorsB64" "$daemonLogsFromEpoch" "$daemonLogsToEpoch"
`;
const emptyResult = (error: unknown): ServerHealthResult => ({
checkedAt: new Date().toISOString(),
containers: { containerCount: 0, serviceCount: 0 },
resources: { memTotalBytes: 0, memUsedBytes: 0, cpuCount: 0 },
inotify: {
maxWatches: 0,
maxInstances: 0,
maxQueuedEvents: 0,
currentInstances: 0,
persisted: false,
},
disk: { totalBytes: 0, usedBytes: 0 },
dockerNetworks: { count: 0, addressPools: null, usage: [] },
daemonErrors: [],
daemonLogsWindow: null,
reservation: null,
error:
error instanceof Error ? error.message : "Could not read server health",
});
/** Usable host addresses in a subnet, excluding network + broadcast. Null if the subnet is missing/invalid. */
export const getSubnetCapacity = (
subnet: string | undefined,
): number | null => {
if (!subnet) return null;
const prefix = Number.parseInt(subnet.split("/")[1] ?? "", 10);
if (Number.isNaN(prefix) || prefix < 0 || prefix > 32) return null;
const hostBits = 32 - prefix;
if (hostBits <= 1) return 0;
return 2 ** hostBits - 2;
};
type DockerNetworkSummary = {
Name: string;
Driver: string;
IPAM?: { Config?: Array<{ Subnet?: string }> | null };
};
type InspectableNetwork = {
inspect: (opts?: { verbose?: boolean }) => Promise<{
Containers?: Record<string, unknown>;
}>;
};
// Includes reserved networks like dokploy-network, which are excluded from the `network` table/UI.
const getNetworksIpUsage = async (
serverId: string | undefined,
): Promise<NetworkIpUsage[]> => {
const docker = await getRemoteDocker(serverId);
const dockerNetworks =
(await docker.listNetworks()) as DockerNetworkSummary[];
const relevant = dockerNetworks.filter(
(n) => n.Driver === "bridge" || n.Driver === "overlay",
);
const rows = await Promise.all(
relevant.map(async (n): Promise<NetworkIpUsage> => {
// IPv4 entry specifically: dual-stack networks list an IPv6 config too, and it may come first.
const subnet = n.IPAM?.Config?.find(
(c) => c.Subnet && !c.Subnet.includes(":"),
)?.Subnet;
const capacity = getSubnetCapacity(subnet);
let containersInUse = 0;
try {
// Cast the object (not the detached method) so `net.inspect(...)` stays a bound method call.
const net = docker.getNetwork(n.Name) as unknown as InspectableNetwork;
// verbose: true for full swarm-wide container list on overlay networks
const info = await net.inspect(
n.Driver === "overlay" ? { verbose: true } : undefined,
);
containersInUse = Object.keys(info.Containers ?? {}).length;
} catch {
// Network may have disappeared between list and inspect; report 0
}
return {
name: n.Name,
driver: n.Driver,
subnet: subnet ?? null,
containersInUse,
capacity,
percentUsed:
capacity && capacity > 0
? Math.round((containersInUse / capacity) * 100)
: null,
};
}),
);
return rows.sort((a, b) => (b.percentUsed ?? -1) - (a.percentUsed ?? -1));
};
// Sums Application memoryReservation/cpuReservation; Compose has no such columns.
export const getReservationSummary = async (
orgId: string,
serverId?: string,
) => {
const appRows = await db
.select({
memoryReservation: applications.memoryReservation,
cpuReservation: applications.cpuReservation,
})
.from(applications)
.innerJoin(
environments,
eq(applications.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(
and(
eq(projects.organizationId, orgId),
serverId
? eq(applications.serverId, serverId)
: isNull(applications.serverId),
),
);
let memoryReservedBytes = 0;
let cpuReservedNanoCpus = 0;
for (const row of appRows) {
if (row.memoryReservation) {
memoryReservedBytes += toInt(row.memoryReservation);
}
if (row.cpuReservation) {
cpuReservedNanoCpus += toInt(row.cpuReservation);
}
}
const composeRows = await db
.select({ composeId: compose.composeId })
.from(compose)
.innerJoin(
environments,
eq(compose.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(
and(
eq(projects.organizationId, orgId),
serverId ? eq(compose.serverId, serverId) : isNull(compose.serverId),
),
);
return {
memoryReservedBytes,
cpuReservedNanoCpus,
appCount: appRows.length,
unsupportedComposeCount: composeRows.length,
};
};
export const getServerHealth = async (
orgId: string,
serverId?: string,
sinceHours = 24,
): Promise<ServerHealthResult> => {
if (IS_CLOUD && !serverId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Server is required",
});
}
const hours = Math.max(
1,
Math.min(168, Math.floor(Number(sinceHours) || 24)),
);
const script = buildHealthScript(hours);
let stdout = "";
try {
const result = serverId
? await execAsyncRemote(serverId, script)
: await execAsync(script);
stdout = result.stdout;
} catch (error) {
return emptyResult(error);
}
let parsed: RawHealthOutput;
try {
parsed = JSON.parse(stdout.trim());
} catch {
return emptyResult(new Error("Could not parse server health output"));
}
const daemonErrors = b64Decode(parsed.daemonErrorsBase64)
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
let addressPools: unknown = null;
const daemonConfigText = b64Decode(parsed.daemonConfigBase64);
if (daemonConfigText) {
try {
addressPools =
JSON.parse(daemonConfigText)?.["default-address-pools"] ?? null;
} catch {
addressPools = null;
}
}
// Not part of the shell script above: needs structured per-network data from the Docker API.
const [reservation, networkUsageResult] = await Promise.all([
getReservationSummary(orgId, serverId).catch(() => null),
getNetworksIpUsage(serverId).then(
(usage) => ({ usage, error: undefined }),
(error: unknown) => ({
usage: [] as NetworkIpUsage[],
error:
error instanceof Error
? error.message
: "Failed to read network usage",
}),
),
]);
return {
checkedAt: new Date().toISOString(),
containers: {
containerCount: toInt(parsed.containerCount),
serviceCount: toInt(parsed.serviceCount),
},
resources: {
memTotalBytes: toInt(parsed.memTotalBytes),
memUsedBytes: toInt(parsed.memUsedBytes),
cpuCount: toInt(parsed.cpuCount),
},
inotify: {
maxWatches: toInt(parsed.inotifyMaxWatches),
maxInstances: toInt(parsed.inotifyMaxInstances),
maxQueuedEvents: toInt(parsed.inotifyMaxQueuedEvents),
currentInstances: toInt(parsed.inotifyCurrentInstances),
persisted: toInt(parsed.inotifyPersistedCount) > 0,
},
disk: {
totalBytes: toInt(parsed.diskTotalBytes),
usedBytes: toInt(parsed.diskUsedBytes),
},
dockerNetworks: {
count: toInt(parsed.networkCount),
addressPools,
usage: networkUsageResult.usage,
usageError: networkUsageResult.error,
},
daemonErrors,
daemonLogsWindow:
toInt(parsed.daemonLogsToEpoch) > 0
? {
fromEpoch: toInt(parsed.daemonLogsFromEpoch),
toEpoch: toInt(parsed.daemonLogsToEpoch),
}
: null,
reservation,
};
};