diff --git a/.env.example b/.env.example index d643c1194..745d45954 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,12 @@ DB_URL='FULL_PATH_TO_YOUR_SQLITE_DB_FILE' TURBO_TELEMETRY_DISABLED=1 +# Comma-separated paths to Docker-compatible Unix sockets. +# Useful for connecting to multiple runtimes (Docker + Podman) simultaneously. +# When unset and no TCP hosts configured, defaults to /var/run/docker.sock. +# Example: DOCKER_SOCKET_PATHS=/var/run/docker.sock,/run/user/1000/podman/podman.sock +# DOCKER_SOCKET_PATHS= + # Enable kubernetes tool # ENABLE_KUBERNETES=true diff --git a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx index 537c85e7c..de7e781a1 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/docker/docker-table.tsx @@ -58,6 +58,18 @@ const createColumns = ( return ; }, }, + { + accessorKey: "host", + header: t("docker.field.host.label"), + size: 140, + Cell({ row }) { + return ( + + {row.original.host} + + ); + }, + }, { accessorKey: "image", header: t("docker.field.containerImage.label"), diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index e5a4b4796..4527482f0 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -138,6 +138,7 @@ const getContainerOrThrowAsync = async (id: string) => { interface DockerContainer { name: string; id: string; + host: string; state: ContainerState; image: string; ports: Port[] | undefined; diff --git a/packages/docker/src/env.ts b/packages/docker/src/env.ts index e858d3e69..003a9bfa6 100644 --- a/packages/docker/src/env.ts +++ b/packages/docker/src/env.ts @@ -4,9 +4,11 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env" export const env = createEnv({ server: { - // Comma separated list of docker hostnames that can be used to connect to query the docker endpoints (localhost:2375,host.docker.internal:2375, ...) + // Comma separated list of docker hostnames that can be used to connect to query the docker endpoints (localhost,host.docker.internal, ...) DOCKER_HOSTNAMES: z.string().optional(), DOCKER_PORTS: z.string().optional(), + // Comma separated paths to Docker-compatible Unix sockets (e.g., /var/run/docker.sock,/run/user/1000/podman/podman.sock) + DOCKER_SOCKET_PATHS: z.string().optional(), ENABLE_DOCKER: createBooleanSchema(true), ENABLE_KUBERNETES: createBooleanSchema(false), }, diff --git a/packages/docker/src/singleton.ts b/packages/docker/src/singleton.ts index 86ce9661e..f24e8eb9c 100644 --- a/packages/docker/src/singleton.ts +++ b/packages/docker/src/singleton.ts @@ -11,31 +11,43 @@ export class DockerSingleton { private static instances: DockerInstance[] | null = null; private createInstances() { + const socketPaths = env.DOCKER_SOCKET_PATHS; const hostVariable = env.DOCKER_HOSTNAMES; const portVariable = env.DOCKER_PORTS; - if (hostVariable === undefined || portVariable === undefined) { + + // Socket instances from DOCKER_SOCKET_PATHS + const socketInstances: DockerInstance[] = socketPaths + ? socketPaths.split(",").map((socketPath) => ({ + host: socketPath, + instance: new Docker({ socketPath }), + })) + : []; + + // TCP instances from existing DOCKER_HOSTNAMES/DOCKER_PORTS + let tcpInstances: DockerInstance[] = []; + if (hostVariable !== undefined && portVariable !== undefined) { + const hostnames = hostVariable.split(","); + const ports = portVariable.split(","); + + if (hostnames.length !== ports.length) { + throw new Error("The number of hosts and ports must match"); + } + + tcpInstances = hostnames.map((host, i) => ({ + host: `${host}:${ports[i]}`, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + instance: new Docker({ host, port: parseInt(ports[i]!, 10) }), + })); + } + + const instances = [...socketInstances, ...tcpInstances]; + + // Default: local socket if nothing else configured + if (instances.length === 0) { return [{ host: "socket", instance: new Docker() }]; } - const hostnames = hostVariable.split(","); - const ports = portVariable.split(","); - if (hostnames.length !== ports.length) { - throw new Error("The number of hosts and ports must match"); - } - - return hostnames.map((host, i) => { - // Check above ensures that ports[i] is not undefined - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const port = ports[i]!; - - return { - host: `${host}:${port}`, - instance: new Docker({ - host, - port: parseInt(port, 10), - }), - }; - }); + return instances; } public static findInstance(host: string): DockerInstance | undefined { diff --git a/packages/docker/src/test/singleton.spec.ts b/packages/docker/src/test/singleton.spec.ts new file mode 100644 index 000000000..53af453af --- /dev/null +++ b/packages/docker/src/test/singleton.spec.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { DockerSingleton } from "../singleton"; + +const mockEnv = vi.hoisted(() => ({ + DOCKER_SOCKET_PATHS: undefined as string | undefined, + DOCKER_HOSTNAMES: undefined as string | undefined, + DOCKER_PORTS: undefined as string | undefined, + ENABLE_DOCKER: true, + ENABLE_KUBERNETES: false, +})); + +vi.mock("dockerode", () => { + return { + default: class MockDocker { + constructor(public opts?: Record) {} + }, + }; +}); + +vi.mock("../env", () => ({ + env: mockEnv, +})); + +describe("DockerSingleton", () => { + beforeEach(() => { + // Reset singleton cache between tests + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + (DockerSingleton as any).instances = null; + + // Reset env to defaults + mockEnv.DOCKER_SOCKET_PATHS = undefined; + mockEnv.DOCKER_HOSTNAMES = undefined; + mockEnv.DOCKER_PORTS = undefined; + }); + + test("should return default socket when no env vars set", () => { + const instances = DockerSingleton.getInstances(); + + expect(instances).toHaveLength(1); + expect(instances[0]?.host).toBe("socket"); + }); + + test("should return TCP instances only when DOCKER_HOSTNAMES and DOCKER_PORTS set", () => { + mockEnv.DOCKER_HOSTNAMES = "remote-host"; + mockEnv.DOCKER_PORTS = "2375"; + + const instances = DockerSingleton.getInstances(); + + expect(instances).toHaveLength(1); + expect(instances[0]?.host).toBe("remote-host:2375"); + }); + + test("should return multiple TCP instances", () => { + mockEnv.DOCKER_HOSTNAMES = "host1,host2"; + mockEnv.DOCKER_PORTS = "2375,2376"; + + const instances = DockerSingleton.getInstances(); + + expect(instances).toHaveLength(2); + expect(instances[0]?.host).toBe("host1:2375"); + expect(instances[1]?.host).toBe("host2:2376"); + }); + + test("should throw when hostname and port counts do not match", () => { + mockEnv.DOCKER_HOSTNAMES = "host1,host2"; + mockEnv.DOCKER_PORTS = "2375"; + + expect(() => DockerSingleton.getInstances()).toThrow("The number of hosts and ports must match"); + }); + + test("should return socket instances when DOCKER_SOCKET_PATHS set", () => { + mockEnv.DOCKER_SOCKET_PATHS = "/var/run/docker.sock"; + + const instances = DockerSingleton.getInstances(); + + expect(instances).toHaveLength(1); + expect(instances[0]?.host).toBe("/var/run/docker.sock"); + }); + + test("should return multiple socket instances", () => { + mockEnv.DOCKER_SOCKET_PATHS = "/var/run/docker.sock,/run/user/1000/podman/podman.sock"; + + const instances = DockerSingleton.getInstances(); + + expect(instances).toHaveLength(2); + expect(instances[0]?.host).toBe("/var/run/docker.sock"); + expect(instances[1]?.host).toBe("/run/user/1000/podman/podman.sock"); + }); + + test("should combine socket and TCP instances", () => { + mockEnv.DOCKER_SOCKET_PATHS = "/var/run/docker.sock,/run/user/1000/podman/podman.sock"; + mockEnv.DOCKER_HOSTNAMES = "remote-host"; + mockEnv.DOCKER_PORTS = "2375"; + + const instances = DockerSingleton.getInstances(); + + expect(instances).toHaveLength(3); + expect(instances[0]?.host).toBe("/var/run/docker.sock"); + expect(instances[1]?.host).toBe("/run/user/1000/podman/podman.sock"); + expect(instances[2]?.host).toBe("remote-host:2375"); + }); + + test("should cache instances on subsequent calls", () => { + const first = DockerSingleton.getInstances(); + const second = DockerSingleton.getInstances(); + + expect(first).toBe(second); + }); +}); diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts index 7b873ec00..6610aba7e 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -2,6 +2,8 @@ import dayjs from "dayjs"; import type { ContainerInfo, ContainerStats } from "dockerode"; import type Dockerode from "dockerode"; +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { db, like, or } from "@homarr/db"; import { icons } from "@homarr/db/schema"; import type { ContainerState } from "@homarr/docker"; @@ -9,6 +11,8 @@ import { dockerLabels, DockerSingleton } from "@homarr/docker"; import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler"; +const logger = createLogger({ module: "dockerRequestHandler" }); + export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({ queryKey: "dockerContainersResult", widgetKind: "dockerContainers", @@ -18,19 +22,34 @@ export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({ cacheDuration: dayjs.duration(20, "seconds"), }); -const dockerInstances = DockerSingleton.getInstances(); - const extractImage = (container: ContainerInfo) => container.Image.split("/").at(-1)?.split(":").at(0) ?? ""; async function getContainersWithStatsAsync() { - const containers = await Promise.all( + const dockerInstances = DockerSingleton.getInstances(); + const results = await Promise.allSettled( dockerInstances.map(async ({ instance, host }) => { const instanceContainers = await instance.listContainers({ all: true }); return instanceContainers .filter((container) => !(dockerLabels.hide in container.Labels)) .map((container) => ({ ...container, instance: host })); }), - ).then((res) => res.flat()); + ); + + const containers = results.flatMap((result, index) => { + if (result.status === "fulfilled") return result.value; + logger.warn( + new ErrorWithMetadata( + "Failed to list containers from Docker host", + { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + host: dockerInstances[index]!.host, + }, + { cause: result.reason }, + ), + ); + + return []; + }); const likeQueries = containers.map((container) => like(icons.name, `%${extractImage(container)}%`)); const dbIcons = @@ -44,8 +63,6 @@ async function getContainersWithStatsAsync() { const instance = dockerInstances.find(({ host }) => host === container.instance)?.instance; if (!instance) return null; - // Get stats, falling back to an empty stats object if fetch fails - // calculateCpuUsage and calculateMemoryUsage will return 0 for invalid/missing stats const stats = await instance .getContainer(container.Id) .stats({ stream: false, "one-shot": true }) @@ -63,6 +80,7 @@ async function getContainersWithStatsAsync() { return { id: container.Id, name: container.Names[0]?.split("/")[1] ?? "Unknown", + host: container.instance, state: container.State as ContainerState, iconUrl: dbIcons.find((icon) => { @@ -80,9 +98,10 @@ async function getContainersWithStatsAsync() { return (await Promise.all(containerStatsPromises)).filter((container) => container !== null); } -function calculateCpuUsage(stats: ContainerStats): number { - // Handle containers with missing or invalid stats (e.g., exited, dead containers) - if (!stats.cpu_stats.online_cpus || stats.cpu_stats.online_cpus === 0 || !stats.cpu_stats.cpu_usage.total_usage) { +export function calculateCpuUsage(stats: ContainerStats): number { + // Handle containers with missing or invalid stats (e.g., exited, dead containers, Podman responses) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!stats.cpu_stats?.online_cpus || stats.cpu_stats.online_cpus === 0 || !stats.cpu_stats.cpu_usage?.total_usage) { return 0; } @@ -95,9 +114,10 @@ function calculateCpuUsage(stats: ContainerStats): number { return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100; } -function calculateMemoryUsage(stats: ContainerStats): number { - // Handle containers with missing or invalid stats (e.g., exited, dead containers) - if (!stats.memory_stats.usage) { +export function calculateMemoryUsage(stats: ContainerStats): number { + // Handle containers with missing or invalid stats (e.g., exited, dead containers, Podman responses) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!stats.memory_stats?.usage) { return 0; } diff --git a/packages/request-handler/src/test/docker.spec.ts b/packages/request-handler/src/test/docker.spec.ts new file mode 100644 index 000000000..b4e34f816 --- /dev/null +++ b/packages/request-handler/src/test/docker.spec.ts @@ -0,0 +1,121 @@ +import type { ContainerStats } from "dockerode"; +import { describe, expect, test, vi } from "vitest"; + +import { createDb } from "@homarr/db/test"; + +import { calculateCpuUsage, calculateMemoryUsage } from "../docker"; + +vi.mock("@homarr/db", async (importActual) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + const actual = await importActual(); + return { + ...actual, + db: createDb(), + }; +}); + +// Helper to create a partial stats object, cast to ContainerStats. +// This mirrors what Podman and other Docker-compatible runtimes may return +// at runtime, even though @types/dockerode declares all fields as required. +const createStats = (overrides: Record = {}) => overrides as unknown as ContainerStats; + +describe("calculateCpuUsage", () => { + test("should return 0 when cpu_stats is undefined (Podman)", () => { + const stats = createStats({ cpu_stats: undefined }); + expect(calculateCpuUsage(stats)).toBe(0); + }); + + test("should return 0 when online_cpus is 0", () => { + const stats = createStats({ + cpu_stats: { online_cpus: 0, cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 }, + }); + expect(calculateCpuUsage(stats)).toBe(0); + }); + + test("should return 0 when online_cpus is undefined", () => { + const stats = createStats({ + cpu_stats: { cpu_usage: { total_usage: 1000 }, system_cpu_usage: 5000 }, + }); + expect(calculateCpuUsage(stats)).toBe(0); + }); + + test("should return 0 when cpu_usage is undefined", () => { + const stats = createStats({ + cpu_stats: { online_cpus: 4, system_cpu_usage: 5000 }, + }); + expect(calculateCpuUsage(stats)).toBe(0); + }); + + test("should return 0 when total_usage is 0", () => { + const stats = createStats({ + cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 0 }, system_cpu_usage: 5000 }, + }); + expect(calculateCpuUsage(stats)).toBe(0); + }); + + test("should return 0 when system_cpu_usage is 0", () => { + const stats = createStats({ + cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1000 }, system_cpu_usage: 0 }, + }); + expect(calculateCpuUsage(stats)).toBe(0); + }); + + test("should calculate correct CPU percentage for valid stats", () => { + const stats = createStats({ + cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 2000 }, system_cpu_usage: 10000 }, + }); + // (2000 / 10000) * 4 * 100 = 80 + expect(calculateCpuUsage(stats)).toBe(80); + }); + + test("should handle fractional CPU usage", () => { + const stats = createStats({ + cpu_stats: { online_cpus: 2, cpu_usage: { total_usage: 500 }, system_cpu_usage: 100000 }, + }); + // (500 / 100000) * 2 * 100 = 1 + expect(calculateCpuUsage(stats)).toBe(1); + }); +}); + +describe("calculateMemoryUsage", () => { + test("should return 0 when memory_stats is undefined (Podman)", () => { + const stats = createStats({ memory_stats: undefined }); + expect(calculateMemoryUsage(stats)).toBe(0); + }); + + test("should return 0 when memory_stats.usage is 0", () => { + const stats = createStats({ memory_stats: { usage: 0 } }); + expect(calculateMemoryUsage(stats)).toBe(0); + }); + + test("should return 0 when memory_stats.usage is undefined", () => { + const stats = createStats({ memory_stats: {} }); + expect(calculateMemoryUsage(stats)).toBe(0); + }); + + test("should subtract cache from usage", () => { + const stats = createStats({ + memory_stats: { usage: 1000, stats: { cache: 200 } }, + }); + expect(calculateMemoryUsage(stats)).toBe(800); + }); + + test("should use total_inactive_file when cache is absent", () => { + const stats = createStats({ + memory_stats: { usage: 1000, stats: { total_inactive_file: 300 } }, + }); + expect(calculateMemoryUsage(stats)).toBe(700); + }); + + test("should use inactive_file as last fallback", () => { + const stats = createStats({ + memory_stats: { usage: 1000, stats: { inactive_file: 150 } }, + }); + expect(calculateMemoryUsage(stats)).toBe(850); + }); + + test("should return raw usage when memory_stats.stats is undefined", () => { + const stats = createStats({ memory_stats: { usage: 512 } }); + expect(calculateMemoryUsage(stats)).toBe(512); + }); +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 981839c3b..29e86458e 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3970,6 +3970,9 @@ }, "ports": { "label": "Ports" + }, + "host": { + "label": "Host" } }, "action": { diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx index 869b76f0b..c135484a8 100644 --- a/packages/widgets/src/docker/component.tsx +++ b/packages/widgets/src/docker/component.tsx @@ -79,6 +79,19 @@ const createColumns = ( return ; }, }, + { + id: "host", + accessorKey: "host", + size: 100, + header: t("field.host.label"), + Cell({ row }) { + return ( + + {row.original.host} + + ); + }, + }, { id: "cpuUsage", sortingFn: (rowA, rowB) => { diff --git a/turbo.json b/turbo.json index 208d42365..33b661f69 100644 --- a/turbo.json +++ b/turbo.json @@ -37,6 +37,7 @@ "DB_DRIVER", "DOCKER_HOSTNAMES", "DOCKER_PORTS", + "DOCKER_SOCKET_PATHS", "NODE_ENV", "PORT", "LOCAL_CERTIFICATE_PATH",