From 88011e1b16975033e9212de3d61aa4987237ead2 Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 7 Apr 2026 01:44:13 -0400 Subject: [PATCH] fix(sidebar): resolve stacks showing unknown status when compose name field is set (#416) * fix(sidebar): resolve stacks showing unknown status when compose name field is set The bulk status endpoint matched containers to stacks using the com.docker.compose.project Docker label, assuming it equals the stack directory name. When a compose file declares a top-level name: field, Docker Compose uses that as the project name instead, causing the label lookup to miss those containers entirely. The fix parses each stack's compose file to build a project-name-to- directory mapping (cached with 60s TTL to avoid re-parsing on every poll), with a fallback to the working_dir label for edge cases. Also extracts compose file name variants into a shared constant and fixes an ordering inconsistency in smartFallback. * docs: add troubleshooting entry for stack status mismatch with name field --- CHANGELOG.md | 1 + backend/src/services/DockerController.ts | 81 +++++++++++++++++++++--- docs/operations/troubleshooting.mdx | 10 +++ 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf4d51ea..c831b351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,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. * **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 d3c8021f..ff83e1f0 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -11,6 +11,13 @@ import { NodeRegistry } from './NodeRegistry'; const execAsync = promisify(exec); 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; +let projectNameCache: { map: Record; builtAt: number } | null = null; + /** 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. */ @@ -374,9 +381,55 @@ class DockerController { return this.validateApiData(containers); } + /** + * 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> { + if (projectNameCache && Date.now() - projectNameCache.builtAt < PROJECT_NAME_CACHE_TTL_MS) { + return projectNameCache.map; + } + + const map: Record = {}; + + 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 ${filePath}:`, err); + break; + } + } + } + })); + + projectNameCache = { map, builtAt: Date.now() }; + return map; + } + public async getBulkStackStatuses(stackNames: string[]): Promise> { - const allContainers = await this.docker.listContainers({ all: true }); - const knownSet = new Set(stackNames); + // Run Docker API call and project name resolution in parallel + const [allContainers, projectToStack] = await Promise.all([ + this.docker.listContainers({ all: true }), + 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 result: Record = {}; for (const name of stackNames) { @@ -385,13 +438,23 @@ class DockerController { for (const container of allContainers as any[]) { const project: string | undefined = container.Labels?.['com.docker.compose.project']; - if (!project || !knownSet.has(project)) continue; + 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)]; + } + } + + if (!stackDir || !result[stackDir]) continue; if (container.State === 'running') { - result[project].status = 'running'; + result[stackDir].status = 'running'; // Detect main web port (first running container with a matchable port wins) - if (result[project].mainPort === undefined && Array.isArray(container.Ports) && container.Ports.length > 0) { + if (result[stackDir].mainPort === undefined && Array.isArray(container.Ports) && container.Ports.length > 0) { const ports = container.Ports as { PrivatePort?: number; PublicPort?: number }[]; let match = ports.find(p => p.PrivatePort && WEB_UI_PORTS.includes(p.PrivatePort)); if (!match) match = ports.find(p => p.PublicPort && WEB_UI_PORTS.includes(p.PublicPort)); @@ -401,11 +464,11 @@ class DockerController { ); const chosen = match || ports[0]; if (chosen?.PublicPort) { - result[project].mainPort = chosen.PublicPort; + result[stackDir].mainPort = chosen.PublicPort; } } - } else if (result[project].status !== 'running') { - result[project].status = 'exited'; + } else if (result[stackDir].status !== 'running') { + result[stackDir].status = 'exited'; } } @@ -502,7 +565,7 @@ class DockerController { try { // 1. Flexible Compose File Discovery // Try multiple valid compose file names - const composeFileNames = ['compose.yaml', 'docker-compose.yml', 'compose.yml', 'docker-compose.yaml']; + const composeFileNames = COMPOSE_FILE_NAMES; let yamlContent: string | null = null; for (const fileName of composeFileNames) { diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index 4b7501b4..ab0164d1 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -222,6 +222,16 @@ To re-test connectivity after making changes, open **Profile > Settings > Nodes* --- +## Stack shows "--" instead of UP even though containers are running + +**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. + +**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. + +--- + ## Network creation fails **Symptom:** Clicking **Create** in the Create Network dialog returns an error about a missing or invalid name.