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
+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();
});
});
-33
View File
@@ -1,33 +0,0 @@
export function calculateCPUPercent(stats) {
let cpuPercent = 0.0;
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage;
if (systemDelta > 0.0 && cpuDelta > 0.0) {
cpuPercent = (cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100.0;
}
return Math.round(cpuPercent * 10) / 10;
}
export function calculateUsedMemory(stats) {
// see https://github.com/docker/cli/blob/dcc161076861177b5eef6cb321722520db3184e7/cli/command/container/stats_helpers.go#L239
return (
stats.memory_stats.usage - (stats.memory_stats.total_inactive_file ?? stats.memory_stats.stats?.inactive_file ?? 0)
);
}
export function calculateThroughput(stats) {
let rxBytes = 0;
let txBytes = 0;
if (stats.networks?.network) {
rxBytes = stats.networks?.network.rx_bytes;
txBytes = stats.networks?.network.tx_bytes;
} else if (stats.networks && Array.isArray(Object.values(stats.networks))) {
Object.values(stats.networks).forEach((containerInterface) => {
rxBytes += containerInterface.rx_bytes;
txBytes += containerInterface.tx_bytes;
});
}
return { rxBytes, txBytes };
}
-55
View File
@@ -1,55 +0,0 @@
import { describe, expect, it } from "vitest";
import { calculateCPUPercent, calculateThroughput, calculateUsedMemory } from "./stats-helpers";
describe("widgets/docker/stats-helpers", () => {
it("calculateCPUPercent returns 0 when deltas are not positive", () => {
expect(
calculateCPUPercent({
cpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000, online_cpus: 2 },
precpu_stats: { cpu_usage: { total_usage: 100 }, system_cpu_usage: 1000 },
}),
).toBe(0);
});
it("calculateCPUPercent computes percent and rounds to 1 decimal", () => {
// cpuDelta=100, systemDelta=1000, cpus=2 => (100/1000)*2*100 = 20.0
expect(
calculateCPUPercent({
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 },
}),
).toBe(20);
});
it("calculateUsedMemory subtracts inactive file (prefers total_inactive_file)", () => {
const stats = {
memory_stats: {
usage: 1000,
total_inactive_file: 100,
stats: { inactive_file: 200 },
},
};
expect(calculateUsedMemory(stats)).toBe(900);
});
it("calculateUsedMemory falls back to stats.inactive_file when total_inactive_file missing", () => {
const stats = {
memory_stats: {
usage: 1000,
stats: { inactive_file: 200 },
},
};
expect(calculateUsedMemory(stats)).toBe(800);
});
it("calculateThroughput uses the special networks.network key when present", () => {
const stats = { networks: { network: { rx_bytes: 5, tx_bytes: 6 }, eth0: { rx_bytes: 1, tx_bytes: 2 } } };
expect(calculateThroughput(stats)).toEqual({ rxBytes: 5, txBytes: 6 });
});
it("calculateThroughput sums all interfaces otherwise", () => {
const stats = { networks: { eth0: { rx_bytes: 1, tx_bytes: 2 }, eth1: { rx_bytes: 3, tx_bytes: 4 } } };
expect(calculateThroughput(stats)).toEqual({ rxBytes: 4, txBytes: 6 });
});
});