From 662bc1a210386e32cd24b33f155ecf6adfda6d8f Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 8 Apr 2026 09:46:21 -0400 Subject: [PATCH] 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. --- CHANGELOG.md | 5 + backend/src/services/DockerController.ts | 149 +++++++++++++----- docs/operations/troubleshooting.mdx | 7 +- .../components/dashboard/ResourceGauges.tsx | 2 +- 4 files changed, 121 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af5e6754..1dbd35c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **console:** fix remote node Host Console failing with "Connection error" / 502. The gateway's console-token request to the remote node was missing license tier headers, causing the remote's Admiral license gate to reject the request. Both the HTTP fetch and the WS upgrade handler now correctly propagate proxy tier headers for console_session tokens. * **auto-update:** resolve failure when executing auto-update policies on remote Distributed API nodes. Previously, the scheduler tried to access the remote Docker daemon directly, which is not supported. Now the update execution is proxied to the remote Sencho instance via HTTP, matching the existing Distributed API architecture. * **schedules:** auto-update policies no longer appear in the Scheduled Operations list. Each view now fetches only its relevant task type via server-side action filtering. +* **console:** fix remote node Console returning 502 by injecting proxy tier headers into console-token fetch and WS upgrade handler. +* **sidebar:** strengthen stack-to-container resolution with a multi-fallback strategy (project label, working_dir, service name, config_files path) to handle containers that predate Sencho's compose file reorganization. +* **resources:** fix incorrect "External" tagging in Resources Hub by applying the same multi-fallback resolution to image, volume, and network classification. Extracted shared helpers (`resolveContainerStack`, `resolveProjectLabel`, `buildAbsDirMap`) to unify logic across all classification and prune methods. +* **dashboard:** fix "active" label on Containers card to use correct plural form ("actives"). ## [0.39.6](https://github.com/AnsoCode/Sencho/compare/v0.39.5...v0.39.6) (2026-04-07) @@ -59,6 +63,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * **sidebar:** fix stacks showing "--" (unknown) instead of "UP" when their compose file uses a top-level `name:` field. The bulk status endpoint matched containers by the `com.docker.compose.project` Docker label against directory names, but a `name:` override causes the label to differ. The fix parses compose files to build a correct project-name-to-directory mapping (cached with 60s TTL), with a fallback to the `working_dir` label for edge cases. +* **sidebar:** fix stacks still showing "--" after Sencho reorganized compose files into subdirectories. Containers that predate the reorganization carry stale Docker labels (project set to the COMPOSE_DIR basename). Added service name and config_files path fallbacks to the container-to-stack matching logic. * **fleet:** fix `spawnSync /bin/sh ENOENT` when triggering remote node self-update. The `execSync` call used `cwd: workingDir` from Docker Compose labels, which is a host-side path that does not exist inside the container. Removed `cwd` (the `-f` flag already provides the absolute compose file path), added `shell: true`, and added a Docker Compose CLI availability check at startup. * **fleet:** fix version detection returning stale value from `generated/version.ts` instead of the authoritative `package.json`. This caused remote nodes to show "unknown" version and false "Update available" badges when both nodes were on the same version. `resolveVersion()` now reads the root `package.json` first and only falls back to the build-time constant if the walk fails. * **fleet:** fix permanently stuck "Timed out" / "Failed" badges after node update attempts. The in-memory update tracker now supports clearing via a new DELETE endpoint, and terminal states are automatically clearable through the Recheck button. diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index ff83e1f0..4b99c7ab 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -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(rawVolumeData)).Volumes || []; - // Build imageId → project mapping from container labels - const imageToProject = new Map(); + // 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(); 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(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(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(); 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(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(containers); } + /** Resolves a Docker Compose project label to a known Sencho stack name, or null. */ + private static resolveProjectLabel( + project: string | undefined, + knownSet: Set, + projectToStack: Record, + ): 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 { + const map: Record = {}; + 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 | undefined, + projectToStack: Record, + knownStackSet: Set, + absDirToStack: Record, + 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 = {}; - 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 = {}; 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; diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index ab0164d1..a4e17e46 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -226,9 +226,12 @@ To re-test connectivity after making changes, open **Profile > Settings > Nodes* **Symptom:** A stack appears in the sidebar with no status indicator (shown as "--"), but clicking on it reveals running containers with active stats. -**Cause:** This was a bug in versions before 0.40.0 where the sidebar status check matched containers by the Docker Compose project name, assuming it equals the stack's directory name. If your compose file declares a top-level `name:` field (e.g. `name: my-custom-name`), Docker Compose uses that as the project label instead, causing the mismatch. +**Cause:** The sidebar status check matches containers by their Docker Compose project label. A mismatch can happen when: -**Fix:** Update to Sencho 0.40.0 or later. The status check now reads the `name:` field from compose files and matches correctly regardless of whether a custom project name is set. +- The compose file declares a top-level `name:` field that overrides the default project name. +- Containers were created before Sencho reorganized compose files into subdirectories, so their labels still reference the old layout. + +**Fix:** Update to the latest version of Sencho. The status check uses multiple fallback strategies (project name mapping, service name, and config file paths) to match containers regardless of how they were originally started. --- diff --git a/frontend/src/components/dashboard/ResourceGauges.tsx b/frontend/src/components/dashboard/ResourceGauges.tsx index b00a38fa..0fffd4b3 100644 --- a/frontend/src/components/dashboard/ResourceGauges.tsx +++ b/frontend/src/components/dashboard/ResourceGauges.tsx @@ -113,7 +113,7 @@ export function ResourceGauges({ stats, systemStats }: ResourceGaugesProps) { {stats.active} - active + {stats.active === 1 ? 'active' : 'actives'}