Performance: use bulk request for docker stats (#7098)

This commit is contained in:
shamoon
2026-09-05 10:08:20 -07:00
committed by GitHub
parent 1024a382ee
commit 7de1faa769
10 changed files with 420 additions and 274 deletions
@@ -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();
});
});
@@ -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();
});
});
+21
View File
@@ -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" },
});
}
}
@@ -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 <Buffer ...> 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" },
});
}
}
@@ -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({
+92
View File
@@ -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 };
}
+173
View File
@@ -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: {} });
});
});
+18 -18
View File
@@ -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 <Container service={service} error={finalError} />;
}
@@ -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 <Container service={service} error="not found" />;
}
if (!statsData || !statusData) {
return (
<Container service={service}>
@@ -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 (
<Container service={service}>
<Block label="docker.cpu" value={t("common.percent", { value: cpuPercent })} highlightValue={cpuPercent} />
{statsData.stats.memory_stats.usage && (
<Block label="docker.mem" value={t("common.bytes", { value: usedMemory })} highlightValue={usedMemory} />
)}
{statsData.stats.networks && (
<Block label="docker.cpu" value={t("common.percent", { value: cpu })} highlightValue={cpu} />
{mem !== undefined && <Block label="docker.mem" value={t("common.bytes", { value: mem })} highlightValue={mem} />}
{rx !== undefined && (
<>
<Block label="docker.rx" value={t("common.bytes", { value: rxBytes })} highlightValue={rxBytes} />
<Block label="docker.tx" value={t("common.bytes", { value: txBytes })} highlightValue={txBytes} />
<Block label="docker.rx" value={t("common.bytes", { value: rx })} highlightValue={rx} />
<Block label="docker.tx" value={t("common.bytes", { value: tx })} highlightValue={tx} />
</>
)}
</Container>
+55 -14
View File
@@ -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(<Component service={{ widget: { type: "docker", container: "c" } }} />, {
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(<Component service={{ widget: { type: "docker", container: "c" } }} />, {
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(<Component service={{ widget: { type: "docker", container: "c", server: "s" } }} />, {
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(<Component service={{ widget: { type: "docker", container: "c", server: "s" } }} />, {
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(<Component service={{ widget: { type: "docker", container: "c", server: "s" } }} />, {
settings: { hideErrors: false },
});
expect(screen.getAllByText(/widget.api_error/).length).toBeGreaterThan(0);
expect(screen.queryByText("docker.cpu")).not.toBeInTheDocument();
});
});