diff --git a/src/__tests__/pages/api/docker/stats.test.js b/src/__tests__/pages/api/docker/stats.test.js new file mode 100644 index 000000000..d85dca361 --- /dev/null +++ b/src/__tests__/pages/api/docker/stats.test.js @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import createMockRes from "test-utils/create-mock-res"; + +const { getDockerStats, logger } = vi.hoisted(() => ({ + getDockerStats: vi.fn(), + logger: { error: vi.fn() }, +})); + +vi.mock("utils/docker/stats", () => ({ getDockerStats })); + +vi.mock("utils/logger", () => ({ + default: () => logger, +})); + +import handler from "pages/api/docker/stats"; + +describe("pages/api/docker/stats", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the stats map for the requested server", async () => { + getDockerStats.mockResolvedValue({ stats: { app: { cpu: 20, mem: 900, rx: 4, tx: 6 } } }); + + const req = { query: { server: "local" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(getDockerStats).toHaveBeenCalledWith("local"); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ stats: { app: { cpu: 20, mem: 900, rx: 4, tx: 6 } } }); + }); + + it("returns 500 when the stats lookup reports an error", async () => { + getDockerStats.mockResolvedValue({ error: "query failed" }); + + const req = { query: { server: "local" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: "query failed" }); + }); + + it("logs and returns 500 when the stats lookup throws", async () => { + getDockerStats.mockRejectedValue(new Error("boom")); + + const req = { query: { server: "local" } }; + const res = createMockRes(); + + await handler(req, res); + + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: { message: "boom" } }); + expect(logger.error).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/pages/api/docker/stats/[...service].test.js b/src/__tests__/pages/api/docker/stats/[...service].test.js deleted file mode 100644 index 9f29a11e8..000000000 --- a/src/__tests__/pages/api/docker/stats/[...service].test.js +++ /dev/null @@ -1,153 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -import createMockRes from "test-utils/create-mock-res"; - -const { state, DockerCtor, getDockerArguments, logger } = vi.hoisted(() => { - const state = { - docker: null, - dockerArgs: { conn: { socketPath: "/var/run/docker.sock" }, swarm: false }, - }; - - function DockerCtor() { - return state.docker; - } - - return { - state, - DockerCtor, - getDockerArguments: vi.fn(() => state.dockerArgs), - logger: { error: vi.fn() }, - }; -}); - -vi.mock("dockerode", () => ({ - default: DockerCtor, -})); - -vi.mock("utils/config/docker", () => ({ - default: getDockerArguments, -})); - -vi.mock("utils/logger", () => ({ - default: () => logger, -})); - -import handler from "pages/api/docker/stats/[...service]"; - -describe("pages/api/docker/stats/[...service]", () => { - beforeEach(() => { - vi.clearAllMocks(); - state.dockerArgs = { conn: { socketPath: "/var/run/docker.sock" }, swarm: false }; - state.docker = { - listContainers: vi.fn(), - getContainer: vi.fn(), - listTasks: vi.fn(), - }; - }); - - it("returns 400 when container name/server params are missing", async () => { - const req = { query: { service: [] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ error: "docker query parameters are required" }); - }); - - it("returns 500 when docker returns a non-array containers payload", async () => { - state.docker.listContainers.mockResolvedValue(Buffer.from("bad")); - - const req = { query: { service: ["c", "local"] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: "query failed" }); - }); - - it("returns stats for an existing container", async () => { - state.docker.listContainers.mockResolvedValue([{ Names: ["/myapp"], Id: "cid1" }]); - const containerStats = { cpu_stats: { cpu_usage: { total_usage: 1 } } }; - state.docker.getContainer.mockReturnValue({ - stats: vi.fn().mockResolvedValue(containerStats), - }); - - const req = { query: { service: ["myapp", "local"] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(res.statusCode).toBe(200); - expect(res.body).toEqual({ stats: containerStats }); - }); - - it("uses swarm tasks to locate a container and reports a friendly error when stats cannot be retrieved", async () => { - state.dockerArgs.swarm = true; - state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]); - state.docker.listTasks.mockResolvedValue([ - { Status: { ContainerStatus: { ContainerID: "local1" } } }, - { Status: { ContainerStatus: { ContainerID: "remote1" } } }, - ]); - state.docker.getContainer.mockReturnValue({ - stats: vi.fn().mockRejectedValue(new Error("nope")), - }); - - const req = { query: { service: ["svc", "local"] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(res.statusCode).toBe(200); - expect(res.body).toEqual({ error: "Unable to retrieve stats" }); - }); - - it("returns stats for a swarm task container when present locally", async () => { - state.dockerArgs.swarm = true; - state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]); - state.docker.listTasks.mockResolvedValue([{ Status: { ContainerStatus: { ContainerID: "local1" } } }]); - - const containerStats = { cpu_stats: { cpu_usage: { total_usage: 2 } } }; - state.docker.getContainer.mockReturnValue({ - stats: vi.fn().mockResolvedValue(containerStats), - }); - - const req = { query: { service: ["svc", "local"] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(res.statusCode).toBe(200); - expect(res.body).toEqual({ stats: containerStats }); - }); - - it("returns 404 when no container or swarm task is found", async () => { - state.dockerArgs.swarm = true; - state.docker.listContainers.mockResolvedValue([{ Names: ["/other"], Id: "local1" }]); - state.docker.listTasks.mockResolvedValue([]); - - const req = { query: { service: ["missing", "local"] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(res.statusCode).toBe(404); - expect(res.body).toEqual({ error: "not found" }); - }); - - it("logs and returns 500 when the docker query throws", async () => { - getDockerArguments.mockImplementationOnce(() => { - throw new Error("boom"); - }); - - const req = { query: { service: ["myapp", "local"] } }; - const res = createMockRes(); - - await handler(req, res); - - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: { message: "boom" } }); - expect(logger.error).toHaveBeenCalled(); - }); -}); diff --git a/src/pages/api/docker/stats.js b/src/pages/api/docker/stats.js new file mode 100644 index 000000000..8bca4aa2b --- /dev/null +++ b/src/pages/api/docker/stats.js @@ -0,0 +1,21 @@ +import { getDockerStats } from "utils/docker/stats"; +import createLogger from "utils/logger"; + +const logger = createLogger("dockerStats"); + +export default async function handler(req, res) { + try { + const result = await getDockerStats(req.query.server); + + if (result.error) { + return res.status(500).send({ error: result.error }); + } + + return res.status(200).json(result); + } catch (e) { + if (e) logger.error(e); + return res.status(500).send({ + error: { message: e?.message ?? "Unknown error" }, + }); + } +} diff --git a/src/pages/api/docker/stats/[...service].js b/src/pages/api/docker/stats/[...service].js deleted file mode 100644 index e92bad7ce..000000000 --- a/src/pages/api/docker/stats/[...service].js +++ /dev/null @@ -1,88 +0,0 @@ -import Docker from "dockerode"; - -import getDockerArguments from "utils/config/docker"; -import createLogger from "utils/logger"; - -const logger = createLogger("dockerStatsService"); - -export default async function handler(req, res) { - const { service } = req.query; - const [containerName, containerServer] = service; - - if (!containerName && !containerServer) { - return res.status(400).send({ - error: "docker query parameters are required", - }); - } - - try { - const dockerArgs = getDockerArguments(containerServer); - const docker = new Docker(dockerArgs.conn); - const containers = await docker.listContainers({ - all: true, - }); - - // bad docker connections can result in a object? - // in any case, this ensures the result is the expected array - if (!Array.isArray(containers)) { - return res.status(500).send({ - error: "query failed", - }); - } - - const containerNames = containers.flatMap((container) => container.Names.map((name) => name.replace(/^\//, ""))); - const containerExists = containerNames.includes(containerName); - - if (containerExists) { - const container = docker.getContainer(containerName); - const stats = await container.stats({ stream: false }); - - return res.status(200).json({ - stats, - }); - } - - // Try with a service deployed in Docker Swarm, if enabled - if (dockerArgs.swarm) { - const tasks = await docker - .listTasks({ - filters: { - service: [containerName], - // A service can have several offline containers, so we only look for an active one. - "desired-state": ["running"], - }, - }) - .catch(() => []); - - // TODO: Show the result for all replicas/containers? - // We can only get stats for 'local' containers so try to find one - const localContainerIDs = containers.map((c) => c.Id); - const task = tasks.find((t) => localContainerIDs.includes(t.Status?.ContainerStatus?.ContainerID)) ?? tasks.at(0); - const taskContainerId = task?.Status?.ContainerStatus?.ContainerID; - - if (taskContainerId) { - try { - const container = docker.getContainer(taskContainerId); - const stats = await container.stats({ stream: false }); - - return res.status(200).json({ - stats, - }); - } catch (e) { - return res.status(200).json({ - error: "Unable to retrieve stats", - }); - } - } - } - - return res.status(404).send({ - error: "not found", - }); - } catch (e) { - if (e) logger.error(e); - return res.status(500).send({ - error: { message: e?.message ?? "Unknown error" }, - }); - } -} diff --git a/src/widgets/docker/stats-helpers.js b/src/utils/docker/stats-helpers.js similarity index 100% rename from src/widgets/docker/stats-helpers.js rename to src/utils/docker/stats-helpers.js diff --git a/src/widgets/docker/stats-helpers.test.js b/src/utils/docker/stats-helpers.test.js similarity index 97% rename from src/widgets/docker/stats-helpers.test.js rename to src/utils/docker/stats-helpers.test.js index 4857eab65..0556f89fe 100644 --- a/src/widgets/docker/stats-helpers.test.js +++ b/src/utils/docker/stats-helpers.test.js @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { calculateCPUPercent, calculateThroughput, calculateUsedMemory } from "./stats-helpers"; -describe("widgets/docker/stats-helpers", () => { +describe("utils/docker/stats-helpers", () => { it("calculateCPUPercent returns 0 when deltas are not positive", () => { expect( calculateCPUPercent({ diff --git a/src/utils/docker/stats.js b/src/utils/docker/stats.js new file mode 100644 index 000000000..7d7928707 --- /dev/null +++ b/src/utils/docker/stats.js @@ -0,0 +1,92 @@ +import Docker from "dockerode"; + +import { getSettings } from "utils/config/config"; +import getDockerArguments from "utils/config/docker"; +import { containersFromConfig, hasHomepageLabels } from "utils/config/service-helpers"; +import { calculateCPUPercent, calculateThroughput, calculateUsedMemory } from "utils/docker/stats-helpers"; + +// mem and network are omitted when docker does not report them, so the widget can skip those blocks +async function statsForContainer(docker, id) { + try { + const raw = await docker.getContainer(id).stats({ stream: false }); + const stats = { cpu: calculateCPUPercent(raw) }; + + if (raw.memory_stats?.usage) { + stats.mem = calculateUsedMemory(raw); + } + + if (raw.networks) { + const { rxBytes, txBytes } = calculateThroughput(raw); + stats.rx = rxBytes; + stats.tx = txBytes; + } + + return stats; + } catch (e) { + // ensure one failed call does not look like an absent container + return { error: e?.message ?? "stats unavailable" }; + } +} + +export async function getDockerStats(server) { + const dockerArgs = getDockerArguments(server); + const docker = new Docker(dockerArgs.conn); + + const [containers, configured] = await Promise.all([ + docker.listContainers({ all: true }), + containersFromConfig(server), + ]); + + if (!Array.isArray(containers)) { + return { error: "query failed" }; + } + + const { instanceName } = getSettings(); + const targets = {}; + const localIds = new Set(); + + containers.forEach((container) => { + localIds.add(container.Id); + if (container.State !== "running") return; + + const labelled = hasHomepageLabels(container.Labels, instanceName); + container.Names.forEach((name) => { + const containerName = name.replace(/^\//, ""); + if (labelled || configured.has(containerName)) targets[containerName] = container.Id; + }); + }); + + if (dockerArgs.swarm) { + const [services, tasks] = await Promise.all([ + docker.listServices().catch(() => []), + docker.listTasks({ filters: { "desired-state": ["running"] } }).catch(() => []), + ]); + + const tasksByService = {}; + tasks.forEach((task) => { + (tasksByService[task.ServiceID] ??= []).push(task); + }); + + services.forEach((service) => { + const name = service.Spec?.Name; + if (!name || targets[name]) return; + if (!configured.has(name) && !hasHomepageLabels(service.Spec?.Labels, instanceName)) return; + + // stats are only available for containers running on this node + const serviceTasks = tasksByService[service.ID] ?? []; + const task = serviceTasks.find((candidate) => localIds.has(candidate.Status?.ContainerStatus?.ContainerID)); + const containerId = task?.Status?.ContainerStatus?.ContainerID; + if (containerId) targets[name] = containerId; + }); + } + + const names = Object.keys(targets); + const results = await Promise.all(names.map((name) => statsForContainer(docker, targets[name]))); + + const stats = {}; + names.forEach((name, index) => { + stats[name] = results[index]; + }); + + return { stats }; +} diff --git a/src/utils/docker/stats.test.js b/src/utils/docker/stats.test.js new file mode 100644 index 000000000..71b30a6ea --- /dev/null +++ b/src/utils/docker/stats.test.js @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { state, DockerCtor, getDockerArguments, containersFromConfig, hasHomepageLabels, getSettings } = vi.hoisted( + () => { + const state = { + docker: null, + containers: [], + statsById: {}, + dockerArgs: { conn: { socketPath: "/var/run/docker.sock" }, swarm: false }, + }; + + function DockerCtor() { + return state.docker; + } + + return { + state, + DockerCtor, + getDockerArguments: vi.fn(() => state.dockerArgs), + containersFromConfig: vi.fn(async () => new Set()), + hasHomepageLabels: vi.fn(() => false), + getSettings: vi.fn(() => ({ instanceName: undefined })), + }; + }, +); + +vi.mock("dockerode", () => ({ default: DockerCtor })); +vi.mock("utils/config/docker", () => ({ default: getDockerArguments })); +vi.mock("utils/config/service-helpers", () => ({ containersFromConfig, hasHomepageLabels })); +vi.mock("utils/config/config", () => ({ getSettings })); + +import { getDockerStats } from "./stats"; + +// cpu 20%, mem 900, rx 4, tx 6 +const rawStats = (overrides = {}) => ({ + cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 2 }, + precpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000 }, + memory_stats: { usage: 1000, total_inactive_file: 100 }, + networks: { eth0: { rx_bytes: 1, tx_bytes: 2 }, eth1: { rx_bytes: 3, tx_bytes: 4 } }, + ...overrides, +}); + +describe("utils/docker/stats", () => { + beforeEach(() => { + vi.clearAllMocks(); + state.dockerArgs = { conn: { socketPath: "/var/run/docker.sock" }, swarm: false }; + state.containers = []; + state.statsById = {}; + state.docker = { + listContainers: vi.fn(async () => state.containers), + listServices: vi.fn(async () => []), + listTasks: vi.fn(async () => []), + getContainer: vi.fn((id) => ({ + stats: vi.fn(async () => { + const entry = state.statsById[id]; + if (entry instanceof Error) throw entry; + if (!entry) throw new Error(`no stats for ${id}`); + return entry; + }), + })), + }; + containersFromConfig.mockResolvedValue(new Set()); + hasHomepageLabels.mockReturnValue(false); + getSettings.mockReturnValue({ instanceName: undefined }); + }); + + it("returns computed stats for configured running containers", async () => { + containersFromConfig.mockResolvedValue(new Set(["app"])); + state.containers = [{ Names: ["/app"], Id: "cid1", State: "running" }]; + state.statsById.cid1 = rawStats(); + + expect(await getDockerStats("local")).toEqual({ stats: { app: { cpu: 20, mem: 900, rx: 4, tx: 6 } } }); + expect(getDockerArguments).toHaveBeenCalledWith("local"); + expect(containersFromConfig).toHaveBeenCalledWith("local"); + }); + + it("omits containers that are neither configured nor labelled", async () => { + containersFromConfig.mockResolvedValue(new Set(["app"])); + state.containers = [ + { Names: ["/app"], Id: "cid1", State: "running" }, + { Names: ["/secret-db"], Id: "cid2", State: "running" }, + ]; + state.statsById.cid1 = rawStats(); + state.statsById.cid2 = rawStats(); + + const result = await getDockerStats("local"); + + expect(Object.keys(result.stats)).toEqual(["app"]); + expect(state.docker.getContainer).not.toHaveBeenCalledWith("cid2"); + }); + + it("includes containers discovered through homepage labels", async () => { + hasHomepageLabels.mockImplementation((labels) => labels?.["homepage.name"] !== undefined); + state.containers = [{ Names: ["/labelled"], Id: "cid1", State: "running", Labels: { "homepage.name": "App" } }]; + state.statsById.cid1 = rawStats(); + + expect(Object.keys((await getDockerStats("local")).stats)).toEqual(["labelled"]); + }); + + it("does not collect stats for containers that are not running", async () => { + containersFromConfig.mockResolvedValue(new Set(["app"])); + state.containers = [{ Names: ["/app"], Id: "cid1", State: "exited" }]; + + expect(await getDockerStats("local")).toEqual({ stats: {} }); + expect(state.docker.getContainer).not.toHaveBeenCalled(); + }); + + it("keeps a per container stats failure without failing the rest", async () => { + containersFromConfig.mockResolvedValue(new Set(["ok", "broken"])); + state.containers = [ + { Names: ["/ok"], Id: "cid1", State: "running" }, + { Names: ["/broken"], Id: "cid2", State: "running" }, + ]; + state.statsById.cid1 = rawStats(); + state.statsById.cid2 = new Error("stats unavailable"); + + expect(await getDockerStats("local")).toEqual({ + stats: { ok: { cpu: 20, mem: 900, rx: 4, tx: 6 }, broken: { error: "stats unavailable" } }, + }); + }); + + it("omits mem and network when docker does not report them", async () => { + containersFromConfig.mockResolvedValue(new Set(["app"])); + state.containers = [{ Names: ["/app"], Id: "cid1", State: "running" }]; + state.statsById.cid1 = rawStats({ memory_stats: {}, networks: undefined }); + + expect(await getDockerStats("local")).toEqual({ stats: { app: { cpu: 20 } } }); + }); + + it("returns an error when docker returns a non-array containers payload", async () => { + state.containers = Buffer.from("bad"); + + expect(await getDockerStats("local")).toEqual({ error: "query failed" }); + }); + + it("resolves a swarm service through its local task container", async () => { + state.dockerArgs.swarm = true; + containersFromConfig.mockResolvedValue(new Set(["svc"])); + state.containers = [{ Names: ["/other"], Id: "local1", State: "running" }]; + state.statsById.local1 = rawStats(); + state.docker.listServices.mockResolvedValue([{ ID: "sid", Spec: { Name: "svc" } }]); + state.docker.listTasks.mockResolvedValue([ + { ServiceID: "sid", Status: { ContainerStatus: { ContainerID: "remote1" } } }, + { ServiceID: "sid", Status: { ContainerStatus: { ContainerID: "local1" } } }, + ]); + + expect(await getDockerStats("swarm")).toEqual({ stats: { svc: { cpu: 20, mem: 900, rx: 4, tx: 6 } } }); + }); + + it("skips a swarm service with no container on this node", async () => { + state.dockerArgs.swarm = true; + containersFromConfig.mockResolvedValue(new Set(["svc"])); + state.containers = [{ Names: ["/other"], Id: "local1", State: "running" }]; + state.docker.listServices.mockResolvedValue([{ ID: "sid", Spec: { Name: "svc" } }]); + state.docker.listTasks.mockResolvedValue([ + { ServiceID: "sid", Status: { ContainerStatus: { ContainerID: "remote1" } } }, + ]); + + expect(await getDockerStats("swarm")).toEqual({ stats: {} }); + }); + + it("omits swarm services that are neither configured nor labelled", async () => { + state.dockerArgs.swarm = true; + state.containers = [{ Names: ["/other"], Id: "local1", State: "running" }]; + state.statsById.local1 = rawStats(); + state.docker.listServices.mockResolvedValue([{ ID: "sid", Spec: { Name: "internal" } }]); + state.docker.listTasks.mockResolvedValue([ + { ServiceID: "sid", Status: { ContainerStatus: { ContainerID: "local1" } } }, + ]); + + expect(await getDockerStats("swarm")).toEqual({ stats: {} }); + }); +}); diff --git a/src/widgets/docker/component.jsx b/src/widgets/docker/component.jsx index 75064f6cf..504aba569 100644 --- a/src/widgets/docker/component.jsx +++ b/src/widgets/docker/component.jsx @@ -1,8 +1,6 @@ import { useTranslation } from "next-i18next/pages"; import useSWR from "swr"; -import { calculateCPUPercent, calculateThroughput, calculateUsedMemory } from "./stats-helpers"; - import Block from "components/services/widget/block"; import Container from "components/services/widget/container"; @@ -10,17 +8,18 @@ export default function Component({ service }) { const { t } = useTranslation(); const { widget } = service; + const server = encodeURIComponent(widget.server || ""); - const { data: statusResponse, error: statusError } = useSWR( - `/api/docker/statuses?server=${encodeURIComponent(widget.server || "")}`, - ); + const { data: statusResponse, error: statusError } = useSWR(`/api/docker/statuses?server=${server}`); const { statuses } = statusResponse ?? {}; const statusData = statuses ? (statuses[widget.container] ?? { status: "not found" }) : undefined; - const { data: statsData, error: statsError } = useSWR(`/api/docker/stats/${widget.container}/${widget.server || ""}`); + const { data: statsResponse, error: statsError } = useSWR(`/api/docker/stats?server=${server}`); + const { stats } = statsResponse ?? {}; + const statsData = stats?.[widget.container]; - if (statsError || statsData?.error || statusError || statusResponse?.error) { - const finalError = statsError ?? statsData?.error ?? statusError ?? statusResponse?.error; + if (statsError || statsResponse?.error || statsData?.error || statusError || statusResponse?.error) { + const finalError = statsError ?? statsResponse?.error ?? statsData?.error ?? statusError ?? statusResponse?.error; return ; } @@ -32,6 +31,11 @@ export default function Component({ service }) { ); } + // running, but reporting no stats: a swarm service whose container is on another node + if (statusData && stats && !statsData) { + return ; + } + if (!statsData || !statusData) { return ( @@ -43,20 +47,16 @@ export default function Component({ service }) { ); } - const { rxBytes, txBytes } = calculateThroughput(statsData.stats); - const cpuPercent = calculateCPUPercent(statsData.stats); - const usedMemory = calculateUsedMemory(statsData.stats); + const { cpu, mem, rx, tx } = statsData; return ( - - {statsData.stats.memory_stats.usage && ( - - )} - {statsData.stats.networks && ( + + {mem !== undefined && } + {rx !== undefined && ( <> - - + + )} diff --git a/src/widgets/docker/component.test.jsx b/src/widgets/docker/component.test.jsx index c94a2ce79..f0e059e40 100644 --- a/src/widgets/docker/component.test.jsx +++ b/src/widgets/docker/component.test.jsx @@ -59,28 +59,69 @@ describe("widgets/docker/component", () => { it("renders cpu/mem/rx/tx values when stats are available", () => { useSWR .mockReturnValueOnce({ data: { statuses: { c: { status: "running" } } }, error: undefined }) - .mockReturnValueOnce({ - data: { - stats: { - cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 2 }, - precpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000 }, - memory_stats: { usage: 1000, total_inactive_file: 100 }, - networks: { eth0: { rx_bytes: 1, tx_bytes: 2 }, eth1: { rx_bytes: 3, tx_bytes: 4 } }, - }, - }, - error: undefined, - }); + .mockReturnValueOnce({ data: { stats: { c: { cpu: 20, mem: 900, rx: 4, tx: 6 } } }, error: undefined }); const { container } = renderWithProviders(, { settings: { hideErrors: false }, }); - // cpu: (100/1000)*2*100=20 + expect(useSWR).toHaveBeenCalledWith("/api/docker/stats?server="); expect(container.textContent).toContain("20"); - // mem used: 1000-100=900 expect(container.textContent).toContain("900"); - // rx=4, tx=6 expect(container.textContent).toContain("4"); expect(container.textContent).toContain("6"); }); + + it("omits the mem and network blocks when the api omits those fields", () => { + useSWR + .mockReturnValueOnce({ data: { statuses: { c: { status: "running" } } }, error: undefined }) + .mockReturnValueOnce({ data: { stats: { c: { cpu: 20 } } }, error: undefined }); + + renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expect(screen.getByText("docker.cpu")).toBeInTheDocument(); + expect(screen.queryByText("docker.mem")).not.toBeInTheDocument(); + expect(screen.queryByText("docker.rx")).not.toBeInTheDocument(); + }); + + it("surfaces a per container stats error rather than reporting it missing", () => { + useSWR + .mockReturnValueOnce({ data: { statuses: { c: { status: "running" } } }, error: undefined }) + .mockReturnValueOnce({ data: { stats: { c: { error: "connect ETIMEDOUT" } } }, error: undefined }); + + renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expect(screen.getAllByText(/widget.api_error/).length).toBeGreaterThan(0); + expect(screen.queryByText("docker.cpu")).not.toBeInTheDocument(); + }); + + it("waits for the status before treating absence from the stats map as an error", () => { + useSWR + .mockReturnValueOnce({ data: undefined, error: undefined }) + .mockReturnValueOnce({ data: { stats: {} }, error: undefined }); + + renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expect(screen.queryAllByText(/widget.api_error/)).toHaveLength(0); + expect(screen.getByText("docker.cpu")).toBeInTheDocument(); + }); + + it("reports an error when a running container is absent from the stats map", () => { + useSWR + .mockReturnValueOnce({ data: { statuses: { c: { status: "running" } } }, error: undefined }) + .mockReturnValueOnce({ data: { stats: {} }, error: undefined }); + + renderWithProviders(, { + settings: { hideErrors: false }, + }); + + expect(screen.getAllByText(/widget.api_error/).length).toBeGreaterThan(0); + expect(screen.queryByText("docker.cpu")).not.toBeInTheDocument(); + }); });