mirror of
https://github.com/gethomepage/homepage.git
synced 2026-09-16 00:55:48 -07:00
Performance: use bulk request for docker stats (#7098)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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: {} });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user