fix(resources): unify container/resource classification with multi-fallback resolution (#425)

* fix(sidebar): add service name and config_files fallbacks for container-to-stack matching

Containers that predate Sencho's reorganization of compose files into
subdirectories carry stale Docker labels where the project name is set
to the COMPOSE_DIR basename (e.g. "compose") rather than the stack
directory name. The existing project name map and working_dir fallbacks
from PR #416 did not cover this case.

Added two new fallback strategies to getBulkStackStatuses:
- Match by com.docker.compose.service label against known stack names
- Extract stack name from com.docker.compose.project.config_files path

Also reused the existing isPathWithinBase utility for path containment
checks and hoisted path.resolve(COMPOSE_DIR) out of the per-container
loop.

* fix(resources): unify container/resource classification with multi-fallback resolution

Extract shared helpers (resolveContainerStack, resolveProjectLabel,
buildAbsDirMap) and apply them consistently across getClassifiedResources,
pruneManagedOnly, getDiskUsageClassified, and getBulkStackStatuses.

Fixes incorrect "External" tagging in Resources Hub for images, volumes,
and networks belonging to stacks that predate Sencho's compose file
reorganization. Also fixes the "active" plural on the dashboard
Containers card.
This commit is contained in:
Anso
2026-04-08 09:46:21 -04:00
committed by GitHub
parent 2354beed02
commit 662bc1a210
4 changed files with 121 additions and 42 deletions
+110 -39
View File
@@ -7,6 +7,7 @@ import fs from 'fs/promises';
import * as yaml from 'yaml';
import { NodeRegistry } from './NodeRegistry';
import { isPathWithinBase } from '../utils/validation';
const execAsync = promisify(exec);
const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose';
@@ -186,46 +187,53 @@ class DockerController {
const SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']);
const knownSet = new Set(knownStackNames);
const [rawImages, rawVolumeData, rawNetworks, allContainers] = await Promise.all([
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 imageId → project mapping from container labels
const imageToProject = new Map<string, string>();
// Build fallback lookup structures for container-to-stack resolution
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
const resolvedBase = path.resolve(COMPOSE_DIR);
// Build imageId → stack mapping using the full fallback resolution chain
const imageToStack = new Map<string, string>();
for (const c of allContainers as any[]) {
const project: string | undefined = c.Labels?.['com.docker.compose.project'];
if (project && c.ImageID) imageToProject.set(c.ImageID, project);
if (!c.ImageID) continue;
const stack = DockerController.resolveContainerStack(
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (stack) imageToStack.set(c.ImageID, stack);
}
const images: ClassifiedImage[] = this.validateApiData<any[]>(rawImages).map((img: any) => {
const project = imageToProject.get(img.Id) ?? null;
const stack = imageToStack.get(img.Id) ?? null;
const managedStatus: ClassifiedImage['managedStatus'] =
img.Containers === 0 ? 'unused' :
project && knownSet.has(project) ? 'managed' : 'unmanaged';
stack ? 'managed' : 'unmanaged';
return {
Id: img.Id,
RepoTags: img.RepoTags ?? [],
Size: img.Size ?? 0,
Containers: img.Containers ?? 0,
managedBy: managedStatus === 'managed' ? project : null,
managedBy: stack,
managedStatus,
};
});
const volumes: ClassifiedVolume[] = rawVolumes.map((vol: any) => {
const project: string | undefined = vol.Labels?.['com.docker.compose.project'];
const managedStatus: ClassifiedVolume['managedStatus'] =
project && knownSet.has(project) ? 'managed' : 'unmanaged';
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,
managedBy: managedStatus === 'managed' ? project! : null,
managedBy: stack,
managedStatus,
};
});
@@ -234,15 +242,14 @@ class DockerController {
if (SYSTEM_NETWORKS.has(net.Name)) {
return { Id: net.Id, Name: net.Name, Driver: net.Driver, Scope: net.Scope, managedBy: null, managedStatus: 'system' as const };
}
const project: string | undefined = net.Labels?.['com.docker.compose.project'];
const managedStatus: ClassifiedNetwork['managedStatus'] =
project && knownSet.has(project) ? 'managed' : 'unmanaged';
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: managedStatus === 'managed' ? project! : null,
managedBy: stack,
managedStatus,
};
});
@@ -255,14 +262,15 @@ class DockerController {
knownStackNames: string[]
): Promise<{ success: boolean; reclaimedBytes: number }> {
const knownSet = new Set(knownStackNames);
const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames);
let reclaimedBytes = 0;
if (target === 'volumes') {
const rawVolumeData = await this.docker.listVolumes();
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
const prunable = rawVolumes.filter((v: any) => {
const project: string | undefined = v.Labels?.['com.docker.compose.project'];
return project && knownSet.has(project) && (v.UsageData?.RefCount ?? 1) === 0;
return !!DockerController.resolveProjectLabel(v.Labels?.['com.docker.compose.project'], knownSet, projectToStack)
&& (v.UsageData?.RefCount ?? 1) === 0;
});
for (const vol of prunable) {
try {
@@ -275,8 +283,7 @@ class DockerController {
} else if (target === 'networks') {
const rawNetworks = await this.docker.listNetworks();
const prunable = (rawNetworks as any[]).filter((n: any) => {
const project: string | undefined = n.Labels?.['com.docker.compose.project'];
return project && knownSet.has(project);
return !!DockerController.resolveProjectLabel(n.Labels?.['com.docker.compose.project'], knownSet, projectToStack);
});
for (const net of prunable) {
try {
@@ -287,10 +294,14 @@ class DockerController {
}
} 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>();
for (const c of allContainers as any[]) {
const project: string | undefined = c.Labels?.['com.docker.compose.project'];
if (!project || !knownSet.has(project)) unmanagedImageIds.add(c.ImageID);
const stack = DockerController.resolveContainerStack(
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (!stack) unmanagedImageIds.add(c.ImageID);
}
const rawImages = await this.docker.listImages({ all: false });
const prunable = (rawImages as any[]).filter((img: any) =>
@@ -330,15 +341,20 @@ class DockerController {
.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((v: any) => knownSet.has(v.Labels?.['com.docker.compose.project'] ?? ''))
.filter(isVolumeManaged)
.reduce((acc: number, v: any) => acc + (v.UsageData?.Size ?? 0), 0);
const unmanagedVolumeBytes = rawVolumes
.filter((v: any) => !knownSet.has(v.Labels?.['com.docker.compose.project'] ?? ''))
.filter((v: any) => !isVolumeManaged(v))
.reduce((acc: number, v: any) => acc + (v.UsageData?.Size ?? 0), 0);
return { ...base, managedImageBytes, unmanagedImageBytes, managedVolumeBytes, unmanagedVolumeBytes };
@@ -381,6 +397,70 @@ class DockerController {
return this.validateApiData<any[]>(containers);
}
/** 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.
@@ -425,11 +505,9 @@ class DockerController {
DockerController.resolveProjectNameMap(stackNames),
]);
// Fallback lookup by absolute working_dir path
const absDirToStack: Record<string, string> = {};
for (const stackDir of stackNames) {
absDirToStack[path.join(COMPOSE_DIR, stackDir)] = stackDir;
}
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) {
@@ -437,16 +515,9 @@ class DockerController {
}
for (const container of allContainers as any[]) {
const project: string | undefined = container.Labels?.['com.docker.compose.project'];
let stackDir = project ? projectToStack[project] : undefined;
// Fallback: match by com.docker.compose.project.working_dir label
if (!stackDir) {
const workingDir: string | undefined = container.Labels?.['com.docker.compose.project.working_dir'];
if (workingDir) {
stackDir = absDirToStack[workingDir] ?? absDirToStack[path.resolve(workingDir)];
}
}
const stackDir = DockerController.resolveContainerStack(
container.Labels, projectToStack, knownStackSet, absDirToStack, resolvedBase,
);
if (!stackDir || !result[stackDir]) continue;