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
This commit is contained in:
Anso
2026-04-07 01:44:13 -04:00
committed by GitHub
parent c79f58d8dd
commit 88011e1b16
3 changed files with 83 additions and 9 deletions
+1
View File
@@ -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.
+72 -9
View File
@@ -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<string, string>; 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<any[]>(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<Record<string, string>> {
if (projectNameCache && Date.now() - projectNameCache.builtAt < PROJECT_NAME_CACHE_TTL_MS) {
return projectNameCache.map;
}
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 ${filePath}:`, err);
break;
}
}
}
}));
projectNameCache = { map, builtAt: Date.now() };
return map;
}
public async getBulkStackStatuses(stackNames: string[]): Promise<Record<string, BulkStackInfo>> {
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<string, string> = {};
for (const stackDir of stackNames) {
absDirToStack[path.join(COMPOSE_DIR, stackDir)] = stackDir;
}
const result: Record<string, BulkStackInfo> = {};
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) {
+10
View File
@@ -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.