Fix dashboard image disk usage calculation (#1461)

* fix dashboard image disk usage calculation

* preserve image size fallback behavior
This commit is contained in:
Aleksandr Murashkin authored and GitHub committed 2026-09-13 13:18:06 +02:00
1 parent 2a9299f932
commit 560b21e03f
4 files changed
+95 -4

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
export interface DockerDiskUsageLike {
LayersSize?: unknown;
ImageUsage?: {
TotalSize?: unknown;
} | null;
Images?: Array<{
Size?: unknown;
}> | null;
}
function asNonNegativeFiniteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) && value >= 0
? value
: null;
}
/**
* Return Docker's aggregate image disk usage without double-counting shared layers.
*
* `Images[].Size` is each image's virtual size (shared + unique), so adding those
* values overstates disk usage whenever images share layers. Docker exposes the
* deduplicated total as `ImageUsage.TotalSize` in the current API and as
* `LayersSize` in the legacy response shape.
*/
export function getImageDiskUsageTotalSize(
diskUsage: DockerDiskUsageLike | null | undefined
): number | null {
const currentTotal = asNonNegativeFiniteNumber(diskUsage?.ImageUsage?.TotalSize);
if (currentTotal !== null) return currentTotal;
return asNonNegativeFiniteNumber(diskUsage?.LayersSize);
}
+5 -2
View File
@@ -18,6 +18,7 @@ import {
} from '$lib/server/docker';
import { listComposeStacks } from '$lib/server/stacks';
import { countLivePending } from '$lib/server/pending-updates-core';
import { getImageDiskUsageTotalSize } from '$lib/server/docker-disk-usage-core';
import { authorize } from '$lib/server/authorize';
import { parseLabels } from '$lib/utils/label-colors';
@@ -239,9 +240,11 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
// Process disk usage from /system/df for accurate size data
if (diskUsage) {
// Images: use Size from /system/df
// Images: use Docker's aggregate size so shared layers are counted once
envStats.images.total = diskUsage.Images?.length || images.length;
envStats.images.totalSize = diskUsage.Images?.reduce((sum: number, img: any) => sum + getValidSize(img.Size), 0) || 0;
envStats.images.totalSize = getImageDiskUsageTotalSize(diskUsage)
?? diskUsage.Images?.reduce((sum: number, img: any) => sum + getValidSize(img.Size), 0)
?? 0;
// Volumes: use UsageData.Size from /system/df
envStats.volumes.total = diskUsage.Volumes?.length || volumes.length;
@@ -26,6 +26,7 @@ import { prefersJSON, sseToJSON } from '$lib/server/sse';
import type { EnvironmentStats } from '../+server';
import { parseLabels } from '$lib/utils/label-colors';
import { isEdgeConnected } from '$lib/server/hawser';
import { getImageDiskUsageTotalSize } from '$lib/server/docker-disk-usage-core';
// Skip disk usage collection (Synology NAS performance fix)
@@ -434,9 +435,11 @@ async function getEnvironmentStatsProgressive(
: getCachedDiskUsage(env.id)
.then((diskUsage) => {
if (diskUsage) {
// Update images with disk usage data (more accurate)
// Update images with Docker's deduplicated aggregate size
envStats.images.total = diskUsage.Images?.length || envStats.images.total;
envStats.images.totalSize = diskUsage.Images?.reduce((sum: number, img: any) => sum + getValidSize(img.Size), 0) || envStats.images.totalSize;
envStats.images.totalSize = getImageDiskUsageTotalSize(diskUsage)
?? diskUsage.Images?.reduce((sum: number, img: any) => sum + getValidSize(img.Size), 0)
?? envStats.images.totalSize;
// Volumes from disk usage
envStats.volumes.total = diskUsage.Volumes?.length || 0;
+53
View File
@@ -0,0 +1,53 @@
// @ts-expect-error -- bun:test is a runtime built-in with no types installed
import { describe, expect, test } from 'bun:test';
import { getImageDiskUsageTotalSize } from '../src/lib/server/docker-disk-usage-core';
describe('getImageDiskUsageTotalSize', () => {
test('uses Docker aggregate size instead of summing virtual image sizes', () => {
const diskUsage = {
LayersSize: 70_326_328_267,
Images: [
{ Size: 189_295_958_800 },
{ Size: 82_498_083_216 }
]
};
expect(getImageDiskUsageTotalSize(diskUsage)).toBe(70_326_328_267);
});
test('prefers the current ImageUsage.TotalSize response field', () => {
expect(getImageDiskUsageTotalSize({
ImageUsage: { TotalSize: 42 },
LayersSize: 99
})).toBe(42);
});
test('supports the legacy LayersSize response field', () => {
expect(getImageDiskUsageTotalSize({ LayersSize: 42 })).toBe(42);
});
test('preserves zero as a valid aggregate size', () => {
expect(getImageDiskUsageTotalSize({
ImageUsage: { TotalSize: 0 },
LayersSize: 99
})).toBe(0);
expect(getImageDiskUsageTotalSize({ LayersSize: 0 })).toBe(0);
});
test('falls back from an invalid current value to a valid legacy value', () => {
expect(getImageDiskUsageTotalSize({
ImageUsage: { TotalSize: Number.NaN },
LayersSize: 42
})).toBe(42);
});
test('returns null when no trustworthy aggregate is available', () => {
expect(getImageDiskUsageTotalSize(undefined)).toBeNull();
expect(getImageDiskUsageTotalSize({})).toBeNull();
expect(getImageDiskUsageTotalSize({ LayersSize: -1 })).toBeNull();
expect(getImageDiskUsageTotalSize({
ImageUsage: { TotalSize: '42' },
LayersSize: null
})).toBeNull();
});
});