Skip to content

Commit 9dcb9f4

Browse files
fix(docker): report container CPU as a total-system percentage (0-100%)
Drop the docker-stats per-core convention (* online_cpus), which produced readings like 134% and surprised users. system_cpu_usage accumulates across all cores, so the plain delta ratio is the share of the whole machine - the same 0-100% scale the Unraid integration now reports, keeping CPU readings consistent across homarr.
1 parent 3726a11 commit 9dcb9f4

2 files changed

Lines changed: 49 additions & 8 deletions

File tree

packages/request-handler/src/docker.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -335,13 +335,25 @@ export function calculateCpuUsage(stats: ContainerStats): number {
335335
return 0;
336336
}
337337

338-
const numberOfCpus = stats.cpu_stats.online_cpus;
339-
const usage = stats.cpu_stats.system_cpu_usage;
340-
if (!usage || usage === 0) {
338+
// Instantaneous CPU usage: compare the latest reading against the previous one
339+
// (precpu_stats). Dividing the cumulative total_usage by the cumulative
340+
// system_cpu_usage instead would report the container's lifetime average, which
341+
// stays high long after a load spike has ended. precpu_stats is only present
342+
// when the stats are NOT fetched in one-shot mode; when it is absent (e.g.
343+
// Podman) the deltas fall back to the cumulative totals.
344+
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage ?? 0);
345+
const systemDelta = (stats.cpu_stats.system_cpu_usage ?? 0) - (stats.precpu_stats?.system_cpu_usage ?? 0);
346+
347+
if (systemDelta <= 0 || cpuDelta < 0) {
341348
return 0;
342349
}
343350

344-
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
351+
// Report usage as a share of the WHOLE machine (0-100%), not docker-stats'
352+
// per-core convention (which multiplies by online_cpus and can exceed 100%,
353+
// e.g. "134%" for 1.34 busy cores). system_cpu_usage already accumulates
354+
// across all cores, so the plain ratio is the total-system share. This keeps
355+
// every CPU reading in homarr on the same intuitive 0-100% scale.
356+
return (cpuDelta / systemDelta) * 100;
345357
}
346358

347359
export function calculateMemoryUsage(stats: ContainerStats): number {

packages/request-handler/src/test/docker.spec.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,45 @@ describe("calculateCpuUsage", () => {
6464
const stats = createStats({
6565
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 2000 }, system_cpu_usage: 10000 },
6666
});
67-
// (2000 / 10000) * 4 * 100 = 80
68-
expect(calculateCpuUsage(stats)).toBe(80);
67+
// Total-system share: (2000 / 10000) * 100 = 20 (system_cpu_usage spans all cores)
68+
expect(calculateCpuUsage(stats)).toBe(20);
6969
});
7070

7171
test("should handle fractional CPU usage", () => {
7272
const stats = createStats({
7373
cpu_stats: { online_cpus: 2, cpu_usage: { total_usage: 500 }, system_cpu_usage: 100000 },
7474
});
75-
// (500 / 100000) * 2 * 100 = 1
76-
expect(calculateCpuUsage(stats)).toBe(1);
75+
// (500 / 100000) * 100 = 0.5
76+
expect(calculateCpuUsage(stats)).toBe(0.5);
77+
});
78+
79+
test("should never exceed 100% even when multiple cores are fully busy", () => {
80+
const stats = createStats({
81+
// 3 of 4 cores fully busy: docker-stats' per-core convention would say 300%.
82+
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 7500 }, system_cpu_usage: 10000 },
83+
});
84+
// Total-system share: (7500 / 10000) * 100 = 75
85+
expect(calculateCpuUsage(stats)).toBe(75);
86+
});
87+
88+
test("should use the delta against precpu_stats for the current usage", () => {
89+
const stats = createStats({
90+
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_002_000 }, system_cpu_usage: 20_000 },
91+
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
92+
});
93+
// Despite a large lifetime total_usage, only the recent delta counts:
94+
// (2000 / 10000) * 100 = 20
95+
expect(calculateCpuUsage(stats)).toBe(20);
96+
});
97+
98+
test("should report 0 for a container that was busy over its lifetime but is idle now", () => {
99+
const stats = createStats({
100+
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 20_000 },
101+
precpu_stats: { cpu_usage: { total_usage: 1_000_000 }, system_cpu_usage: 10_000 },
102+
});
103+
// No CPU delta since the last reading -> current usage is 0, even though the
104+
// lifetime average would be high. This is the regression the fix addresses.
105+
expect(calculateCpuUsage(stats)).toBe(0);
77106
});
78107
});
79108

0 commit comments

Comments
 (0)