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