diff --git a/src/lib/server/docker-disk-usage-core.ts b/src/lib/server/docker-disk-usage-core.ts new file mode 100644 index 0000000..28446a7 --- /dev/null +++ b/src/lib/server/docker-disk-usage-core.ts @@ -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); +} diff --git a/src/routes/api/dashboard/stats/+server.ts b/src/routes/api/dashboard/stats/+server.ts index 62d66b3..c71e6a7 100644 --- a/src/routes/api/dashboard/stats/+server.ts +++ b/src/routes/api/dashboard/stats/+server.ts @@ -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; diff --git a/src/routes/api/dashboard/stats/stream/+server.ts b/src/routes/api/dashboard/stats/stream/+server.ts index 66e9f52..82ca84a 100644 --- a/src/routes/api/dashboard/stats/stream/+server.ts +++ b/src/routes/api/dashboard/stats/stream/+server.ts @@ -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; diff --git a/tests/docker-disk-usage-core.test.ts b/tests/docker-disk-usage-core.test.ts new file mode 100644 index 0000000..da5ddaa --- /dev/null +++ b/tests/docker-disk-usage-core.test.ts @@ -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(); + }); +});