diff --git a/backend/src/__tests__/docker-controller.test.ts b/backend/src/__tests__/docker-controller.test.ts index bc8a2d43..7245e97d 100644 --- a/backend/src/__tests__/docker-controller.test.ts +++ b/backend/src/__tests__/docker-controller.test.ts @@ -179,6 +179,10 @@ describe('DockerController - getDiskUsage', () => { { UsageData: { RefCount: 0, Size: 400 } }, // reclaimable (unused) { UsageData: { RefCount: 1, Size: 300 } }, // not reclaimable (in use) ], + BuildCache: [ + { ID: 'bc1', InUse: false, Size: 600 }, // reclaimable + { ID: 'bc2', InUse: true, Size: 250 }, // not reclaimable + ], }); const dc = DockerController.getInstance(1); @@ -187,6 +191,8 @@ describe('DockerController - getDiskUsage', () => { expect(usage.reclaimableImages).toBe(500); expect(usage.reclaimableContainers).toBe(200); expect(usage.reclaimableVolumes).toBe(400); + expect(usage.reclaimableBuildCache).toBe(600); + expect(usage.reclaimableBuildCacheCount).toBe(1); }); it('handles empty arrays gracefully', async () => { diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 02bab200..be376411 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -163,17 +163,27 @@ class DockerController { return { bytes, count: reclaimable.length }; }; + const reclaimableBuildCache = (items: any[]) => { + if (!items || !Array.isArray(items)) return { bytes: 0, count: 0 }; + const reclaimable = items.filter(i => i.InUse === false); + const bytes = reclaimable.reduce((acc, item) => acc + (item.Size || 0), 0); + return { bytes, count: reclaimable.length }; + }; + const images = df.Images ? reclaimableImages(df.Images) : { bytes: 0, count: 0 }; const containers = df.Containers ? reclaimableContainers(df.Containers) : { bytes: 0, count: 0 }; const volumes = df.Volumes ? reclaimableVolumes(df.Volumes) : { bytes: 0, count: 0 }; + const buildCache = df.BuildCache ? reclaimableBuildCache(df.BuildCache) : { bytes: 0, count: 0 }; return { reclaimableImages: images.bytes, reclaimableContainers: containers.bytes, reclaimableVolumes: volumes.bytes, + reclaimableBuildCache: buildCache.bytes, reclaimableImageCount: images.count, reclaimableContainerCount: containers.count, reclaimableVolumeCount: volumes.count, + reclaimableBuildCacheCount: buildCache.count, }; } @@ -376,9 +386,11 @@ class DockerController { reclaimableImages: number; reclaimableContainers: number; reclaimableVolumes: number; + reclaimableBuildCache: number; reclaimableImageCount: number; reclaimableContainerCount: number; reclaimableVolumeCount: number; + reclaimableBuildCacheCount: number; managedImageBytes: number; unmanagedImageBytes: number; managedVolumeBytes: number; diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index a327f09d..2194f2ed 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -1,6 +1,4 @@ import si from 'systeminformation'; -import { exec } from 'child_process'; -import { promisify } from 'util'; import semver from 'semver'; import DockerController from './DockerController'; import { DatabaseService } from './DatabaseService'; @@ -10,8 +8,6 @@ import { isValidVersion, getSenchoVersion } from './CapabilityRegistry'; import { getLatestVersion } from '../utils/version-check'; import { isDebugEnabled } from '../utils/debug'; -const execAsync = promisify(exec); - const getMetricDetails = (metric: string): { name: string, unit: string } => { switch (metric) { case 'cpu_percent': return { name: 'CPU usage', unit: '%' }; @@ -168,39 +164,17 @@ export class MonitorService { try { const janitorLimitGb = parseFloat(settings['docker_janitor_gb']); if (!isNaN(janitorLimitGb) && janitorLimitGb > 0) { - // Run docker system df to find reclamable space - const { stdout } = await execAsync('docker system df --format "{{json .}}"'); - // Output might be multiple lines of JSON (Images, Containers, Local Volumes, Build Cache) - let totalReclaimableBytes = 0; - const lines = stdout.split('\n').filter(l => l.trim().length > 0); - for (const line of lines) { - try { - const parsed = JSON.parse(line); - // RECLAIMABLE might be something like "1.2GB" or "400MB" Let's parse it manually or just use raw sizes from docker api. Actually docker system df JSON format gives Reclaimable field as string e.g. "1.196GB" (or "0B"). - const reclaimStr = parsed.Reclaimable; - if (reclaimStr) { - // Extract the number and the unit. e.g "1.196GB" (92%) -> 1.196 - const match = reclaimStr.match(/^([0-9.]+)([a-zA-Z]+)/); - if (match) { - const val = parseFloat(match[1]); - // Docker emits both "kB" (lowercase k) on newer versions and "KB" - // historically. Normalise so neither is silently dropped, and - // include "TB" for hosts with terabytes of reclaimable build cache. - const unit = match[2].toUpperCase(); - let bytes = 0; - if (unit === 'TB') bytes = val * 1024 * 1024 * 1024 * 1024; - else if (unit === 'GB') bytes = val * 1024 * 1024 * 1024; - else if (unit === 'MB') bytes = val * 1024 * 1024; - else if (unit === 'KB') bytes = val * 1024; - else if (unit === 'B') bytes = val; - - totalReclaimableBytes += bytes; - } - } - } catch (e) { - console.warn('[MonitorService] Failed to parse Docker system df output:', e); - } - } + // Use the Docker Engine API directly via dockerode. The previous + // shell-out parsed `docker system df --format "{{json .}}"` and + // walked the human-readable "1.196GB" Reclaimable strings with + // a regex; the API returns raw byte counts and saves a fork + // every monitor tick (default 30 s). + const usage = await DockerController.getInstance().getDiskUsage(); + const totalReclaimableBytes = + usage.reclaimableImages + + usage.reclaimableContainers + + usage.reclaimableVolumes + + usage.reclaimableBuildCache; const reclaimGb = totalReclaimableBytes / (1024 * 1024 * 1024); const JANITOR_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours