Files
sencho/backend/src/services/DockerController.ts
T
Anso 8980910153 feat: node-scoped Networking operator page (#1603)
* feat: add node-scoped Networking operator page

Adds a Networking view with overview, topology, inventory, and findings.

Shared aggregate reads back the page; Resources keeps prune and redirects here.

Includes fail-closed network delete guards, operator docs, and /nodes/:slug/networking routing.

* fix: rename unused variable n to _n to satisfy no-unused-vars lint

* fix: keep top bar search clickable when nav grows

* feat: complete Compose-first Networking Phase 2 operator assistant

* fix: move networking action visibility helper out of component module

* feat(networking): complete Compose-first Networking operator page

Finish the node-scoped Networking page (Overview, Networks, Topology,
Findings) with design-system parity and correct finding semantics.

- Rebuild detail sheets on SystemSheet/SheetSection; align the tab band,
  masthead, and mobile tone with Fleet and Security.
- Encode the host-mode and exposure severity matrix; fix collision counts
  so intentional shared externals are not flagged; add one typed drift
  predicate shared by inventory, topology, badges, and overview counts.
- Preserve per-container attachments and IPs on topology node clicks;
  drawer-only click with an explicit logs action; ownership and boolean
  filters; bound large graphs before layout.
- Aggregate cached Compose Doctor findings into the Findings tab with
  honest source labels, structural merge and dedupe, staleness
  reconciliation, and a shared exposure-context helper both engines use.
- Networks tab: privacy-safe service search, precise ownership counts,
  schema v3 with version-2 adapters on every endpoint, pre-confirm delete
  reasons, and the shared sortable table with an internal scroll region.
- Interop: Fleet node-card networking signal with pending-intent
  navigation, stack-to-node backlink, and Dossier/Drift deep links.
- Enrich sanitized inspect with an allowlisted connected-container list;
  fetch topology once and filter client-side.
- Docs and tests across every new finding kind, adapter, and flow.

* fix(networking): correct drift count, exposure fail-soft, and inspect crash paths

Address code-review findings on the Networking page implementation:
- Fix the Overview drift count to use the shared drift-kind predicate instead
  of a hardcoded list that omitted external-network-missing.
- Gate Compose Doctor's unclassified-exposure and reverse-proxy-undocumented
  rules on exposure-context availability, so a DB read failure no longer
  fabricates findings (mirrors the live engine's existing fail-soft behavior).
- Guard the per-stack exposure-intent read in topology aggregation so a
  transient DB failure degrades to unknown intent instead of failing the
  whole response.
- Harden the network detail drawer against a partial inspect payload from an
  older remote node, and log the real error instead of a bare catch.
- Remove now-duplicated severity-rank and drift-kind helpers in favor of the
  shared modules; drop dead backend-only exports; widen the frontend schema
  version type to a plain number instead of casting past a literal type.
- Add coverage for the delete-guard precedence, the full host-mode severity
  matrix, the schema-2 compatibility adapter, and the sanitized connected-
  container allowlist; tighten two tests that were not exercising the
  behavior they claimed to.

* fix: add missing onOpenNodeNetworking prop to FleetView experimental test

The added required prop on FleetViewProps broke the merge-build when the
test file (on main but not on this branch) was compiled against the
updated FleetView interface.
2026-07-14 20:18:10 -04:00

2713 lines
105 KiB
TypeScript

import Docker from 'dockerode';
import WebSocket from 'ws';
import { execFile } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs/promises';
import * as yaml from 'yaml';
import { NodeRegistry } from './NodeRegistry';
import { CacheService } from './CacheService';
import { FileSystemService } from './FileSystemService';
import SelfIdentityService from './SelfIdentityService';
import {
fingerprintPrunePlan,
normalizePruneTargets,
PRUNEABLE_CONTAINER_STATES,
PrunePlanStaleError,
type PruneItemOutcome,
type PrunePlan,
type PrunePlanItem,
type PruneScope,
type PruneTarget,
} from './prunePlan';
import type { NetworkingNetworkBase } from './network/networkingTypes';
import { isPathWithinBase } from '../utils/validation';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import { describeSpawnError } from '../utils/spawnErrors';
import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs';
export type {
PruneItemOutcome,
PrunePlan,
PrunePlanItem,
PruneScope,
PruneTarget,
} from './prunePlan';
export { PrunePlanStaleError } from './prunePlan';
/** Parsed row from `docker compose ps --format json`. */
interface ComposePsContainer {
ID?: string;
Name?: string;
Service?: string;
State?: string;
Status?: string;
Publishers?: { URL?: string; TargetPort?: number; PublishedPort?: number; Protocol?: string }[];
}
const execFileAsync = promisify(execFile);
const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose';
/** Canonical compose file name variants, checked in priority order. */
const COMPOSE_FILE_NAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'] as const;
/** Cached mapping from compose `name:` field to stack directory name. TTL-based to avoid re-parsing YAML on every poll. */
const PROJECT_NAME_CACHE_TTL_MS = 60_000;
const PROJECT_NAME_CACHE_KEY = 'project-name-map';
/** How long a resolved container StartedAt stays fresh before re-inspecting. */
const STARTED_AT_CACHE_TTL_MS = 20_000;
/** Cap on concurrent inspect() calls when resolving StartedAt in bulk. */
const STARTED_AT_INSPECT_CONCURRENCY = 10;
/** Common web-UI private ports, checked in priority order when detecting the main app port. */
const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000];
/** Ports that should never be treated as the main app port. */
const IGNORE_PORTS = [1900, 53, 22];
/**
* Pick the published host port most likely to be a web UI, in priority order,
* skipping UDP (not browser-openable) and deprioritizing system ports, falling
* back to the first TCP port when nothing better matches. Returns the chosen
* PublicPort, or undefined when no TCP port qualifies.
*/
export function selectMainWebPort(
ports: { PrivatePort?: number; PublicPort?: number; Type?: string }[],
): number | undefined {
const tcp = ports.filter(p => p.Type !== 'udp');
let match = tcp.find(p => p.PrivatePort && WEB_UI_PORTS.includes(p.PrivatePort));
if (!match) match = tcp.find(p => p.PublicPort && WEB_UI_PORTS.includes(p.PublicPort));
if (!match) match = tcp.find(p =>
(!p.PrivatePort || !IGNORE_PORTS.includes(p.PrivatePort)) &&
(!p.PublicPort || !IGNORE_PORTS.includes(p.PublicPort)),
);
const chosen = match || tcp[0];
return chosen?.PublicPort;
}
/**
* Pull the exit code out of a Docker container Status string. listContainers
* exposes the code only inside Status (e.g. "Exited (137) 2 minutes ago"); the
* structured code would otherwise need a per-container inspect. Returns null when
* no parenthesized code is present (e.g. "Up 3 hours", "Created").
*/
export function parseExitCode(status: string | undefined): number | null {
if (!status) return null;
const match = /\((\d+)\)/.exec(status);
return match ? Number(match[1]) : null;
}
/**
* Whether a container represents a genuine failure (a crash) rather than a clean
* completion. A dead container always counts. An exited or restarting container
* counts only when it left with a non-zero exit code (read from its Status
* string), so a finished init job (exit 0) or a container cleanly cycling under a
* restart policy does not mark its stack as degraded, while a crash loop (e.g.
* "Restarting (1)") does. An exited or restarting container with an unreadable
* code is treated as failed, erring toward surfacing a crash.
*/
export function isContainerFailed(state: string, status: string | undefined): boolean {
if (state === 'dead') return true;
if (state === 'exited' || state === 'restarting') {
const code = parseExitCode(status);
return code === null ? true : code !== 0;
}
return false;
}
export interface BulkStackInfo {
status: 'running' | 'exited' | 'unknown' | 'partial';
mainPort?: number;
/** Unix seconds of the oldest running container's last start (approximates stack uptime). */
runningSince?: number;
/** Running container count for the stack (set when the stack has containers). */
running?: number;
/** Total container count for the stack; paired with `running` for the sidebar tooltip. */
total?: number;
/** True when this stack is the running Sencho instance (compose project matches stack name). */
isSelf?: boolean;
}
export interface ClassifiedImage {
Id: string;
RepoTags: string[];
Size: number;
Containers: number;
/** Deduped, localeCompare-sorted Sencho stacks that currently use this image. */
usedByStacks: string[];
/** First entry of usedByStacks, or null when unused / unmanaged-only. */
managedBy: string | null;
managedStatus: 'managed' | 'unmanaged' | 'unused';
isSencho: boolean;
}
export interface PortInUseInfo {
stack: string | null;
container: string;
}
export interface ClassifiedVolume {
Name: string;
Driver: string;
Mountpoint: string;
Size: number;
CreatedAt: string | null;
managedBy: string | null;
managedStatus: 'managed' | 'unmanaged';
isSencho: boolean;
}
export interface ClassifiedNetwork {
Id: string;
Name: string;
Driver: string;
Scope: string;
managedBy: string | null;
managedStatus: 'managed' | 'unmanaged' | 'system';
isSencho: boolean;
}
export interface TopologyContainer {
id: string;
name: string;
ip: string;
state: string;
image: string;
stack: string | null;
}
export interface TopologyNetwork {
Id: string;
Name: string;
Driver: string;
Scope: string;
managedBy: string | null;
managedStatus: 'managed' | 'unmanaged' | 'system';
containers: TopologyContainer[];
}
/** A host-published port binding on a container. */
export interface DependencyPort {
/** Host interface the port binds to ('' or '0.0.0.0'/'::' means all interfaces). */
ip: string;
publishedPort: number;
privatePort: number | null;
protocol: string;
}
/** A container as seen by the dependency map: compose identity plus its real
* runtime network/volume names and published ports. */
export interface DependencyContainer {
id: string;
name: string;
/** com.docker.compose.service label, or null for non-compose containers. */
service: string | null;
/** Raw com.docker.compose.project label (may not map to a known stack). */
composeProject: string | null;
/** Resolved Sencho stack, or null when the container is not Sencho-managed. */
stack: string | null;
state: string;
image: string;
networks: { name: string; id: string; ip: string }[];
/** Named-volume sources mounted by the container (bind mounts excluded). */
volumes: string[];
ports: DependencyPort[];
}
export interface DependencyNetwork {
id: string;
name: string;
driver: string;
scope: string;
isSystem: boolean;
ingress?: boolean;
enableIPv6?: boolean;
/** Raw com.docker.compose.project label (may not map to a known stack). */
composeProject: string | null;
/** Resolved Sencho stack this network belongs to, or null. */
stack: string | null;
}
export interface DependencyVolume {
name: string;
driver: string;
composeProject: string | null;
/** Resolved Sencho stack this volume belongs to, or null. */
stack: string | null;
}
/** One node-scoped snapshot of everything the dependency map needs. */
export interface DependencySnapshot {
containers: DependencyContainer[];
networks: DependencyNetwork[];
volumes: DependencyVolume[];
}
export type NetworkDriver = 'bridge' | 'overlay' | 'macvlan' | 'host' | 'none';
export interface CreateNetworkOptions {
Name: string;
Driver?: NetworkDriver;
IPAM?: {
Config: Array<{
Subnet?: string;
Gateway?: string;
IPRange?: string;
}>;
};
Labels?: Record<string, string>;
Internal?: boolean;
Attachable?: boolean;
}
export interface LabelInventoryRow {
id: string;
name: string;
state: string;
stack: string | null;
service: string | null;
labels: Record<string, string>;
imageId: string;
inspectFailed: boolean;
}
class DockerController {
private static readonly SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']);
/**
* Cache of container last-start times (unix seconds), keyed by `${nodeId}:${containerId}`.
* Static because getInstance() hands out throwaway instances per request; the cache must
* outlive them so status polls do not re-inspect every container every few seconds.
*/
private static startedAtCache = new Map<string, { startedAtSeconds: number; cachedAtMs: number }>();
private docker: Docker;
private nodeId: number;
private constructor(nodeId: number) {
this.nodeId = nodeId;
this.docker = NodeRegistry.getInstance().getDocker(nodeId);
}
public static getInstance(nodeId?: number): DockerController {
const id = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
return new DockerController(id);
}
public getDocker(): Docker {
return this.docker;
}
private validateApiData<T>(data: any): T {
// If the daemon port points to a web server (like Sencho UI), Dockerode receives HTML
if (typeof data === 'string') {
throw new Error("Invalid response from Docker API. Did you provide a web port instead of the Docker daemon port?");
}
return data as T;
}
public async getDiskUsage() {
const df = await this.docker.df();
const reclaimableContainers = (items: any[]) => {
if (!items || !Array.isArray(items)) return { bytes: 0, count: 0 };
// Count only the containers `docker container prune` will actually
// remove: created, exited, and dead. A paused or restarting container
// survives the prune, so counting it leaves a residue the banner can
// never clear no matter which prune the operator runs.
const prunableStates = new Set(['created', 'exited', 'dead']);
const reclaimable = items.filter(i => prunableStates.has(String(i.State).toLowerCase()));
// Size by the writable layer only. `docker system df` reports a stopped
// container's reclaimable as its SizeRw; SizeRootFs additionally includes
// the read-only image layers, which removing the container never frees,
// so using it over-reports what a prune actually reclaims.
const bytes = reclaimable.reduce((acc, item) => {
const size = typeof item.SizeRw === 'number' && item.SizeRw > 0 ? item.SizeRw : 0;
return acc + size;
}, 0);
return { bytes, count: reclaimable.length };
};
// Prefer the daemon's own ImageUsage.Reclaimable (Docker API v1.44+); it is
// the exact number `docker system df` displays and accounts for shared
// layers correctly. On older daemons, fall back to LayersSize minus the
// unique-to-in-use bytes, the same arithmetic the CLI uses internally.
// Summing per-image Size/VirtualSize would over-report by the multiplicity
// of every shared layer (the old bug that inflated the banner).
const reclaimableImages = (items: any[], layersSize: number, serverReclaimable: number | undefined) => {
if (!items || !Array.isArray(items)) return { bytes: 0, count: 0 };
const reclaimable = items.filter(i => (i?.Containers ?? 0) === 0);
if (serverReclaimable !== undefined && serverReclaimable >= 0) {
return { bytes: serverReclaimable, count: reclaimable.length };
}
let used = 0;
for (const item of items) {
if (!item || (item.Containers ?? 0) <= 0) continue;
// Choose the best non-negative size: prefer VirtualSize, fall back to
// Size. If neither is known the image is truly unaccountable; skipping
// it leaks at most one image's worth of bytes into the reclaim total.
let virt = -1;
if (typeof item.VirtualSize === 'number' && item.VirtualSize >= 0) {
virt = item.VirtualSize;
} else if (typeof item.Size === 'number' && item.Size >= 0) {
virt = item.Size;
}
if (virt < 0) continue;
// SharedSize === -1 (or absent) means "unknown" on older daemons. Treat
// it as 0 so the image's full size counts as in-use. Under-reporting
// reclaimable is the safer direction; the previous skip-on-(-1) path
// moved those bytes into the reclaimable total and re-inflated it.
const shared = typeof item.SharedSize === 'number' && item.SharedSize >= 0
? item.SharedSize
: 0;
used += Math.max(0, virt - shared);
}
const bytes = Math.max(0, (layersSize || 0) - used);
return { bytes, count: reclaimable.length };
};
const reclaimableVolumes = (items: any[]) => {
if (!items || !Array.isArray(items)) return { bytes: 0, count: 0 };
const reclaimable = items.filter(i => i.UsageData?.RefCount === 0);
const bytes = reclaimable.reduce((acc, item) => {
const size = item.UsageData?.Size || 0;
return acc + size;
}, 0);
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 imageUsage = (df as { ImageUsage?: { Reclaimable?: number } }).ImageUsage;
const images = df.Images
? reclaimableImages(df.Images, df.LayersSize ?? 0, imageUsage?.Reclaimable)
: { 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,
};
}
// Sencho's own image, networks, and named volumes are always in use by the
// running container, so Docker's server-side prune APIs (pruneContainers,
// pruneImages, pruneNetworks, pruneVolumes) skip them by definition. No
// extra self-guard is needed at this layer; the `managed` scope path goes
// through `pruneManagedOnly`, which adds an explicit self filter for
// defense-in-depth.
public async pruneSystem(target: 'containers' | 'images' | 'networks' | 'volumes', labelFilter?: string) {
let spaceReclaimed = 0;
if (target === 'containers') {
const filters: Record<string, string[]> = {};
if (labelFilter) filters.label = [labelFilter];
const r = await this.docker.pruneContainers({ filters });
spaceReclaimed = r.SpaceReclaimed || 0;
} else if (target === 'images') {
// Remove all unused images, not just dangling ones
const filters: Record<string, string[] | Record<string, boolean>> = { dangling: { 'false': true } };
if (labelFilter) filters.label = [labelFilter];
const r = await this.docker.pruneImages({ filters });
spaceReclaimed = r.SpaceReclaimed || 0;
} else if (target === 'networks') {
const filters: Record<string, string[]> = {};
if (labelFilter) filters.label = [labelFilter];
const r = await this.docker.pruneNetworks({ filters });
spaceReclaimed = (r as { SpaceReclaimed?: number }).SpaceReclaimed || 0;
} else if (target === 'volumes') {
const filters: Record<string, string[]> = { all: ['true'] };
if (labelFilter) filters.label = [labelFilter];
const r = await this.docker.pruneVolumes({ filters });
spaceReclaimed = r.SpaceReclaimed || 0;
}
return {
success: true,
reclaimedBytes: spaceReclaimed
};
}
// Prune ONLY dangling (untagged) images. Distinct from pruneSystem('images'),
// which uses { dangling: { 'false': true } } to remove every unused image.
// Used by the prune-on-update flow to reclaim the layers a pull/recreate
// orphans, without touching tagged images for stopped stacks.
public async pruneDanglingImages(): Promise<{ success: boolean; reclaimedBytes: number }> {
const filters: Record<string, string[] | Record<string, boolean>> = { dangling: { 'true': true } };
const r = await this.docker.pruneImages({ filters });
return { success: true, reclaimedBytes: r.SpaceReclaimed || 0 };
}
public async getImages() {
const data = await this.docker.listImages({ all: false });
return this.validateApiData<any[]>(data);
}
public async getVolumes() {
const data = await this.docker.listVolumes();
const validated = this.validateApiData<any>(data);
return validated.Volumes || [];
}
public async getNetworks() {
const data = await this.docker.listNetworks();
return this.validateApiData<any[]>(data);
}
public async getClassifiedResources(knownStackNames: string[]): Promise<{
images: ClassifiedImage[];
volumes: ClassifiedVolume[];
networks: ClassifiedNetwork[];
}> {
const debug = isDebugEnabled();
const t0 = debug ? Date.now() : 0;
const knownSet = new Set(knownStackNames);
const [rawImages, rawVolumeData, rawNetworks, allContainers, projectToStack] = await Promise.all([
this.docker.listImages({ all: false }),
this.docker.listVolumes(),
this.docker.listNetworks(),
this.docker.listContainers({ all: true }),
DockerController.resolveProjectNameMap(knownStackNames),
]);
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
// Build fallback lookup structures for container-to-stack resolution
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
const resolvedBase = path.resolve(COMPOSE_DIR);
// Build imageId → set of Sencho stacks (multi-stack reverse index).
const imageToStacks = new Map<string, Set<string>>();
for (const c of allContainers as any[]) {
if (!c.ImageID) continue;
const stack = DockerController.resolveContainerStack(
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (!stack) continue;
const set = imageToStacks.get(c.ImageID) ?? new Set<string>();
set.add(stack);
imageToStacks.set(c.ImageID, set);
}
const selfIdentity = SelfIdentityService.getInstance();
const images: ClassifiedImage[] = this.validateApiData<any[]>(rawImages).map((img: any) => {
const usedByStacks = [...(imageToStacks.get(img.Id) ?? [])].sort((a, b) => a.localeCompare(b));
const managedBy = usedByStacks[0] ?? null;
const managedStatus: ClassifiedImage['managedStatus'] =
img.Containers === 0 ? 'unused' :
managedBy ? 'managed' : 'unmanaged';
return {
Id: img.Id,
RepoTags: img.RepoTags ?? [],
Size: img.Size ?? 0,
Containers: img.Containers ?? 0,
usedByStacks,
managedBy,
managedStatus,
isSencho: selfIdentity.isOwnImage(img.Id),
};
});
const volumes: ClassifiedVolume[] = rawVolumes.map((vol: any) => {
const stack = DockerController.resolveProjectLabel(vol.Labels?.['com.docker.compose.project'], knownSet, projectToStack);
const managedStatus: ClassifiedVolume['managedStatus'] = stack ? 'managed' : 'unmanaged';
return {
Name: vol.Name,
Driver: vol.Driver,
Mountpoint: vol.Mountpoint,
Size: vol.UsageData?.Size ?? 0,
CreatedAt: vol.CreatedAt ?? null,
managedBy: stack,
managedStatus,
isSencho: selfIdentity.isOwnVolume(vol.Name),
};
});
const networks: ClassifiedNetwork[] = this.validateApiData<any[]>(rawNetworks).map((net: any) => {
if (DockerController.SYSTEM_NETWORKS.has(net.Name)) {
return { Id: net.Id, Name: net.Name, Driver: net.Driver, Scope: net.Scope, managedBy: null, managedStatus: 'system' as const, isSencho: false };
}
const stack = DockerController.resolveProjectLabel(net.Labels?.['com.docker.compose.project'], knownSet, projectToStack);
const managedStatus: ClassifiedNetwork['managedStatus'] = stack ? 'managed' : 'unmanaged';
return {
Id: net.Id,
Name: net.Name,
Driver: net.Driver,
Scope: net.Scope,
managedBy: stack,
managedStatus,
isSencho: selfIdentity.isOwnNetwork(net.Id) || selfIdentity.isOwnNetwork(net.Name),
};
});
if (debug) console.debug('[Resources:debug] Classification completed', {
ms: Date.now() - t0, images: images.length, volumes: volumes.length, networks: networks.length,
});
return { images, volumes, networks };
}
/**
* Returns the `docker df` snapshot, or null if the call fails. The
* destructive prune path uses this for a `LayersSize` before/after delta;
* the estimate path uses it for a SharedSize lookup. Null on failure lets
* each caller decide how to degrade rather than throwing mid-prune.
*/
private async safeDfSnapshot(): Promise<{
LayersSize?: number;
Images?: Array<{ Id?: string; Size?: number; VirtualSize?: number; SharedSize?: number; Containers?: number }>;
Volumes?: Array<{ Name?: string; UsageData?: { RefCount?: number; Size?: number } }>;
} | null> {
try {
return await this.docker.df();
} catch {
return null;
}
}
/**
* Volume RefCount/Size come from `docker system df`, not `GET /volumes`.
* listVolumes returns UsageData: null on real daemons, so treating a missing
* RefCount as "in use" (?? 1) would make every volume look non-prunable.
*/
private static mapVolumeUsageFromDf(
df: { Volumes?: Array<{ Name?: string; UsageData?: { RefCount?: number; Size?: number } }> } | null,
): Map<string, { refCount: number; size: number }> {
const m = new Map<string, { refCount: number; size: number }>();
if (!df?.Volumes) return m;
for (const vol of df.Volumes) {
if (!vol?.Name) continue;
const refCount = vol.UsageData?.RefCount;
// Missing RefCount is unknown usage; omit so callers treat as non-prunable.
if (typeof refCount !== 'number') continue;
m.set(vol.Name, {
refCount,
size: typeof vol.UsageData?.Size === 'number' ? vol.UsageData.Size : 0,
});
}
return m;
}
/**
* Extracts `Id -> SharedSize` from a df snapshot. Treats missing or
* negative (Docker's "unknown" sentinel) SharedSize as 0 so the caller
* counts the image's full Size as unique, an under-report rather than an
* over-report.
*/
private static mapSharedSizesFromDf(
df: { Images?: Array<{ Id?: string; SharedSize?: number }> } | null,
): Map<string, number> {
const m = new Map<string, number>();
if (!df?.Images) return m;
for (const img of df.Images) {
if (!img?.Id) continue;
const s = typeof img.SharedSize === 'number' && img.SharedSize >= 0 ? img.SharedSize : 0;
m.set(img.Id, s);
}
return m;
}
/** Unique-ish reclaim estimate for one image: Size/VirtualSize minus SharedSize. */
private static imageUniqueBytes(
img: { Id: string; Size?: number; VirtualSize?: number },
sharedSizes: Map<string, number>,
): number {
let virt = -1;
if (typeof img.VirtualSize === 'number' && img.VirtualSize >= 0) virt = img.VirtualSize;
else if (typeof img.Size === 'number' && img.Size >= 0) virt = img.Size;
if (virt < 0) return 0;
return Math.max(0, virt - (sharedSizes.get(img.Id) ?? 0));
}
public async pruneManagedOnly(
target: 'images' | 'volumes' | 'networks',
knownStackNames: string[]
): Promise<{ success: boolean; reclaimedBytes: number }> {
const knownSet = new Set(knownStackNames);
const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames);
const selfIdentity = SelfIdentityService.getInstance();
let reclaimedBytes = 0;
if (target === 'volumes') {
const rawVolumeData = await this.docker.listVolumes();
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
const volumeUsage = DockerController.mapVolumeUsageFromDf(await this.safeDfSnapshot());
const prunable = rawVolumes.filter((v: any) => {
const usage = volumeUsage.get(v.Name);
// Missing from df: unknown usage; do not prune.
if (!usage || usage.refCount !== 0) return false;
return !!DockerController.resolveProjectLabel(v.Labels?.['com.docker.compose.project'], knownSet, projectToStack)
&& !selfIdentity.isOwnVolume(v.Name);
});
// Removals are independent and Docker handles concurrent volume
// deletes; parallelize so wall time matches the slowest single
// remove rather than the sum of all of them.
await Promise.all(prunable.map(async (vol) => {
try {
await this.docker.getVolume(vol.Name).remove({ force: true });
reclaimedBytes += volumeUsage.get(vol.Name)?.size ?? 0;
} catch (e) {
console.error(`[pruneManagedOnly] Failed to remove volume ${vol.Name}:`, e);
}
}));
} else if (target === 'networks') {
const rawNetworks = await this.docker.listNetworks();
const prunable = (rawNetworks as any[]).filter((n: any) => {
return !!DockerController.resolveProjectLabel(n.Labels?.['com.docker.compose.project'], knownSet, projectToStack)
&& !selfIdentity.isOwnNetwork(n.Id)
&& !selfIdentity.isOwnNetwork(n.Name);
});
await Promise.all(prunable.map(async (net) => {
try {
await this.docker.getNetwork(net.Id).remove({ force: true });
} catch (e) {
console.error(`[pruneManagedOnly] Failed to remove network ${net.Name}:`, e);
}
}));
} else if (target === 'images') {
const allContainers = await this.docker.listContainers({ all: true });
const resolvedBase = path.resolve(COMPOSE_DIR);
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
const unmanagedImageIds = new Set<string>();
const managedImageIds = new Set<string>();
for (const c of allContainers as any[]) {
const stack = DockerController.resolveContainerStack(
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (!c.ImageID) continue;
if (stack) managedImageIds.add(c.ImageID);
else unmanagedImageIds.add(c.ImageID);
}
const rawImages = await this.docker.listImages({ all: false });
const prunable = (rawImages as any[]).filter((img: any) => {
if (img.Containers !== 0 || selfIdentity.isOwnImage(img.Id)) return false;
if (unmanagedImageIds.has(img.Id)) return false;
const labeled = DockerController.resolveProjectLabel(
img.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
);
return !!labeled || managedImageIds.has(img.Id);
});
// df-before / df-after delta is the only honest measurement of bytes
// actually freed. Per-image (Size - SharedSize) undercounts layers
// shared exclusively between prunable images (Docker frees the layer
// once, but the per-image formula subtracts it from every referrer).
const beforeDf = await this.safeDfSnapshot();
await Promise.all(prunable.map(async (img) => {
try {
await this.docker.getImage(img.Id).remove({ force: true });
} catch (e) {
console.error(`[pruneManagedOnly] Failed to remove image ${img.Id}:`, e);
}
}));
const afterDf = await this.safeDfSnapshot();
if (typeof beforeDf?.LayersSize === 'number' && typeof afterDf?.LayersSize === 'number') {
// Clamp to 0: a concurrent pull during the prune can grow on-disk
// bytes, and attributing that growth to "reclaimed" would mislead.
reclaimedBytes = Math.max(0, beforeDf.LayersSize - afterDf.LayersSize);
} else if (beforeDf) {
// After-snapshot failed but we have the before-snapshot, so fall
// back to a per-image lower bound. (We deliberately do not fall back
// on after-only: after the prune those image IDs are gone from the
// daemon's view, so a SharedSize map built from afterDf would treat
// every pruned image as having no sharing and over-report.)
console.warn('[pruneManagedOnly] docker df after-snapshot unavailable; reporting per-image lower bound');
const shared = DockerController.mapSharedSizesFromDf(beforeDf);
for (const img of prunable) {
reclaimedBytes += Math.max(0, (img.Size ?? 0) - (shared.get(img.Id) ?? 0));
}
} else {
console.warn('[pruneManagedOnly] docker df unavailable on both ends; reporting 0 reclaimed');
}
}
return { success: true, reclaimedBytes };
}
/**
* Non-destructive sibling of `pruneManagedOnly` used by the dry-run path and
* the `/api/system/prune/estimate` route. Walks the same filter rules but
* does not call `.remove()`. Kept structurally parallel to the destructive
* method so the two stay in lockstep when the enumeration logic changes.
*
* For images, the returned figure is a **conservative lower bound** on
* bytes that will actually be freed. The formula subtracts each prunable
* image's `SharedSize`, which double-counts layers shared between two
* prunable images (the layer would only be freed once on prune). The
* exact freed bytes can only be measured by the df-delta that
* `pruneManagedOnly` reports after the destructive action.
*/
public async estimateManagedReclaim(
target: 'images' | 'volumes' | 'networks',
knownStackNames: string[],
): Promise<{ reclaimableBytes: number }> {
const knownSet = new Set(knownStackNames);
const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames);
const selfIdentity = SelfIdentityService.getInstance();
let reclaimableBytes = 0;
if (target === 'volumes') {
const rawVolumeData = await this.docker.listVolumes();
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
const volumeUsage = DockerController.mapVolumeUsageFromDf(await this.safeDfSnapshot());
const prunable = rawVolumes.filter((v: any) => {
const usage = volumeUsage.get(v.Name);
if (!usage || usage.refCount !== 0) return false;
return !!DockerController.resolveProjectLabel(v.Labels?.['com.docker.compose.project'], knownSet, projectToStack)
&& !selfIdentity.isOwnVolume(v.Name);
});
for (const vol of prunable) reclaimableBytes += volumeUsage.get(vol.Name)?.size ?? 0;
} else if (target === 'networks') {
// Networks have no on-disk size; the dry-run still reports 0 so the
// shape matches the destructive path.
} else if (target === 'images') {
const allContainers = await this.docker.listContainers({ all: true });
const resolvedBase = path.resolve(COMPOSE_DIR);
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
const unmanagedImageIds = new Set<string>();
const managedImageIds = new Set<string>();
for (const c of allContainers as any[]) {
const stack = DockerController.resolveContainerStack(
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (!c.ImageID) continue;
if (stack) managedImageIds.add(c.ImageID);
else unmanagedImageIds.add(c.ImageID);
}
const rawImages = await this.docker.listImages({ all: false });
const prunable = (rawImages as any[]).filter((img: any) => {
if (img.Containers !== 0 || selfIdentity.isOwnImage(img.Id)) return false;
if (unmanagedImageIds.has(img.Id)) return false;
// Unused images with no container attribution are not Sencho-managed.
const labeled = DockerController.resolveProjectLabel(
img.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
);
return !!labeled || managedImageIds.has(img.Id);
});
const sharedSizes = DockerController.mapSharedSizesFromDf(await this.safeDfSnapshot());
for (const img of prunable) {
reclaimableBytes += Math.max(0, (img.Size ?? 0) - (sharedSizes.get(img.Id) ?? 0));
}
}
return { reclaimableBytes };
}
/**
* Non-destructive estimate for the `all` (system) prune scope. Reuses the
* same disk-usage source as the resources page so the readout matches what
* the operator already sees there.
*/
public async estimateSystemReclaim(
target: 'containers' | 'images' | 'networks' | 'volumes',
knownStackNames: string[],
): Promise<{ reclaimableBytes: number }> {
const df = await this.getDiskUsageClassified(knownStackNames);
if (target === 'images') return { reclaimableBytes: df.reclaimableImages };
if (target === 'containers') return { reclaimableBytes: df.reclaimableContainers };
if (target === 'volumes') return { reclaimableBytes: df.reclaimableVolumes };
// Networks have no on-disk size.
return { reclaimableBytes: 0 };
}
/**
* Build an itemized prune plan using the same eligibility predicates that
* `executePrunePlan` revalidates before each delete. Never calls remove APIs.
*/
public async buildPrunePlan(
targets: PruneTarget[],
scope: PruneScope,
knownStackNames: string[],
nodeId: number = this.nodeId,
): Promise<PrunePlan> {
const ordered = normalizePruneTargets(targets);
const knownSet = new Set(knownStackNames);
const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames);
const selfIdentity = SelfIdentityService.getInstance();
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
const resolvedBase = path.resolve(COMPOSE_DIR);
const allContainers = await this.docker.listContainers({ all: true, size: true }) as Array<{
Id: string;
Names?: string[];
State?: string;
Status?: string;
Image?: string;
ImageID?: string;
Labels?: Record<string, string>;
SizeRw?: number;
NetworkSettings?: { Networks?: Record<string, unknown> };
}>;
const items: PrunePlanItem[] = [];
const containerName = (c: { Id: string; Names?: string[] }) =>
(c.Names?.[0] ?? c.Id).replace(/^\//, '');
if (ordered.includes('containers')) {
for (const c of allContainers) {
if (selfIdentity.isOwnContainer(c.Id)) continue;
const state = String(c.State ?? '').toLowerCase();
if (!PRUNEABLE_CONTAINER_STATES.has(state)) continue;
if (scope === 'managed') {
const stack = DockerController.resolveContainerStack(
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (!stack) continue;
}
items.push({
target: 'containers',
id: c.Id,
name: containerName(c),
sizeBytes: typeof c.SizeRw === 'number' && c.SizeRw > 0 ? c.SizeRw : undefined,
});
}
}
// Volume usage (RefCount/Size) must come from docker.df(); listVolumes
// returns UsageData: null on real daemons.
const dfSnapshot = await this.safeDfSnapshot();
const volumeUsage = DockerController.mapVolumeUsageFromDf(dfSnapshot);
const sharedSizes = DockerController.mapSharedSizesFromDf(dfSnapshot);
if (ordered.includes('volumes')) {
const rawVolumeData = await this.docker.listVolumes();
const rawVolumes = (this.validateApiData<{ Volumes?: Array<{
Name: string;
Labels?: Record<string, string>;
}> }>(rawVolumeData)).Volumes || [];
for (const vol of rawVolumes) {
if (selfIdentity.isOwnVolume(vol.Name)) continue;
const usage = volumeUsage.get(vol.Name);
if (!usage || usage.refCount !== 0) continue;
if (scope === 'managed') {
const stack = DockerController.resolveProjectLabel(
vol.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
);
if (!stack) continue;
}
items.push({
target: 'volumes',
id: vol.Name,
name: vol.Name,
sizeBytes: usage.size > 0 ? usage.size : undefined,
});
}
}
if (ordered.includes('networks')) {
const rawNetworks = await this.docker.listNetworks() as Array<{
Id: string;
Name: string;
Labels?: Record<string, string>;
}>;
const networksInUse = new Set<string>();
for (const c of allContainers) {
const nets = c.NetworkSettings?.Networks;
if (!nets) continue;
for (const netName of Object.keys(nets)) {
networksInUse.add(netName);
}
}
for (const net of rawNetworks) {
if (DockerController.SYSTEM_NETWORKS.has(net.Name)) continue;
if (selfIdentity.isOwnNetwork(net.Id) || selfIdentity.isOwnNetwork(net.Name)) continue;
if (networksInUse.has(net.Name) || networksInUse.has(net.Id)) continue;
// Belt-and-braces: inspect when list summary did not expose attachments.
try {
const inspected = await this.docker.getNetwork(net.Id).inspect() as {
Containers?: Record<string, unknown>;
};
if (inspected.Containers && Object.keys(inspected.Containers).length > 0) continue;
} catch (e) {
console.warn(
`[buildPrunePlan] Skipping network ${sanitizeForLog(net.Name)}: inspect failed:`,
sanitizeForLog(e instanceof Error ? e.message : String(e)),
);
continue;
}
if (scope === 'managed') {
const stack = DockerController.resolveProjectLabel(
net.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
);
if (!stack) continue;
}
items.push({ target: 'networks', id: net.Id, name: net.Name });
}
}
if (ordered.includes('images')) {
const unmanagedImageIds = new Set<string>();
const managedImageIds = new Set<string>();
const imageToContainerIds = new Map<string, string[]>();
for (const c of allContainers) {
if (!c.ImageID) continue;
const refs = imageToContainerIds.get(c.ImageID) ?? [];
refs.push(c.Id);
imageToContainerIds.set(c.ImageID, refs);
const stack = DockerController.resolveContainerStack(
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (stack) managedImageIds.add(c.ImageID);
else unmanagedImageIds.add(c.ImageID);
}
const plannedContainerIds = new Set(
items.filter((i) => i.target === 'containers').map((i) => i.id),
);
const rawImages = await this.docker.listImages({ all: false }) as Array<{
Id: string;
RepoTags?: string[] | null;
Labels?: Record<string, string>;
Size?: number;
VirtualSize?: number;
Containers?: number;
}>;
// An image becomes free only when every container that references it is
// also in this plan (not merely when any planned container uses it).
const freeingImages = ordered.includes('containers');
for (const img of rawImages) {
if (selfIdentity.isOwnImage(img.Id)) continue;
const containers = img.Containers ?? 0;
const refs = imageToContainerIds.get(img.Id) ?? [];
const becomesFree = freeingImages
&& refs.length > 0
&& refs.length >= containers
&& refs.every((id) => plannedContainerIds.has(id));
if (containers > 0 && !becomesFree) continue;
if (scope === 'managed') {
if (unmanagedImageIds.has(img.Id)) continue;
const labeled = DockerController.resolveProjectLabel(
img.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
);
// Unattributed unused images (no managed container, no compose label)
// are not Sencho-managed; keep them out of managed prune.
if (!becomesFree && !labeled && !managedImageIds.has(img.Id)) continue;
}
const name = img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>'
? img.RepoTags[0]
: img.Id.slice(0, 12);
const unique = DockerController.imageUniqueBytes(img, sharedSizes);
items.push({
target: 'images',
id: img.Id,
name,
sizeBytes: unique > 0 ? unique : undefined,
});
}
}
// Preserve target execution order in the item list for stable previews.
const targetRank = new Map(ordered.map((t, i) => [t, i]));
items.sort((a, b) => {
const tr = (targetRank.get(a.target) ?? 99) - (targetRank.get(b.target) ?? 99);
if (tr !== 0) return tr;
return a.id.localeCompare(b.id);
});
const reclaimableBytes = items.reduce((acc, item) => acc + (item.sizeBytes ?? 0), 0);
const fingerprint = fingerprintPrunePlan(nodeId, scope, ordered, items);
return {
scope,
targets: ordered,
items,
reclaimableBytes,
fingerprint,
createdAt: Date.now(),
nodeId,
};
}
/**
* Rebuild the plan with the same targets/scope. Returns the fresh plan when
* the fingerprint still matches, otherwise null (caller maps to 409).
*/
public async assertPlanFresh(
plan: PrunePlan,
knownStackNames: string[],
): Promise<PrunePlan | null> {
const rebuilt = await this.buildPrunePlan(plan.targets, plan.scope, knownStackNames, plan.nodeId);
if (rebuilt.fingerprint !== plan.fingerprint) return null;
return rebuilt;
}
/**
* Execute a previously previewed plan: revalidate fingerprint, then delete
* each planned item serially with force:false. Never deletes unplanned items.
*/
public async executePrunePlan(
plan: PrunePlan,
knownStackNames: string[],
): Promise<{ outcomes: PruneItemOutcome[]; reclaimedBytes: number; success: boolean }> {
const fresh = await this.assertPlanFresh(plan, knownStackNames);
if (!fresh) throw new PrunePlanStaleError();
const knownSet = new Set(knownStackNames);
const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames);
const selfIdentity = SelfIdentityService.getInstance();
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
const resolvedBase = path.resolve(COMPOSE_DIR);
const outcomes: PruneItemOutcome[] = [];
let reclaimedBytes = 0;
/** Image IDs whose planned container removal failed or was skipped. */
const blockedImageIds = new Set<string>();
const imageIdBlocked = (imageId: string): boolean => {
for (const id of blockedImageIds) {
if (imageId === id || imageId.startsWith(id) || id.startsWith(imageId)) return true;
}
return false;
};
// Items are already sorted in dependency-safe target order by buildPrunePlan.
for (const item of fresh.items) {
const { target } = item;
try {
if (target === 'containers') {
const outcome = await this.executePlannedContainer(
item, fresh.scope, knownSet, projectToStack, absDirToStack, resolvedBase, selfIdentity,
);
if (outcome.status !== 'removed' && outcome.imageId) {
blockedImageIds.add(outcome.imageId);
}
if (outcome.status === 'removed') {
outcomes.push({
id: outcome.id,
target: 'containers',
status: 'removed',
sizeBytes: outcome.sizeBytes,
});
reclaimedBytes += outcome.sizeBytes ?? item.sizeBytes ?? 0;
} else if (outcome.status === 'skipped') {
outcomes.push({
id: outcome.id,
target: 'containers',
status: 'skipped',
reason: outcome.reason,
});
} else {
outcomes.push({
id: outcome.id,
target: 'containers',
status: 'failed',
error: outcome.error,
});
}
continue;
}
if (target === 'volumes') {
const outcome = await this.executePlannedVolume(item, fresh.scope, knownSet, projectToStack, selfIdentity);
outcomes.push(outcome);
if (outcome.status === 'removed') reclaimedBytes += outcome.sizeBytes ?? item.sizeBytes ?? 0;
continue;
}
if (target === 'networks') {
outcomes.push(await this.executePlannedNetwork(item, fresh.scope, knownSet, projectToStack, selfIdentity));
continue;
}
if (target === 'images') {
if (imageIdBlocked(item.id)) {
const stillReferenced = await this.imageStillReferenced(item.id);
if (stillReferenced) {
outcomes.push({
id: item.id,
target: 'images',
status: 'skipped',
reason: 'Still referenced after container prune skipped or failed',
});
continue;
}
}
const outcome = await this.executePlannedImage(
item, fresh.scope, knownSet, projectToStack, absDirToStack, resolvedBase, selfIdentity,
);
outcomes.push(outcome);
if (outcome.status === 'removed') reclaimedBytes += outcome.sizeBytes ?? item.sizeBytes ?? 0;
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
console.error(`[executePrunePlan] Failed ${target} ${sanitizeForLog(item.id)}:`, sanitizeForLog(message));
outcomes.push({ id: item.id, target, status: 'failed', error: message });
if (target === 'containers') {
const imageId = await this.lookupContainerImageId(item.id);
if (imageId) blockedImageIds.add(imageId);
}
}
}
const success = outcomes.every((o) => o.status !== 'failed');
return { outcomes, reclaimedBytes, success };
}
private async lookupContainerImageId(containerId: string): Promise<string | null> {
try {
const inspected = await this.docker.getContainer(containerId).inspect() as { Image?: string };
return inspected.Image ?? null;
} catch {
return null;
}
}
private async imageStillReferenced(imageId: string): Promise<boolean> {
try {
const images = await this.docker.listImages({ all: false }) as Array<{ Id: string; Containers?: number }>;
const match = images.find((img) => img.Id === imageId || img.Id.startsWith(imageId) || imageId.startsWith(img.Id));
return (match?.Containers ?? 0) > 0;
} catch {
return true;
}
}
private async executePlannedContainer(
item: PrunePlanItem,
scope: PruneScope,
knownSet: Set<string>,
projectToStack: Record<string, string>,
absDirToStack: Record<string, string>,
resolvedBase: string,
selfIdentity: SelfIdentityService,
): Promise<
| { id: string; target: 'containers'; status: 'removed'; sizeBytes?: number; imageId?: string }
| { id: string; target: 'containers'; status: 'skipped'; reason: string; imageId?: string }
| { id: string; target: 'containers'; status: 'failed'; error: string; imageId?: string }
> {
if (selfIdentity.isOwnContainer(item.id)) {
return { id: item.id, target: 'containers', status: 'skipped', reason: 'Sencho self container' };
}
let inspected: {
State?: { Status?: string };
Config?: { Labels?: Record<string, string> };
Image?: string;
};
try {
inspected = await this.docker.getContainer(item.id).inspect();
} catch {
return { id: item.id, target: 'containers', status: 'skipped', reason: 'Container no longer exists' };
}
const imageId = inspected.Image;
const state = String(inspected.State?.Status ?? '').toLowerCase();
if (!PRUNEABLE_CONTAINER_STATES.has(state)) {
return {
id: item.id,
target: 'containers',
status: 'skipped',
reason: `Container state is ${state || 'unknown'}`,
imageId,
};
}
if (scope === 'managed') {
const stack = DockerController.resolveContainerStack(
inspected.Config?.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (!stack) {
return {
id: item.id,
target: 'containers',
status: 'skipped',
reason: 'No longer a managed stack container',
imageId,
};
}
}
await this.docker.getContainer(item.id).remove({ force: false });
return { id: item.id, target: 'containers', status: 'removed', sizeBytes: item.sizeBytes, imageId };
}
private async executePlannedVolume(
item: PrunePlanItem,
scope: PruneScope,
knownSet: Set<string>,
projectToStack: Record<string, string>,
selfIdentity: SelfIdentityService,
): Promise<PruneItemOutcome> {
if (selfIdentity.isOwnVolume(item.id)) {
return { id: item.id, target: 'volumes', status: 'skipped', reason: 'Sencho self volume' };
}
const rawVolumeData = await this.docker.listVolumes();
const rawVolumes = (this.validateApiData<{ Volumes?: Array<{
Name: string;
Labels?: Record<string, string>;
}> }>(rawVolumeData)).Volumes || [];
const vol = rawVolumes.find((v) => v.Name === item.id);
if (!vol) {
return { id: item.id, target: 'volumes', status: 'skipped', reason: 'Volume no longer exists' };
}
const usage = DockerController.mapVolumeUsageFromDf(await this.safeDfSnapshot()).get(item.id);
if (!usage || usage.refCount !== 0) {
return { id: item.id, target: 'volumes', status: 'skipped', reason: 'Volume is in use' };
}
if (scope === 'managed') {
const stack = DockerController.resolveProjectLabel(
vol.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
);
if (!stack) {
return { id: item.id, target: 'volumes', status: 'skipped', reason: 'No longer a managed volume' };
}
}
await this.docker.getVolume(item.id).remove({ force: false });
return { id: item.id, target: 'volumes', status: 'removed', sizeBytes: item.sizeBytes ?? usage.size };
}
private async executePlannedNetwork(
item: PrunePlanItem,
scope: PruneScope,
knownSet: Set<string>,
projectToStack: Record<string, string>,
selfIdentity: SelfIdentityService,
): Promise<PruneItemOutcome> {
if (selfIdentity.isOwnNetwork(item.id)) {
return { id: item.id, target: 'networks', status: 'skipped', reason: 'Sencho self network' };
}
let inspected: {
Name?: string;
Labels?: Record<string, string>;
Containers?: Record<string, unknown>;
};
try {
inspected = await this.docker.getNetwork(item.id).inspect();
} catch {
return { id: item.id, target: 'networks', status: 'skipped', reason: 'Network no longer exists' };
}
if (DockerController.SYSTEM_NETWORKS.has(inspected.Name ?? '')) {
return { id: item.id, target: 'networks', status: 'skipped', reason: 'System network' };
}
if (inspected.Containers && Object.keys(inspected.Containers).length > 0) {
return { id: item.id, target: 'networks', status: 'skipped', reason: 'Network is in use' };
}
if (scope === 'managed') {
const stack = DockerController.resolveProjectLabel(
inspected.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
);
if (!stack) {
return { id: item.id, target: 'networks', status: 'skipped', reason: 'No longer a managed network' };
}
}
await this.docker.getNetwork(item.id).remove();
return { id: item.id, target: 'networks', status: 'removed' };
}
private async executePlannedImage(
item: PrunePlanItem,
scope: PruneScope,
knownSet: Set<string>,
projectToStack: Record<string, string>,
absDirToStack: Record<string, string>,
resolvedBase: string,
selfIdentity: SelfIdentityService,
): Promise<PruneItemOutcome> {
if (selfIdentity.isOwnImage(item.id)) {
return { id: item.id, target: 'images', status: 'skipped', reason: 'Sencho self image' };
}
const rawImages = await this.docker.listImages({ all: false }) as Array<{
Id: string;
Size?: number;
Containers?: number;
}>;
const img = rawImages.find((i) => i.Id === item.id || i.Id.startsWith(item.id) || item.id.startsWith(i.Id));
if (!img) {
return { id: item.id, target: 'images', status: 'skipped', reason: 'Image no longer exists' };
}
if ((img.Containers ?? 0) > 0) {
return { id: item.id, target: 'images', status: 'skipped', reason: 'Image still has container references' };
}
if (scope === 'managed') {
const allContainers = await this.docker.listContainers({ all: true }) as Array<{
ImageID?: string;
Labels?: Record<string, string>;
}>;
for (const c of allContainers) {
if (c.ImageID !== img.Id) continue;
const stack = DockerController.resolveContainerStack(
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (!stack) {
return { id: item.id, target: 'images', status: 'skipped', reason: 'Image referenced by unmanaged container' };
}
}
}
await this.docker.getImage(item.id).remove({ force: false });
// Prefer plan unique-bytes; do not fall back to full Size (shared layers).
return { id: item.id, target: 'images', status: 'removed', sizeBytes: item.sizeBytes ?? 0 };
}
public async getDiskUsageClassified(knownStackNames: string[]): Promise<{
reclaimableImages: number;
reclaimableContainers: number;
reclaimableVolumes: number;
reclaimableBuildCache: number;
reclaimableImageCount: number;
reclaimableContainerCount: number;
reclaimableVolumeCount: number;
reclaimableBuildCacheCount: number;
managedImageBytes: number;
unmanagedImageBytes: number;
managedVolumeBytes: number;
unmanagedVolumeBytes: number;
}> {
const [base, classified] = await Promise.all([
this.getDiskUsage(),
this.getClassifiedResources(knownStackNames),
]);
const managedImageBytes = classified.images
.filter(i => i.managedStatus === 'managed')
.reduce((acc, i) => acc + i.Size, 0);
const unmanagedImageBytes = classified.images
.filter(i => i.managedStatus === 'unmanaged')
.reduce((acc, i) => acc + i.Size, 0);
// Volume disk usage: query raw volumes for UsageData (not available in classified resources)
const rawVolumeData = await this.docker.listVolumes();
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames);
const knownSet = new Set(knownStackNames);
const isVolumeManaged = (v: any): boolean =>
!!DockerController.resolveProjectLabel(v.Labels?.['com.docker.compose.project'], knownSet, projectToStack);
const managedVolumeBytes = rawVolumes
.filter(isVolumeManaged)
.reduce((acc: number, v: any) => acc + (v.UsageData?.Size ?? 0), 0);
const unmanagedVolumeBytes = rawVolumes
.filter((v: any) => !isVolumeManaged(v))
.reduce((acc: number, v: any) => acc + (v.UsageData?.Size ?? 0), 0);
return { ...base, managedImageBytes, unmanagedImageBytes, managedVolumeBytes, unmanagedVolumeBytes };
}
public async removeImage(id: string) {
const image = this.docker.getImage(id);
await image.remove({ force: true });
}
public async inspectImage(id: string) {
const image = this.docker.getImage(id);
const [inspect, history] = await Promise.all([image.inspect(), image.history()]);
return { inspect, history };
}
public async removeVolume(name: string) {
const volume = this.docker.getVolume(name);
await volume.remove({ force: true });
}
public async removeNetwork(id: string) {
const network = this.docker.getNetwork(id);
await network.remove({ force: true });
}
public async inspectNetwork(id: string) {
const network = this.docker.getNetwork(id);
return await network.inspect();
}
public async createNetwork(options: CreateNetworkOptions) {
if (!options.Name || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(options.Name)) {
throw new Error('Invalid network name. Use alphanumeric characters, hyphens, underscores, and dots.');
}
return await this.docker.createNetwork(options);
}
/**
* Attach a container to a Docker network. Idempotent: if the container is
* already attached the call resolves silently. Optionally pins the
* container's IPv4 address inside the network so other services can use
* static `extra_hosts` entries against it.
*/
public async connectContainerToNetwork(
networkName: string,
containerId: string,
opts: { ipv4Address?: string } = {},
): Promise<void> {
const network = this.docker.getNetwork(networkName);
const payload: { Container: string; EndpointConfig?: { IPAMConfig?: { IPv4Address: string } } } = {
Container: containerId,
};
if (opts.ipv4Address) {
payload.EndpointConfig = { IPAMConfig: { IPv4Address: opts.ipv4Address } };
}
try {
await network.connect(payload);
} catch (err) {
if (DockerController.isAlreadyConnectedError(err)) return;
throw err;
}
}
/**
* Detach a container from a Docker network. Idempotent: if the container
* is not attached the call resolves silently.
*/
public async disconnectContainerFromNetwork(
networkName: string,
containerId: string,
): Promise<void> {
const network = this.docker.getNetwork(networkName);
try {
await network.disconnect({ Container: containerId, Force: true });
} catch (err) {
if (DockerController.isNotConnectedError(err)) return;
throw err;
}
}
private static isAlreadyConnectedError(err: unknown): boolean {
const e = err as { statusCode?: number; message?: string };
const msg = (e?.message || '').toLowerCase();
// Docker daemon returns 403 for several distinct cases (already
// attached, host-network containers, permission denied), so match the
// message body too rather than treating any 403 as idempotent success.
if (e?.statusCode === 403 && (msg.includes('already exists') || msg.includes('already attached'))) {
return true;
}
return msg.includes('already exists') || msg.includes('already attached');
}
private static isNotConnectedError(err: unknown): boolean {
const e = err as { statusCode?: number; message?: string };
if (e?.statusCode === 404) return true;
const msg = (e?.message || '').toLowerCase();
return msg.includes('is not connected') || msg.includes('no such container');
}
public async getRunningContainers() {
const containers = await this.docker.listContainers({ all: false });
return this.validateApiData<any[]>(containers);
}
public async getAllContainers() {
const containers = await this.docker.listContainers({ all: true });
return this.validateApiData<any[]>(containers);
}
/** Runtime labels + image ref from container inspect. Null (logged) when inspect fails. */
public async inspectContainerLabelsAndImage(
containerId: string,
): Promise<{ labels: Record<string, string>; imageId: string } | null> {
try {
const info = await this.docker.getContainer(containerId).inspect();
return { labels: info.Config?.Labels ?? {}, imageId: info.Image ?? '' };
} catch (err) {
console.error('[DockerController] Container inspect failed for %s:', sanitizeForLog(containerId), err);
return null;
}
}
/** Image-level labels for label provenance. Null (logged) when the image cannot be inspected. */
public async inspectImageLabels(imageId: string): Promise<{ labels: Record<string, string> } | null> {
if (!imageId) return null;
try {
const info = await this.docker.getImage(imageId).inspect();
return { labels: info.Config?.Labels ?? {} };
} catch (err) {
console.error('[DockerController] Image inspect failed for %s:', sanitizeForLog(imageId), err);
return null;
}
}
/**
* Containers with runtime labels for the label-inventory API. Resolves stack
* membership with the same multi-fallback strategy as bulk status. Captures the
* image ref so label provenance can distinguish image-inherited labels.
*/
public async listContainersForLabelInventory(): Promise<LabelInventoryRow[]> {
const knownStacks = await FileSystemService.getInstance(this.nodeId).getStacks();
const listed = await this.getAllContainers() as Array<{
Id?: string;
Names?: string[];
State?: string;
Labels?: Record<string, string>;
ImageID?: string;
}>;
const projectToStack = await DockerController.resolveProjectNameMap(knownStacks);
const absDirToStack = DockerController.buildAbsDirMap(knownStacks);
const resolvedBase = path.resolve(COMPOSE_DIR);
const knownStackSet = new Set(knownStacks);
const CONCURRENCY = 8;
const results: LabelInventoryRow[] = new Array(listed.length);
let index = 0;
const worker = async () => {
while (index < listed.length) {
const i = index++;
const c = listed[i];
const id = c.Id ?? '';
const name = (c.Names?.[0] ?? '').replace(/^\//, '');
const state = c.State ?? 'unknown';
const stack = DockerController.resolveContainerStack(
c.Labels,
projectToStack,
knownStackSet,
absDirToStack,
resolvedBase,
);
const service = c.Labels?.['com.docker.compose.service'] ?? null;
if (!id) {
results[i] = { id, name, state, stack, service, labels: {}, imageId: c.ImageID ?? '', inspectFailed: true };
continue;
}
let labels: Record<string, string>;
let imageId: string;
let inspectFailed = false;
try {
const info = await this.docker.getContainer(id).inspect();
labels = info.Config?.Labels ?? {};
imageId = info.Image ?? '';
} catch (err) {
console.error('[DockerController] Container inspect failed for %s:', sanitizeForLog(id), err);
labels = c.Labels ?? {};
imageId = c.ImageID ?? '';
inspectFailed = true;
}
results[i] = { id, name, state, stack, service, labels, imageId, inspectFailed };
}
};
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, listed.length) }, worker));
return results;
}
/** Resolve a container by its durable name (not ephemeral ID). */
public async findContainerByName(name: string): Promise<{
id: string;
name: string;
state: string;
image: string;
stackProject: string | null;
} | null> {
const normalized = name.replace(/^\//, '');
const containers = await this.getAllContainers();
for (const c of containers) {
const containerName = c.Names?.[0]?.replace(/^\//, '');
if (containerName === normalized) {
return {
id: c.Id,
name: containerName,
state: c.State ?? 'unknown',
image: c.Image ?? '',
stackProject: c.Labels?.['com.docker.compose.project'] ?? null,
};
}
}
return null;
}
/**
* Builds topology data with 2 Docker API calls instead of N+1.
* Fetches all networks + all containers in parallel, then maps
* container-to-network relationships in memory using NetworkSettings.
*/
public async getTopologyData(
knownStackNames: string[],
includeSystem: boolean,
): Promise<TopologyNetwork[]> {
const debug = isDebugEnabled();
const t0 = debug ? Date.now() : 0;
const knownSet = new Set(knownStackNames);
const [rawNetworks, rawContainers, projectToStack] = await Promise.all([
this.docker.listNetworks(),
this.docker.listContainers({ all: true }),
DockerController.resolveProjectNameMap(knownStackNames),
]);
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
const resolvedBase = path.resolve(COMPOSE_DIR);
const networks = this.validateApiData<any[]>(rawNetworks);
const containers = this.validateApiData<any[]>(rawContainers);
// Build network map, optionally filtering system networks
const networkMap = new Map<string, TopologyNetwork>();
for (const net of networks) {
const isSystem = DockerController.SYSTEM_NETWORKS.has(net.Name);
if (isSystem && !includeSystem) continue;
const stack = isSystem
? null
: DockerController.resolveProjectLabel(
net.Labels?.['com.docker.compose.project'],
knownSet,
projectToStack,
);
const managedStatus: TopologyNetwork['managedStatus'] = isSystem
? 'system'
: stack ? 'managed' : 'unmanaged';
networkMap.set(net.Id, {
Id: net.Id,
Name: net.Name,
Driver: net.Driver ?? 'bridge',
Scope: net.Scope ?? 'local',
managedBy: stack,
managedStatus,
containers: [],
});
}
// Map containers to their networks via NetworkSettings.
// Stack resolution is deferred until a network match is found to avoid
// wasted work for containers not attached to any tracked network.
for (const c of containers) {
const netSettings: Record<string, { NetworkID?: string; IPAddress?: string }> =
c.NetworkSettings?.Networks ?? {};
let containerStack: string | null | undefined;
let stackResolved = false;
for (const [, netInfo] of Object.entries(netSettings)) {
const netId = netInfo.NetworkID;
if (!netId) continue;
const topology = networkMap.get(netId);
if (!topology) continue;
if (!stackResolved) {
containerStack = DockerController.resolveContainerStack(
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
stackResolved = true;
}
topology.containers.push({
id: c.Id,
name: (c.Names?.[0] ?? '').replace(/^\//, '') || (c.Id ?? '').substring(0, 12),
ip: netInfo.IPAddress ?? '',
state: c.State ?? 'unknown',
image: c.Image ?? '',
stack: containerStack ?? null,
});
}
}
const result = Array.from(networkMap.values());
if (debug) {
const totalContainers = result.reduce((sum, n) => sum + n.containers.length, 0);
console.debug('[Resources:debug] Topology built', {
ms: Date.now() - t0,
networks: result.length,
containers: totalContainers,
systemFiltered: !includeSystem,
stacksKnown: knownStackNames.length,
});
}
return result;
}
/**
* One-shot, node-scoped snapshot for the dependency map: every container's
* compose service identity, real network/volume names, and protocol/IP-typed
* published ports, plus the full network and volume inventory. Three Docker
* list calls and no per-container inspect keep it cheap at fleet scale.
*/
public async getDependencySnapshot(knownStackNames: string[]): Promise<DependencySnapshot> {
const knownSet = new Set(knownStackNames);
const [rawContainers, rawNetworks, rawVolumeData, projectToStack] = await Promise.all([
this.docker.listContainers({ all: true }),
this.docker.listNetworks(),
this.docker.listVolumes(),
DockerController.resolveProjectNameMap(knownStackNames),
]);
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
const resolvedBase = path.resolve(COMPOSE_DIR);
const containersRaw = this.validateApiData<any[]>(rawContainers);
const networksRaw = this.validateApiData<any[]>(rawNetworks);
const volumesRaw: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
const containers: DependencyContainer[] = containersRaw.map((c: any) => {
const netSettings: Record<string, { NetworkID?: string; IPAddress?: string }> =
c.NetworkSettings?.Networks ?? {};
const networks = Object.entries(netSettings).map(([name, info]) => ({
name,
id: info.NetworkID ?? '',
ip: info.IPAddress ?? '',
}));
const volumes: string[] = Array.isArray(c.Mounts)
? c.Mounts
.filter((m: any) => m?.Type === 'volume' && typeof m.Name === 'string' && m.Name)
.map((m: any) => m.Name as string)
: [];
const ports: DependencyPort[] = Array.isArray(c.Ports)
? c.Ports
.filter((p: any) => typeof p.PublicPort === 'number' && p.PublicPort > 0)
.map((p: any) => ({
ip: typeof p.IP === 'string' ? p.IP : '',
publishedPort: p.PublicPort as number,
privatePort: typeof p.PrivatePort === 'number' ? p.PrivatePort : null,
protocol: typeof p.Type === 'string' ? p.Type : 'tcp',
}))
: [];
return {
id: c.Id,
name: (c.Names?.[0] ?? '').replace(/^\//, '') || (c.Id ?? '').substring(0, 12),
service: c.Labels?.['com.docker.compose.service'] ?? null,
composeProject: c.Labels?.['com.docker.compose.project'] ?? null,
stack: DockerController.resolveContainerStack(c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase),
state: c.State ?? 'unknown',
image: c.Image ?? '',
networks,
volumes,
ports,
};
});
const networks: DependencyNetwork[] = networksRaw.map((net: any) => {
const project = net.Labels?.['com.docker.compose.project'] ?? null;
const ingress = net.Ingress === true;
return {
id: net.Id,
name: net.Name,
driver: net.Driver ?? 'bridge',
scope: net.Scope ?? 'local',
isSystem: DockerController.SYSTEM_NETWORKS.has(net.Name) || ingress,
ingress,
...(typeof net.EnableIPv6 === 'boolean' ? { enableIPv6: net.EnableIPv6 } : {}),
composeProject: project,
stack: DockerController.resolveProjectLabel(project ?? undefined, knownSet, projectToStack),
};
});
const volumes: DependencyVolume[] = volumesRaw.map((vol: any) => {
const project = vol.Labels?.['com.docker.compose.project'] ?? null;
return {
name: vol.Name,
driver: vol.Driver ?? 'local',
composeProject: project,
stack: DockerController.resolveProjectLabel(project ?? undefined, knownSet, projectToStack),
};
});
return { containers, networks, volumes };
}
/**
* Classifies snapshot networks into base inventory rows (phase A). Uses only
* the provided snapshot; no additional Docker API calls.
*/
public static classifySnapshotNetworks(
snapshot: DependencySnapshot,
_knownStackNames: string[],
): NetworkingNetworkBase[] {
const selfIdentity = SelfIdentityService.getInstance();
const connectedByNetwork = new Map<string, number>();
for (const c of snapshot.containers) {
for (const attached of c.networks) {
const key = attached.id || attached.name;
connectedByNetwork.set(key, (connectedByNetwork.get(key) ?? 0) + 1);
if (attached.name && attached.name !== key) {
connectedByNetwork.set(attached.name, (connectedByNetwork.get(attached.name) ?? 0) + 1);
}
}
}
return snapshot.networks.map(net => ({
id: net.id,
name: net.name,
driver: net.driver,
scope: net.scope,
isSystem: net.isSystem,
ingress: net.ingress === true,
enableIPv6: net.enableIPv6,
composeProject: net.composeProject,
stack: net.stack,
connectedCount: connectedByNetwork.get(net.id) ?? connectedByNetwork.get(net.name) ?? 0,
isSencho: selfIdentity.isOwnNetwork(net.id) || selfIdentity.isOwnNetwork(net.name),
ownership: net.isSystem || net.ingress === true
? 'system'
: net.stack
? 'sencho-managed'
: net.composeProject
? 'compose-managed'
: 'unmanaged',
declaredByStacks: [],
declaredExternalByStacks: [],
isExternalDependency: false,
}));
}
/** Resolves a Docker Compose project label to a known Sencho stack name, or null. */
private static resolveProjectLabel(
project: string | undefined,
knownSet: Set<string>,
projectToStack: Record<string, string>,
): string | null {
if (!project) return null;
if (knownSet.has(project)) return project;
if (projectToStack[project]) return projectToStack[project];
return null;
}
/** Builds a map from absolute stack directory paths to stack names. */
private static buildAbsDirMap(stackNames: string[]): Record<string, string> {
const map: Record<string, string> = {};
for (const stackDir of stackNames) {
map[path.join(COMPOSE_DIR, stackDir)] = stackDir;
}
return map;
}
/**
* Resolves which Sencho stack a container belongs to using a multi-fallback strategy.
* Handles containers whose labels predate Sencho's reorganization of compose files into subdirectories.
*/
private static resolveContainerStack(
containerLabels: Record<string, string> | undefined,
projectToStack: Record<string, string>,
knownStackSet: Set<string>,
absDirToStack: Record<string, string>,
resolvedBase: string,
): string | null {
if (!containerLabels) return null;
// Primary: match by project name (handles name: overrides and standard directory-based names)
const project = containerLabels['com.docker.compose.project'];
if (project && projectToStack[project]) return projectToStack[project];
// Fallback 1: match by working_dir
const workingDir = containerLabels['com.docker.compose.project.working_dir'];
if (workingDir) {
const match = absDirToStack[workingDir] ?? absDirToStack[path.resolve(workingDir)];
if (match) return match;
}
// Fallback 2: match by service name
const serviceName = containerLabels['com.docker.compose.service'];
if (serviceName && knownStackSet.has(serviceName)) return serviceName;
// Fallback 3: extract stack from config_files path
const configFiles = containerLabels['com.docker.compose.project.config_files'];
if (configFiles) {
const firstFile = configFiles.split(',')[0].trim();
const resolvedFile = path.resolve(firstFile);
if (isPathWithinBase(resolvedFile, resolvedBase)) {
const relative = resolvedFile.slice(resolvedBase.length + 1);
const firstSegment = relative.split(path.sep)[0].replace(/\.(ya?ml)$/, '');
if (knownStackSet.has(firstSegment)) return firstSegment;
}
}
return null;
}
/**
* Builds (or returns cached) mapping from Docker project name to Sencho stack directory name.
* Compose files with a top-level `name:` field override the default project name.
*/
private static async resolveProjectNameMap(stackNames: string[]): Promise<Record<string, string>> {
return CacheService.getInstance().getOrFetch(
PROJECT_NAME_CACHE_KEY,
PROJECT_NAME_CACHE_TTL_MS,
async () => {
const map: Record<string, string> = {};
await Promise.all(stackNames.map(async (stackDir) => {
map[stackDir] = stackDir;
for (const fileName of COMPOSE_FILE_NAMES) {
const filePath = path.join(COMPOSE_DIR, stackDir, fileName);
try {
const content = await fs.readFile(filePath, 'utf-8');
const parsed = yaml.parse(content);
if (parsed?.name && typeof parsed.name === 'string') {
map[parsed.name] = stackDir;
}
break;
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
console.error('[DockerController] Failed to read %s:', sanitizeForLog(filePath), sanitizeForLog((err as Error)?.message ?? String(err)));
break;
}
}
}
}));
return map;
},
);
}
public async getBulkStackStatuses(stackNames: string[]): Promise<Record<string, BulkStackInfo>> {
// Run Docker API call and project name resolution in parallel
const [allContainers, projectToStack] = await Promise.all([
this.docker.listContainers({ all: true }),
DockerController.resolveProjectNameMap(stackNames),
]);
const absDirToStack = DockerController.buildAbsDirMap(stackNames);
const knownStackSet = new Set(stackNames);
const resolvedBase = path.resolve(COMPOSE_DIR);
const result: Record<string, BulkStackInfo> = {};
for (const name of stackNames) {
result[name] = { status: 'unknown' };
}
// Per stack, collect running container ids plus the oldest Created as a
// fallback. Uptime is resolved from StartedAt after the loop; Created is
// only used if an inspect fails, since it never moves on restart.
const runningByStack: Record<string, { ids: string[]; oldestCreated?: number }> = {};
// Per stack, tally running, genuinely-failed (crashed), and total containers
// so the status can distinguish a fully-up stack from one that is partially
// degraded (some running, some crashed).
const countsByStack: Record<string, { running: number; failed: number; total: number }> = {};
for (const name of stackNames) {
countsByStack[name] = { running: 0, failed: 0, total: 0 };
}
for (const container of allContainers as any[]) {
const stackDir = DockerController.resolveContainerStack(
container.Labels, projectToStack, knownStackSet, absDirToStack, resolvedBase,
);
if (!stackDir || !result[stackDir]) continue;
const counts = countsByStack[stackDir];
counts.total += 1;
if (container.State === 'running') {
counts.running += 1;
const acc = (runningByStack[stackDir] ??= { ids: [] });
if (typeof container.Id === 'string') acc.ids.push(container.Id);
const created = typeof container.Created === 'number' ? container.Created : undefined;
if (created !== undefined && (acc.oldestCreated === undefined || created < acc.oldestCreated)) {
acc.oldestCreated = created;
}
// Detect main web port (first running container with a matchable port wins)
if (result[stackDir].mainPort === undefined && Array.isArray(container.Ports) && container.Ports.length > 0) {
const mainPort = selectMainWebPort(
container.Ports as { PrivatePort?: number; PublicPort?: number; Type?: string }[],
);
if (mainPort) result[stackDir].mainPort = mainPort;
}
} else if (isContainerFailed(container.State, container.Status)) {
counts.failed += 1;
}
}
// Classify each stack from its tallies. "partial" requires at least one
// running and at least one crashed container, so a stack with a cleanly
// finished one-shot container (exit 0) stays "running". A stack with no
// containers keeps its seeded "unknown".
for (const name of stackNames) {
const { running, failed, total } = countsByStack[name];
if (total === 0) continue;
if (running === 0) result[name].status = 'exited';
else if (failed > 0) result[name].status = 'partial';
else result[name].status = 'running';
result[name].running = running;
result[name].total = total;
}
// Resolve real uptime: oldest StartedAt across each stack's running
// containers, falling back to the oldest Created when inspect is unavailable.
const allRunningIds = Object.values(runningByStack).flatMap(s => s.ids);
const startedAts = await this.getRunningStartedAts(allRunningIds);
for (const [stackDir, acc] of Object.entries(runningByStack)) {
let oldest: number | undefined;
for (const id of acc.ids) {
const started = startedAts.get(id);
if (started !== undefined && (oldest === undefined || started < oldest)) {
oldest = started;
}
}
result[stackDir].runningSince = oldest ?? acc.oldestCreated;
}
return result;
}
/**
* Resolve each container's last start time (unix seconds) from State.StartedAt,
* which Docker exposes only via inspect() (listContainers carries Created, which
* does not move on restart). Results are cached briefly per node+container so
* steady-state status polls avoid re-inspecting. A container whose inspect fails
* (e.g. it vanished between listing and inspect) is omitted; callers fall back to
* Created for those rather than failing the whole batch.
*/
private async getRunningStartedAts(containerIds: string[]): Promise<Map<string, number>> {
const now = Date.now();
const cache = DockerController.startedAtCache;
const out = new Map<string, number>();
const misses: string[] = [];
for (const id of containerIds) {
const cached = cache.get(`${this.nodeId}:${id}`);
if (cached && now - cached.cachedAtMs < STARTED_AT_CACHE_TTL_MS) {
out.set(id, cached.startedAtSeconds);
} else {
misses.push(id);
}
}
for (let i = 0; i < misses.length; i += STARTED_AT_INSPECT_CONCURRENCY) {
const chunk = misses.slice(i, i + STARTED_AT_INSPECT_CONCURRENCY);
await Promise.all(chunk.map(async (id) => {
try {
const info = await this.docker.getContainer(id).inspect();
const startedAt = info.State?.StartedAt;
const seconds = startedAt ? Math.floor(Date.parse(startedAt) / 1000) : NaN;
// Skip the Docker zero-time (0001-01-01...) and unparseable values.
if (Number.isFinite(seconds) && seconds > 0) {
cache.set(`${this.nodeId}:${id}`, { startedAtSeconds: seconds, cachedAtMs: now });
out.set(id, seconds);
}
} catch (err: unknown) {
console.warn('[DockerController] StartedAt inspect failed for %s: %s', sanitizeForLog(id), sanitizeForLog((err as Error)?.message ?? String(err)));
}
}));
}
// Bound the cache: drop entries past their TTL so removed containers that
// will never be queried again do not accumulate.
for (const [key, entry] of cache) {
if (now - entry.cachedAtMs >= STARTED_AT_CACHE_TTL_MS) cache.delete(key);
}
return out;
}
/**
* Returns a map of host ports currently bound by running containers,
* with ownership info (Sencho-managed stack name or external).
*/
public async getPortsInUse(knownStackNames: string[]): Promise<Record<number, PortInUseInfo>> {
const [allContainers, projectToStack] = await Promise.all([
this.docker.listContainers({ all: false }),
DockerController.resolveProjectNameMap(knownStackNames),
]);
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
const knownStackSet = new Set(knownStackNames);
const resolvedBase = path.resolve(COMPOSE_DIR);
const result: Record<number, PortInUseInfo> = {};
for (const container of allContainers as Array<{ Names?: string[]; Labels?: Record<string, string>; Ports?: Array<{ PublicPort?: number }> }>) {
const stackDir = DockerController.resolveContainerStack(
container.Labels, projectToStack, knownStackSet, absDirToStack, resolvedBase,
);
const containerName = (container.Names?.[0] || '').replace(/^\//, '');
if (!Array.isArray(container.Ports)) continue;
for (const port of container.Ports) {
if (!port.PublicPort || port.PublicPort <= 0) continue;
// First container to claim a port wins (avoids overwrites)
if (result[port.PublicPort]) continue;
result[port.PublicPort] = { stack: stackDir, container: containerName };
}
}
return result;
}
/**
* Containers visible to `docker compose ps` for this stack. Empty when Compose
* does not manage any containers (including first deploy or mislabeled legacy).
*/
private async fetchComposePsContainers(stackName: string, stackDir: string): Promise<ComposePsContainer[]> {
const filePrefix = authoredComposeFileArgs(stackName, this.nodeId);
const envFileArgs = await authoredComposeEnvFileArgs(stackName, this.nodeId);
const { stdout, stderr } = await execFileAsync(
'docker',
['compose', ...filePrefix, ...envFileArgs, 'ps', '--format', 'json', '-a'],
{
cwd: stackDir,
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
}
);
let containers: ComposePsContainer[] = [];
if (stdout && stdout.trim() !== '') {
try {
const parsed = JSON.parse(stdout);
containers = Array.isArray(parsed) ? parsed : [parsed];
} catch (parseError) {
try {
const lines = stdout.trim().split('\n').filter(line => line.trim() !== '');
containers = lines.map(line => JSON.parse(line) as ComposePsContainer);
} catch (innerError) {
console.error('Docker Compose JSON Parse Error for %s:', sanitizeForLog(stackName), sanitizeForLog(stderr || (parseError as Error).message));
}
}
}
return containers;
}
/**
* Legacy orphan containers that Compose ps cannot see but would block a deploy
* (wrong project labels). When Compose already manages the stack, returns [] so
* deploy can rely on selective `compose up` recreation.
*/
public async getLegacyOrphanContainersByStack(stackName: string): Promise<Array<{ Id: string }>> {
const stackDir = path.join(NodeRegistry.getInstance().getComposeDir(this.nodeId), stackName);
const toIds = (list: Array<{ Id?: string }>) =>
list.filter((c): c is { Id: string } => typeof c.Id === 'string' && c.Id.length > 0)
.map((c) => ({ Id: c.Id }));
try {
const composeContainers = await this.fetchComposePsContainers(stackName, stackDir);
if (composeContainers.length > 0) return [];
return toIds(await this.smartFallback(stackName, stackDir));
} catch (error) {
const execError = error as NodeJS.ErrnoException & { stderr?: string };
const mapped = describeSpawnError(execError, { command: 'docker compose ps' });
const detail = execError.stderr || mapped.message;
console.error('Docker Compose Error for %s:', sanitizeForLog(stackName), sanitizeForLog(detail));
try {
return toIds(await this.smartFallback(stackName, stackDir));
} catch {
return [];
}
}
}
public async getContainersByStack(stackName: string) {
// Resolve the compose dir and the authored prefix for THIS controller's node,
// not the process default, so a non-default local node sees its own stack dir
// and deploy spec.
const stackDir = path.join(NodeRegistry.getInstance().getComposeDir(this.nodeId), stackName);
try {
const containers = await this.fetchComposePsContainers(stackName, stackDir);
// If containers found via docker compose ps, return them
if (containers.length > 0) {
// Map to frontend's expected interface
// Note: docker compose ps returns Name (singular), but frontend expects Names (array)
// Dockerode returns Names with leading slash, so we add it for compatibility
const mapped = containers.map((c) => {
let Ports: { PrivatePort: number, PublicPort: number, Type?: string }[] = [];
if (c.Publishers && Array.isArray(c.Publishers)) {
Ports = c.Publishers
.filter(p => typeof p.PublishedPort === 'number' && p.PublishedPort > 0)
.map(p => ({ PrivatePort: (p.TargetPort || 0) as number, PublicPort: p.PublishedPort as number, Type: p.Protocol?.toLowerCase() }));
}
return {
Id: c.ID || '',
Names: ['/' + (c.Name || '')], // Add leading slash to match Dockerode format
Service: c.Service || '',
State: c.State || 'unknown',
Status: c.Status || '',
Ports
};
});
return await this.enrichContainers(mapped);
}
// SMART FALLBACK: Trigger when docker compose ps returns empty
// This handles legacy containers with incorrect project labels
return await this.enrichContainers(await this.smartFallback(stackName, stackDir));
} catch (error) {
// If command fails (e.g., stack not deployed, invalid YAML, missing env_file,
// or host under memory pressure causing posix_spawn to fail with ENOMEM,
// which Linux libuv can surface as ENOENT).
const execError = error as NodeJS.ErrnoException & { stderr?: string };
const mapped = describeSpawnError(execError, { command: 'docker compose ps' });
const detail = execError.stderr || mapped.message;
console.error('Docker Compose Error for %s:', sanitizeForLog(stackName), sanitizeForLog(detail));
// Try smart fallback even on error
return await this.enrichContainers(await this.smartFallback(stackName, stackDir));
}
}
/**
* Inspect each container to attach healthcheck status, image tag, and image digest.
* Each mapper catches its own errors, so Promise.all never rejects (allSettled adds ceremony with no behavior change).
*/
private async enrichContainers<T extends { Id?: string }>(list: T[]): Promise<Array<T & { healthStatus: 'healthy' | 'unhealthy' | 'starting' | 'none'; Image?: string; ImageID?: string }>> {
return Promise.all(list.map(async (c) => {
const base = { ...c, healthStatus: 'none' as const };
if (!c.Id) return base;
try {
const info = await this.docker.getContainer(c.Id).inspect();
const health = info.State?.Health?.Status;
const healthStatus: 'healthy' | 'unhealthy' | 'starting' | 'none' =
health === 'healthy' || health === 'unhealthy' || health === 'starting' ? health : 'none';
return { ...c, healthStatus, Image: info.Config?.Image, ImageID: info.Image };
} catch {
return base;
}
}));
}
/**
* Smart Fallback: Find legacy containers by parsing compose YAML definitions.
* This handles containers that were deployed with incorrect project labels
* that cause `docker compose ps` to ignore them.
*/
private async smartFallback(stackName: string, stackDir: string): Promise<any[]> {
try {
// 1. Flexible Compose File Discovery
// Try multiple valid compose file names
const composeFileNames = COMPOSE_FILE_NAMES;
let yamlContent: string | null = null;
for (const fileName of composeFileNames) {
try {
yamlContent = await fs.readFile(path.join(stackDir, fileName), 'utf-8');
break; // Successfully read a file, stop trying
} catch {
// File doesn't exist, try next
continue;
}
}
if (!yamlContent) {
// No compose file found
return [];
}
const parsedYaml = yaml.parse(yamlContent);
if (!parsedYaml || !parsedYaml.services) return [];
// 2. Extract expected container names with legacy prefix support
const expectedNames: string[] = [];
const nameToService = new Map<string, string>();
for (const [serviceName, serviceConfig] of Object.entries(parsedYaml.services)) {
const config = serviceConfig as { container_name?: string };
nameToService.set(serviceName, serviceName);
if (config.container_name) {
expectedNames.push(config.container_name);
nameToService.set(config.container_name, serviceName);
} else {
// Standard v2 naming
expectedNames.push(serviceName);
expectedNames.push(`${stackName}-${serviceName}-1`);
// Legacy project prefix catch - accounts for orphan containers
expectedNames.push(`compose-${serviceName}-1`);
expectedNames.push(`compose_${serviceName}_1`);
}
}
// 3. Query the raw Docker daemon
const allContainers = await this.docker.listContainers({ all: true });
// 4. Match containers by name
const fallbackContainers = allContainers.filter(container => {
// container.Names usually looks like ['/plex']
return container.Names.some(name => {
const strippedName = name.replace(/^\//, '');
return expectedNames.includes(strippedName);
});
});
// 5. Map to the frontend interface
return fallbackContainers.map(c => {
const strippedName = c.Names?.[0]?.replace(/^\//, '') ?? '';
const labelService = c.Labels?.['com.docker.compose.service'];
const service = (typeof labelService === 'string' && labelService.length > 0
? labelService
: nameToService.get(strippedName)) ?? '';
let Ports: { PrivatePort: number, PublicPort: number, Type?: string }[] = [];
if (c.Ports && Array.isArray(c.Ports)) {
Ports = c.Ports
.filter((p: any) => typeof p.PublicPort === 'number' && p.PublicPort > 0)
.map((p: any) => ({ PrivatePort: (p.PrivatePort || 0) as number, PublicPort: p.PublicPort as number, Type: typeof p.Type === 'string' ? p.Type.toLowerCase() : undefined }));
}
return {
Id: c.Id,
Names: c.Names,
Service: service,
State: c.State,
Status: c.Status,
Labels: c.Labels,
Ports
};
});
} catch (fallbackError) {
console.error('Smart Fallback failed for %s:', sanitizeForLog(stackName), sanitizeForLog((fallbackError as Error)?.message ?? String(fallbackError)));
return [];
}
}
public async streamContainerLogs(containerId: string, req: any, res: any): Promise<void> {
const container = this.docker.getContainer(containerId);
// 1. Set SSE Headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
try {
const logStream = await container.logs({
follow: true,
stdout: true,
stderr: true,
tail: 100 // Send the last 100 lines immediately for context
});
// 2. Process and forward the stream
logStream.on('data', (chunk: Buffer) => {
// Docker multiplexes stdout/stderr with an 8-byte header if TTY is false.
let data = chunk;
if (chunk.length > 8 && (chunk[0] === 1 || chunk[0] === 2)) {
data = chunk.slice(8);
}
const text = data.toString('utf-8');
const lines = text.split('\n');
lines.forEach(line => {
if (line.trim()) {
res.write(`data: ${JSON.stringify(line)}\n\n`);
}
});
});
// 3. Cleanup on disconnect
req.on('close', () => {
(logStream as any).destroy();
});
} catch (error: any) {
res.write(`data: ${JSON.stringify('[Sencho] Error fetching logs: ' + error.message)}\n\n`);
res.end();
}
}
// State-safe: silently ignores 304 "already started" errors
public async startContainer(containerId: string) {
try {
const container = this.docker.getContainer(containerId);
await container.start();
} catch (error: any) {
if (error?.statusCode === 304) {
// Container already running - not an error
return;
}
throw error;
}
}
// State-safe: silently ignores 304 "already stopped" errors
public async stopContainer(containerId: string) {
try {
const container = this.docker.getContainer(containerId);
await container.stop();
} catch (error: any) {
if (error?.statusCode === 304) {
// Container already stopped - not an error
return;
}
throw error;
}
}
public async restartContainer(containerId: string) {
const container = this.docker.getContainer(containerId);
await container.restart();
}
public async getOrphanContainers(knownStackNames: string[]) {
// 1. Fetch all containers (running and stopped)
const allContainers = await this.docker.listContainers({ all: true });
// 2. Filter and categorize orphans
const orphans: Record<string, any[]> = {};
const selfIdentity = SelfIdentityService.getInstance();
allContainers.forEach((container) => {
// Look for the docker compose project label
const projectName = container.Labels?.['com.docker.compose.project'];
// Sencho's own container is not a stack on this node, so when it carries
// a compose-project label (compose-deployed installations) it would
// otherwise surface here as a stray under that project name.
if (selfIdentity.isOwnContainer(container.Id)) return;
// If it has a project label, but the project is NOT in our known list...
if (projectName && !knownStackNames.includes(projectName)) {
if (!orphans[projectName]) {
orphans[projectName] = [];
}
orphans[projectName].push({
Id: container.Id,
Names: container.Names,
State: container.State,
Status: container.Status,
Image: container.Image
});
}
});
return orphans;
}
public async removeContainers(containerIds: string[]) {
if (isDebugEnabled()) console.debug('[Resources:debug] removeContainers', { count: containerIds.length });
const results = [];
for (const id of containerIds) {
try {
const container = this.docker.getContainer(id);
await container.remove({ force: true });
results.push({ id, success: true });
} catch (error: any) {
console.error('Failed to remove container %s:', sanitizeForLog(id), sanitizeForLog(error.message));
results.push({ id, success: false, error: error.message });
}
}
return results;
}
public async streamStats(containerId: string, ws: WebSocket) {
const container = this.docker.getContainer(containerId);
const stats = await container.stats({ stream: true });
stats.on('data', (chunk: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(chunk.toString());
}
});
stats.on('error', (err: Error) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ error: err.message }));
}
});
stats.on('end', () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ end: true }));
}
});
// Destroy the Docker stats stream when the WebSocket closes to prevent
// orphaned streams polling the daemon after client disconnect.
ws.on('close', () => {
try { (stats as any).destroy(); } catch (e) {
// Stream already ended before client disconnected
console.warn('[DockerController] Stats stream already ended on WS close:', (e as Error).message);
}
});
}
public async getContainerStatsStream(containerId: string): Promise<string> {
const container = this.docker.getContainer(containerId);
const stats = await container.stats({ stream: false });
return typeof stats === 'string' ? stats : JSON.stringify(stats);
}
/** Return the cumulative restart count for a container via inspect(). */
public async getContainerRestartCount(containerId: string): Promise<number> {
const container = this.docker.getContainer(containerId);
const info = await container.inspect();
return info.RestartCount ?? 0;
}
/**
* Exec into a container with full session isolation.
* All state (exec instance, stream) lives in this closure - no singleton traps.
* The WebSocket message handler is registered here to handle input, resize, and cleanup.
*/
public async execContainer(containerId: string, ws: WebSocket) {
try {
// Input validation
if (!containerId || typeof containerId !== 'string') {
console.warn('[Exec] Empty or invalid containerId');
if (ws.readyState === WebSocket.OPEN) {
ws.send('\r\n\x1b[31mError: No container ID provided\x1b[0m\r\n');
ws.close();
}
return;
}
const container = this.docker.getContainer(containerId);
// Verify the container is running before attempting exec
const info = await container.inspect();
if (!info.State?.Running) {
console.warn('[Exec] Container not running:', containerId);
if (ws.readyState === WebSocket.OPEN) {
ws.send('\r\n\x1b[31mError: Container is not running\x1b[0m\r\n');
ws.close();
}
return;
}
// Try bash first, fall back to sh.
// Both exec creation AND start must be inside the try/catch because
// some runtimes reject unknown binaries at start(), not at creation.
const execOpts = { AttachStdin: true, AttachStdout: true, AttachStderr: true, Tty: true } as const;
let dockerExec: Docker.Exec;
let stream: import('stream').Duplex;
let shellType = '/bin/bash';
try {
dockerExec = await container.exec({ ...execOpts, Cmd: ['/bin/bash'] });
stream = await dockerExec.start({ hijack: true, stdin: true });
} catch {
shellType = '/bin/sh';
dockerExec = await container.exec({ ...execOpts, Cmd: ['/bin/sh'] });
stream = await dockerExec.start({ hijack: true, stdin: true });
}
if (isDebugEnabled()) console.debug('[Exec:diag] Creating exec', { containerId, shell: shellType });
if (isDebugEnabled()) console.log('[Exec] Shell session started', { containerId, shell: shellType });
// --- Downstream: container output → client ---
stream.on('data', (chunk: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(chunk.toString());
}
});
stream.on('error', (err: Error) => {
console.error('[Exec] Stream error:', err.message, { containerId });
});
stream.on('end', () => {
if (isDebugEnabled()) console.log('[Exec] Shell session ended', { containerId, reason: 'stream-end' });
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
});
// --- Upstream: client messages → container ---
ws.on('message', (raw: WebSocket.Data) => {
try {
const msg = JSON.parse(raw.toString());
switch (msg.type) {
case 'input':
if (msg.data) {
stream.write(msg.data);
}
break;
case 'resize':
if (msg.rows && msg.cols) {
if (isDebugEnabled()) console.debug('[Exec:diag] Terminal resize', { containerId, rows: msg.rows, cols: msg.cols });
dockerExec.resize({ h: msg.rows, w: msg.cols }).catch((e: Error) => {
// Exec may have ended before resize completes
console.warn('[Exec] Resize failed (exec may have ended):', e.message);
});
}
break;
case 'ping':
// Keep-alive, no-op
break;
}
} catch (e) {
// Non-JSON or malformed WebSocket message
console.warn('[Exec] Ignoring malformed WS message:', (e as Error).message);
}
});
// --- Cleanup: prevent zombie processes ---
ws.on('close', () => {
if (isDebugEnabled()) console.log('[Exec] Shell session ended', { containerId, reason: 'ws-close' });
try {
stream.destroy();
} catch (e) {
// Stream already destroyed before WS close
console.warn('[Exec] Stream already destroyed on WS close:', (e as Error).message);
}
});
} catch (error) {
const err = error as Error;
console.error('[Exec] Failed to start shell:', err.message, { containerId });
if (ws.readyState === WebSocket.OPEN) {
ws.send(`\r\n\x1b[31mFailed to start shell: ${err.message}\x1b[0m\r\n`);
}
}
}
}
export const globalDockerNetwork = { rxSec: 0, txSec: 0 };
let lastNetSum = { rx: 0, tx: 0, timestamp: Date.now() };
let isUpdatingNetwork = false;
export const updateGlobalDockerNetwork = async () => {
if (isUpdatingNetwork) return; // Prevent overlapping calls
isUpdatingNetwork = true;
try {
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
const dockerController = DockerController.getInstance(nodeId);
const containers = await dockerController.getRunningContainers();
const statsResults = await Promise.allSettled(
containers.map(c => dockerController.getContainerStatsStream(c.Id))
);
let currentRxSum = 0;
let currentTxSum = 0;
for (const result of statsResults) {
if (result.status === 'fulfilled') {
try {
const stats = typeof result.value === 'string' ? JSON.parse(result.value) : result.value;
if (stats.networks) {
for (const [_, net] of Object.entries(stats.networks) as any) {
currentRxSum += net.rx_bytes || 0;
currentTxSum += net.tx_bytes || 0;
}
}
} catch (e) {
// ignore parsing errors
}
}
}
const now = Date.now();
const timeDiffSeconds = (now - lastNetSum.timestamp) / 1000;
if (timeDiffSeconds > 0) {
const rxDelta = currentRxSum >= lastNetSum.rx ? currentRxSum - lastNetSum.rx : 0;
const txDelta = currentTxSum >= lastNetSum.tx ? currentTxSum - lastNetSum.tx : 0;
globalDockerNetwork.rxSec = rxDelta / timeDiffSeconds;
globalDockerNetwork.txSec = txDelta / timeDiffSeconds;
}
lastNetSum = { rx: currentRxSum, tx: currentTxSum, timestamp: now };
} catch {
// Silently skip when Docker is unreachable (e.g. no local engine).
// Network stats will remain at their last known values.
} finally {
isUpdatingNetwork = false;
}
};
// Poll network stats every 5s (reduced from 3s to lower Docker daemon pressure)
setInterval(updateGlobalDockerNetwork, 5000);
export default DockerController;