From d62ac095031ae203b2d29a1f0caa85a113884da7 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Sun, 22 Mar 2026 18:36:17 -0400 Subject: [PATCH] fix(stats): classify managed containers by working_dir instead of project name Containers launched from the COMPOSE_DIR root (not individual subdirs) all share the root folder's name as their com.docker.compose.project label, making them appear as "external" despite being in COMPOSE_DIR. Switch to com.docker.compose.project.working_dir: a container is managed if its working directory is within COMPOSE_DIR, regardless of what project name Docker Compose assigned to it. --- CHANGELOG.md | 1 + backend/src/index.ts | 29 +++++++++++++++-------------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06d8dd49..9ed663e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **Managed container count wrong when stacks launched from COMPOSE_DIR root:** The `GET /api/stats` endpoint classified containers as managed by matching `com.docker.compose.project` against known stack directory names. When stacks are launched from the COMPOSE_DIR root (rather than each subdirectory), Docker assigns the root folder name as the project name for all of them — causing every such container to appear as "external". Fixed by classifying via `com.docker.compose.project.working_dir`: a container is managed if its working directory falls within COMPOSE_DIR, regardless of the project name. - **Blank page on HTTP deployments (root cause — Helmet 8 default CSP):** Helmet 8 merges custom `directives` with its built-in defaults, which include `upgrade-insecure-requests`. The previous fix (PR #59) omitted the directive from the custom object, but Helmet silently re-added it from defaults. The correct fix is `upgradeInsecureRequests: null`, which is the Helmet 8 API for explicitly removing a default directive. - **Login loop caused by remote node auth failure:** When a configured remote node had an expired or invalid API token, every proxied request to that node returned 401. Both `apiFetch` and `fetchForNode` treated any 401 as a user session failure and dispatched `sencho-unauthorized`, immediately logging the user out and sending them back to the login page — even after a successful login. Fixed by adding a `proxyRes` handler that stamps all remote-proxied responses with `x-sencho-proxy: 1`; the frontend now only fires `sencho-unauthorized` when this header is absent (i.e., it's a genuine local session failure). - **Missing `authMiddleware` on notifications endpoints:** `GET /api/notifications`, `POST /api/notifications/read`, `DELETE /api/notifications/:id`, `DELETE /api/notifications`, and `POST /api/notifications/test` were all missing `authMiddleware`, violating the default-deny policy. Added to all five. diff --git a/backend/src/index.ts b/backend/src/index.ts index 63cab8b9..081acadb 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1156,24 +1156,25 @@ app.post('/api/convert', async (req: Request, res: Response) => { // Get all containers stats for dashboard app.get('/api/stats', async (req: Request, res: Response) => { try { - const [dockerController, knownStacks] = [ - DockerController.getInstance(req.nodeId), - await FileSystemService.getInstance(req.nodeId).getStacks(), - ]; - const allContainers = await dockerController.getAllContainers(); - const knownSet = new Set(knownStacks); + const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(req.nodeId)); + const allContainers = await DockerController.getInstance(req.nodeId).getAllContainers(); + + // A container is "managed" if Docker started it from within COMPOSE_DIR. + // We use com.docker.compose.project.working_dir rather than project name because + // stacks launched from the COMPOSE_DIR root (not a subdirectory) all share the + // project name of the root folder — causing false "external" classification. + const isManagedByComposeDir = (c: any): boolean => { + const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir']; + if (!workingDir) return false; + const resolved = path.resolve(workingDir); + return resolved === composeDir || resolved.startsWith(composeDir + path.sep); + }; const active = allContainers.filter((c: any) => c.State === 'running').length; const exited = allContainers.filter((c: any) => c.State === 'exited').length; const total = allContainers.length; - const managed = allContainers.filter((c: any) => { - const project: string | undefined = c.Labels?.['com.docker.compose.project']; - return project && knownSet.has(project) && c.State === 'running'; - }).length; - const unmanaged = allContainers.filter((c: any) => { - const project: string | undefined = c.Labels?.['com.docker.compose.project']; - return project && !knownSet.has(project) && c.State === 'running'; - }).length; + const managed = allContainers.filter((c: any) => c.State === 'running' && isManagedByComposeDir(c)).length; + const unmanaged = allContainers.filter((c: any) => c.State === 'running' && !isManagedByComposeDir(c)).length; res.json({ active, managed, unmanaged, exited, total }); } catch (error) {