From bb4ddde35a321225e11b0b2ab5d32301572f42ae Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 24 Jun 2026 19:57:49 -0400 Subject: [PATCH] feat(sidebar): surface partial status for multi-container stacks (#1426) Bulk stack-status aggregation collapsed a stack to "running" as soon as any container was up, so a multi-container stack with crashed containers showed a green UP pill and the degradation was invisible from the sidebar. Add a crash-aware "partial" state: a stack is partial when at least one container is running and at least one has genuinely failed (exited with a non-zero code, dead, or crash-looping). Cleanly finished one-shot containers (exit 0) and clean restart-policy cycling do not count, so an app with a completed init job stays UP. The exit code is read from the container Status string, so no extra inspect calls are needed. Render partial as an amber PT pill with a hover tooltip showing the running/total count, fold it into the Down filter (needs-attention), and treat it as running for context-menu lifecycle actions so operators keep stop/restart/update. The dashboard stack-health table, cross-node search rows, and the command palette all pick up the new state through the shared status surfaces. --- .../src/__tests__/docker-controller.test.ts | 151 +++++++++++++++++- backend/src/routes/stacks.ts | 4 +- backend/src/services/DockerController.ts | 67 +++++++- docs/features/sidebar.mdx | 6 +- frontend/src/components/EditorLayout.tsx | 2 + .../hooks/useSidebarContextMenu.test.ts | 26 ++- .../hooks/useSidebarContextMenu.ts | 6 +- .../hooks/useStackActions.test.ts | 16 ++ .../EditorLayout/hooks/useStackActions.ts | 5 +- .../EditorLayout/hooks/useStackListState.ts | 30 +++- .../src/components/GlobalCommandPalette.tsx | 1 + .../__tests__/GlobalCommandPalette.test.tsx | 15 ++ .../components/dashboard/StackHealthTable.tsx | 12 +- .../__tests__/StackHealthTable.test.tsx | 20 +++ .../src/components/dashboard/classifyRow.ts | 15 ++ frontend/src/components/dashboard/types.ts | 2 +- frontend/src/components/sidebar/StackList.tsx | 5 +- frontend/src/components/sidebar/StackRow.tsx | 15 +- .../sidebar/__tests__/StackRow.test.tsx | 20 +++ .../__tests__/stack-status-utils.test.ts | 34 ++++ .../components/sidebar/stack-status-utils.ts | 9 +- frontend/src/hooks/useCrossNodeStackSearch.ts | 2 +- 22 files changed, 423 insertions(+), 40 deletions(-) create mode 100644 frontend/src/components/dashboard/__tests__/StackHealthTable.test.tsx create mode 100644 frontend/src/components/dashboard/classifyRow.ts create mode 100644 frontend/src/components/sidebar/__tests__/stack-status-utils.test.ts diff --git a/backend/src/__tests__/docker-controller.test.ts b/backend/src/__tests__/docker-controller.test.ts index 586fa141..f9a0ecff 100644 --- a/backend/src/__tests__/docker-controller.test.ts +++ b/backend/src/__tests__/docker-controller.test.ts @@ -44,7 +44,7 @@ vi.mock('util', () => ({ promisify: () => vi.fn(), })); -import DockerController, { selectMainWebPort } from '../services/DockerController'; +import DockerController, { selectMainWebPort, parseExitCode, isContainerFailed } from '../services/DockerController'; import { CacheService } from '../services/CacheService'; beforeEach(() => { @@ -1365,3 +1365,152 @@ describe('selectMainWebPort', () => { expect(selectMainWebPort([])).toBeUndefined(); }); }); + +describe('parseExitCode', () => { + it('extracts the code from an exited Status string', () => { + expect(parseExitCode('Exited (0) 5 minutes ago')).toBe(0); + expect(parseExitCode('Exited (137) 2 minutes ago')).toBe(137); + }); + + it('reads the code from a restarting Status string', () => { + expect(parseExitCode('Restarting (1) 3 seconds ago')).toBe(1); + }); + + it('returns null when no parenthesized code is present', () => { + expect(parseExitCode('Up 3 hours')).toBeNull(); + expect(parseExitCode('Up 2 hours (healthy)')).toBeNull(); + expect(parseExitCode('Created')).toBeNull(); + expect(parseExitCode(undefined)).toBeNull(); + }); +}); + +describe('isContainerFailed', () => { + it('treats a dead container as failed', () => { + expect(isContainerFailed('dead', 'Dead')).toBe(true); + }); + + it('treats a crash-looping restart as failed but a clean restart as not', () => { + expect(isContainerFailed('restarting', 'Restarting (1) 5 seconds ago')).toBe(true); + expect(isContainerFailed('restarting', 'Restarting (0) 2 seconds ago')).toBe(false); + }); + + it('treats a non-zero exit as failed and a clean exit as not failed', () => { + expect(isContainerFailed('exited', 'Exited (137) 2 minutes ago')).toBe(true); + expect(isContainerFailed('exited', 'Exited (0) 5 minutes ago')).toBe(false); + }); + + it('treats an exited container with an unreadable code as failed', () => { + expect(isContainerFailed('exited', 'Exited')).toBe(true); + }); + + it('does not treat running, created, or paused containers as failed', () => { + expect(isContainerFailed('running', 'Up 2 hours')).toBe(false); + expect(isContainerFailed('created', 'Created')).toBe(false); + expect(isContainerFailed('paused', 'Up 2 hours (Paused)')).toBe(false); + }); +}); + +describe('DockerController - getBulkStackStatuses partial status', () => { + beforeEach(() => { + CacheService.getInstance().flush(); + }); + + const container = (id: string, project: string, state: string, status: string) => ({ + Id: id, Names: [`/${id}`], State: state, Status: status, + Image: 'nginx', Created: 1000, Labels: { 'com.docker.compose.project': project }, + }); + + // Any running container triggers an inspect for uptime; stub a valid StartedAt. + const stubInspect = () => { + mockDocker.getContainer.mockReturnValue({ + inspect: vi.fn().mockResolvedValue({ State: { StartedAt: '2026-06-09T12:00:00.000Z' } }), + }); + }; + + it('keeps a stack UP when a one-shot container exits cleanly', async () => { + stubInspect(); + mockDocker.listContainers.mockResolvedValue([ + container('clean-run', 'clean-stack', 'running', 'Up 2 hours'), + container('clean-init', 'clean-stack', 'exited', 'Exited (0) 5 minutes ago'), + ]); + + const result = await DockerController.getInstance(1).getBulkStackStatuses(['clean-stack']); + expect(result['clean-stack'].status).toBe('running'); + expect(result['clean-stack'].running).toBe(1); + expect(result['clean-stack'].total).toBe(2); + }); + + it('marks a stack partial when a container crashes alongside a running one', async () => { + stubInspect(); + mockDocker.listContainers.mockResolvedValue([ + container('crash-run', 'crash-stack', 'running', 'Up 2 hours'), + container('crash-exit', 'crash-stack', 'exited', 'Exited (137) 1 minute ago'), + ]); + + const result = await DockerController.getInstance(1).getBulkStackStatuses(['crash-stack']); + expect(result['crash-stack'].status).toBe('partial'); + expect(result['crash-stack'].running).toBe(1); + expect(result['crash-stack'].total).toBe(2); + }); + + it('marks a stack partial for a dead or restart-looping container', async () => { + stubInspect(); + mockDocker.listContainers.mockResolvedValue([ + container('d-run', 'dead-stack', 'running', 'Up 2 hours'), + container('d-dead', 'dead-stack', 'dead', 'Dead'), + container('r-run', 'restart-stack', 'running', 'Up 2 hours'), + container('r-loop', 'restart-stack', 'restarting', 'Restarting (1) 5 seconds ago'), + ]); + + const result = await DockerController.getInstance(1).getBulkStackStatuses(['dead-stack', 'restart-stack']); + expect(result['dead-stack'].status).toBe('partial'); + expect(result['restart-stack'].status).toBe('partial'); + }); + + it('keeps a stack running when a sibling is paused rather than crashed', async () => { + stubInspect(); + mockDocker.listContainers.mockResolvedValue([ + container('p-run', 'paused-stack', 'running', 'Up 2 hours'), + container('p-paused', 'paused-stack', 'paused', 'Up 2 hours (Paused)'), + ]); + + const result = await DockerController.getInstance(1).getBulkStackStatuses(['paused-stack']); + expect(result['paused-stack'].status).toBe('running'); + expect(result['paused-stack'].running).toBe(1); + expect(result['paused-stack'].total).toBe(2); + }); + + it('reports running when every container is up', async () => { + stubInspect(); + mockDocker.listContainers.mockResolvedValue([ + container('all-1', 'all-stack', 'running', 'Up 2 hours'), + container('all-2', 'all-stack', 'running', 'Up 1 hour'), + ]); + + const result = await DockerController.getInstance(1).getBulkStackStatuses(['all-stack']); + expect(result['all-stack'].status).toBe('running'); + expect(result['all-stack'].running).toBe(2); + expect(result['all-stack'].total).toBe(2); + }); + + it('reports exited when no container is running, even if some crashed', async () => { + mockDocker.listContainers.mockResolvedValue([ + container('down-1', 'down-stack', 'exited', 'Exited (1) 3 minutes ago'), + container('down-2', 'down-stack', 'exited', 'Exited (0) 3 minutes ago'), + ]); + + const result = await DockerController.getInstance(1).getBulkStackStatuses(['down-stack']); + expect(result['down-stack'].status).toBe('exited'); + expect(result['down-stack'].running).toBe(0); + expect(result['down-stack'].total).toBe(2); + }); + + it('reports unknown for a stack with no containers', async () => { + mockDocker.listContainers.mockResolvedValue([]); + + const result = await DockerController.getInstance(1).getBulkStackStatuses(['empty-stack']); + expect(result['empty-stack'].status).toBe('unknown'); + expect(result['empty-stack'].running).toBeUndefined(); + expect(result['empty-stack'].total).toBeUndefined(); + }); +}); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index d9b93798..5e25e298 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -13,7 +13,7 @@ import { FileSystemService } from '../services/FileSystemService'; import { StackFileRootsService, STACK_SOURCE_ROOT_ID, stackSourceFileRoot, type StackFileRoot } from '../services/StackFileRootsService'; import { FileRootGateway } from '../services/FileRootGateway'; import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService'; -import DockerController from '../services/DockerController'; +import DockerController, { type BulkStackInfo } from '../services/DockerController'; import { DatabaseService, type StackDossierFields } from '../services/DatabaseService'; import { MeshService } from '../services/MeshService'; import { CacheService } from '../services/CacheService'; @@ -240,7 +240,7 @@ stacksRouter.get('/statuses', async (req: Request, res: Response) => { const stackNames = stacks.map((s: string) => s.replace(/\.(yml|yaml)$/, '')); const dockerController = DockerController.getInstance(req.nodeId); const bulkInfo = await dockerController.getBulkStackStatuses(stackNames); - const data: Record = {}; + const data: Record = {}; for (const stack of stacks) { const name = stack.replace(/\.(yml|yaml)$/, ''); data[stack] = bulkInfo[name] ?? { status: 'unknown' }; diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 29ac5ce8..a4c90b40 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -54,11 +54,45 @@ export function selectMainWebPort( return chosen?.PublicPort; } +/** + * Pull the exit code out of a Docker container Status string. listContainers + * exposes the code only inside Status (e.g. "Exited (137) 2 minutes ago"); the + * structured code would otherwise need a per-container inspect. Returns null when + * no parenthesized code is present (e.g. "Up 3 hours", "Created"). + */ +export function parseExitCode(status: string | undefined): number | null { + if (!status) return null; + const match = /\((\d+)\)/.exec(status); + return match ? Number(match[1]) : null; +} + +/** + * Whether a container represents a genuine failure (a crash) rather than a clean + * completion. A dead container always counts. An exited or restarting container + * counts only when it left with a non-zero exit code (read from its Status + * string), so a finished init job (exit 0) or a container cleanly cycling under a + * restart policy does not mark its stack as degraded, while a crash loop (e.g. + * "Restarting (1)") does. An exited or restarting container with an unreadable + * code is treated as failed, erring toward surfacing a crash. + */ +export function isContainerFailed(state: string, status: string | undefined): boolean { + if (state === 'dead') return true; + if (state === 'exited' || state === 'restarting') { + const code = parseExitCode(status); + return code === null ? true : code !== 0; + } + return false; +} + export interface BulkStackInfo { - status: 'running' | 'exited' | 'unknown'; + status: 'running' | 'exited' | 'unknown' | 'partial'; mainPort?: number; /** Unix seconds of the oldest running container's last start (approximates stack uptime). */ runningSince?: number; + /** Running container count for the stack (set when the stack has containers). */ + running?: number; + /** Total container count for the stack; paired with `running` for the sidebar tooltip. */ + total?: number; } export interface ClassifiedImage { @@ -1144,6 +1178,14 @@ class DockerController { // only used if an inspect fails, since it never moves on restart. const runningByStack: Record = {}; + // Per stack, tally running, genuinely-failed (crashed), and total containers + // so the status can distinguish a fully-up stack from one that is partially + // degraded (some running, some crashed). + const countsByStack: Record = {}; + for (const name of stackNames) { + countsByStack[name] = { running: 0, failed: 0, total: 0 }; + } + for (const container of allContainers as any[]) { const stackDir = DockerController.resolveContainerStack( container.Labels, projectToStack, knownStackSet, absDirToStack, resolvedBase, @@ -1151,8 +1193,11 @@ class DockerController { if (!stackDir || !result[stackDir]) continue; + const counts = countsByStack[stackDir]; + counts.total += 1; + if (container.State === 'running') { - result[stackDir].status = 'running'; + counts.running += 1; const acc = (runningByStack[stackDir] ??= { ids: [] }); if (typeof container.Id === 'string') acc.ids.push(container.Id); @@ -1168,11 +1213,25 @@ class DockerController { ); if (mainPort) result[stackDir].mainPort = mainPort; } - } else if (result[stackDir].status !== 'running') { - result[stackDir].status = 'exited'; + } else if (isContainerFailed(container.State, container.Status)) { + counts.failed += 1; } } + // Classify each stack from its tallies. "partial" requires at least one + // running and at least one crashed container, so a stack with a cleanly + // finished one-shot container (exit 0) stays "running". A stack with no + // containers keeps its seeded "unknown". + for (const name of stackNames) { + const { running, failed, total } = countsByStack[name]; + if (total === 0) continue; + if (running === 0) result[name].status = 'exited'; + else if (failed > 0) result[name].status = 'partial'; + else result[name].status = 'running'; + result[name].running = running; + result[name].total = total; + } + // Resolve real uptime: oldest StartedAt across each stack's running // containers, falling back to the oldest Created when inspect is unavailable. const allRunningIds = Object.values(runningByStack).flatMap(s => s.ids); diff --git a/docs/features/sidebar.mdx b/docs/features/sidebar.mdx index f200788d..03b2f4b7 100644 --- a/docs/features/sidebar.mdx +++ b/docs/features/sidebar.mdx @@ -26,8 +26,8 @@ From top to bottom: Four chips sit below the search box. Each shows a live count to the right of its label, and counts above 99 render as **99+** to keep the row from overflowing. - **All**: every stack on the node. -- **Up**: stacks where at least one container is running. -- **Down**: stacks where no container is running. +- **Up**: stacks that are running with nothing crashed. A stack with a cleanly finished one-shot container (an init job that exited without error) still counts as up. +- **Down**: stacks that need attention, whether fully stopped or running with at least one crashed container (the `PT` state described below). - **Updates**: stacks with at least one image update available. The chip turns orange when the count is non-zero so you can spot pending updates at a glance. @@ -52,7 +52,7 @@ Right-click a stack and choose **Pin to top**. Pinned stacks sit in a dedicated Each row gives you everything you need to read the stack at a glance, in a fixed column order: -- **Status pill** on the left. Two uppercase letters in mono type, or a spinner while a lifecycle action is in flight. `UP` means at least one container is running; `DN` means the stack is stopped. +- **Status pill** on the left. Two uppercase letters in mono type, or a spinner while a lifecycle action is in flight. `UP` (green) means the stack is running with nothing crashed, `DN` (red) means the stack is stopped, and `PT` (amber) means the stack is partially running: at least one container is up and at least one has crashed (exited with an error, died, or is restart-looping). Hover the `PT` pill to see how many containers are running, such as `3/5 running`. A stack whose only stopped container finished cleanly (an init job that exited without error) stays `UP`. - **Stack name** in mono type, truncated with an ellipsis when the row gets tight. - **Label dots** to the right of the name. Up to three colored dots representing the stack's labels render here. If a stack carries more than three labels, a **+N** counter appears for the extras. - **Update indicator**. When a stack has an image update pending, an extra colored dot appears alongside the label dots. If only a Git source update is pending (no image update), a small Git branch icon shows instead. The image-update dot takes priority when both apply. diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 8c3af8b8..e23d9759 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -111,6 +111,7 @@ export default function EditorLayout() { isScanning, searchQuery, setSearchQuery, stackStatuses, + stackCounts, stackLabelMap, filterChip, setFilterChip, bulkMode, @@ -629,6 +630,7 @@ export default function EditorLayout() { searchQuery, stackLabelMap, stackStatuses: stackStatuses as Record, + stackCounts, stackUpdates, gitSourcePendingMap, pinnedFiles: pinned, diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts index 98185497..6df1868a 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts @@ -6,9 +6,13 @@ import type { Node } from '@/context/NodeContext'; // buildMenuCtx derives canOpenApp from the active node plus the stack's // published port; only the fields it reads need to be real, the handler // closures are never invoked here. -function makeOptions(activeNode: Node | null, stackPorts: Record) { +function makeOptions( + activeNode: Node | null, + stackPorts: Record, + stackStatuses: Record = { 'web.yml': 'running' }, +) { const stackListState = { - stackStatuses: { 'web.yml': 'running' }, + stackStatuses, stackPorts, isStackBusy: () => false, isPinned: () => false, @@ -62,3 +66,21 @@ describe('useSidebarContextMenu canOpenApp', () => { expect(result.current('web.yml').canOpenApp).toBe(false); }); }); + +describe('useSidebarContextMenu stackStatus', () => { + it('maps a partial stack to running so it gets running-stack actions', () => { + const { result } = renderHook(() => + useSidebarContextMenu(makeOptions({ id: 1, type: 'local' } as Node, {}, { 'web.yml': 'partial' }))); + expect(result.current('web.yml').stackStatus).toBe('running'); + }); + + it('passes exited and unknown through unchanged', () => { + const exited = renderHook(() => + useSidebarContextMenu(makeOptions({ id: 1, type: 'local' } as Node, {}, { 'web.yml': 'exited' }))); + expect(exited.result.current('web.yml').stackStatus).toBe('exited'); + + const missing = renderHook(() => + useSidebarContextMenu(makeOptions({ id: 1, type: 'local' } as Node, {}, {}))); + expect(missing.result.current('web.yml').stackStatus).toBe('unknown'); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts index 8b8d4034..0c8dda6f 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts @@ -36,8 +36,12 @@ export function useSidebarContextMenu({ const buildMenuCtx = useCallback((file: string): StackMenuCtx => { const sName = file.replace(/\.(yml|yaml)$/, ''); const mainPort = stackListState.stackPorts[file]; + // A partial stack has running containers, so it gets the running-stack + // lifecycle affordances; the menu's status union stays three-state. + const rawStatus = stackListState.stackStatuses[file] ?? 'unknown'; + const stackStatus = rawStatus === 'partial' ? 'running' : rawStatus; return { - stackStatus: (stackListState.stackStatuses[file] ?? 'unknown') as 'running' | 'exited' | 'unknown', + stackStatus, // Only offer "Open App" when a browser-reachable URL can actually be built // (a remote node with no API host, e.g. a pilot agent, yields none). canOpenApp: mainPort !== undefined && buildServiceUrl({ node: activeNode, publicPort: mainPort }) !== null, diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index a928a5e1..cf9c4674 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -862,6 +862,22 @@ describe('useStackActions recovery records', () => { }); }); +describe('useStackActions.getStackMenuVisibility', () => { + it('gives a partial stack the running-stack lifecycle actions', () => { + const { result } = setup({ stackList: { stackStatuses: { 'web.yml': 'partial' } as never } }); + expect(result.current.getStackMenuVisibility('web.yml')).toEqual({ + showDeploy: false, showStop: true, showRestart: true, showUpdate: true, + }); + }); + + it('shows deploy (not stop/restart/update) for an exited stack', () => { + const { result } = setup({ stackList: { stackStatuses: { 'web.yml': 'exited' } as never } }); + expect(result.current.getStackMenuVisibility('web.yml')).toEqual({ + showDeploy: true, showStop: false, showRestart: false, showUpdate: false, + }); + }); +}); + describe('useStackActions.openStackApp', () => { beforeEach(() => { vi.mocked(apiFetch).mockReset(); diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index 6676892a..1b6b8c7d 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -261,7 +261,10 @@ export function useStackActions(options: UseStackActionsOptions) { editorState.envContent !== editorState.originalEnvContent; const getStackMenuVisibility = (file: string) => { - const status = stackListState.stackStatuses[file]; + // A partial stack has running containers, so it shows the running-stack + // lifecycle actions (stop/restart/update) rather than deploy. + const raw = stackListState.stackStatuses[file]; + const status = raw === 'partial' ? 'running' : raw; return { showDeploy: status !== 'running', showStop: status === 'running', diff --git a/frontend/src/components/EditorLayout/hooks/useStackListState.ts b/frontend/src/components/EditorLayout/hooks/useStackListState.ts index 33bdb100..afc4faf2 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackListState.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackListState.ts @@ -12,15 +12,22 @@ import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards'; import type { StackAction, StackActionResult, ContainerInfo } from '../EditorView'; import type { Label as StackLabel } from '../../label-types'; import type { FilterChip } from '../../sidebar/sidebar-types'; +import { isDownStatus } from '../../sidebar/stack-status-utils'; import type { StackRowStatus } from '../../sidebar/stack-status-utils'; interface StackStatus { - [key: string]: 'running' | 'exited' | 'unknown'; + [key: string]: StackRowStatus; +} + +interface StackCounts { + [key: string]: { running: number; total: number } | undefined; } interface StackStatusInfo { - status: 'running' | 'exited' | 'unknown'; + status: StackRowStatus; mainPort?: number; + running?: number; + total?: number; } export interface RemoteResult { @@ -58,6 +65,7 @@ export function useStackListState() { const [searchQuery, setSearchQuery] = useState(''); const [stackStatuses, setStackStatuses] = useState({}); const [stackPorts, setStackPorts] = useState>({}); + const [stackCounts, setStackCounts] = useState({}); const [labels, setLabels] = useState([]); const [stackLabelMap, setStackLabelMap] = useState>({}); const [filterChip, setFilterChip] = useState('all'); @@ -165,19 +173,23 @@ export function useStackListState() { // Fetch all stack statuses in a single bulk call (falls back to per-stack queries for older remote nodes) const statusRes = await apiFetch('/stacks/statuses'); if (stale()) return fileList; - let bulkStatuses: Record | null = null; + let bulkStatuses: Record | null = null; const bulkPorts: Record = {}; + const bulkCounts: StackCounts = {}; if (statusRes.ok) { const raw = await statusRes.json(); bulkStatuses = {}; - // Handle both old format (plain string) and new format ({ status, mainPort }) + // Handle both old format (plain string) and new format ({ status, mainPort, running, total }) for (const [key, val] of Object.entries(raw)) { if (typeof val === 'string') { - bulkStatuses[key] = val as 'running' | 'exited' | 'unknown'; + bulkStatuses[key] = val as StackRowStatus; } else if (val && typeof val === 'object' && 'status' in val) { const info = val as StackStatusInfo; bulkStatuses[key] = info.status; if (info.mainPort) bulkPorts[key] = info.mainPort; + if (info.running !== undefined && info.total !== undefined) { + bulkCounts[key] = { running: info.running, total: info.total }; + } } } } else { @@ -211,6 +223,7 @@ export function useStackListState() { if (keys.length === Object.keys(prev).length && keys.every(k => prev[k] === bulkPorts[k])) return prev; return bulkPorts; }); + setStackCounts(bulkCounts); refreshLabels(); return fileList; } catch (error) { @@ -275,14 +288,14 @@ export function useStackListState() { const filterCounts = useMemo(() => ({ all: filteredFiles.length, up: filteredFiles.filter(f => stackStatuses[f] === 'running').length, - down: filteredFiles.filter(f => stackStatuses[f] === 'exited').length, + down: filteredFiles.filter(f => isDownStatus(stackStatuses[f])).length, updates: filteredFiles.filter(f => !!stackUpdates[f]).length, }), [filteredFiles, stackStatuses, stackUpdates]); const chipFilteredFiles = useMemo(() => { if (filterChip === 'all') return filteredFiles; if (filterChip === 'up') return filteredFiles.filter(f => stackStatuses[f] === 'running'); - if (filterChip === 'down') return filteredFiles.filter(f => stackStatuses[f] === 'exited'); + if (filterChip === 'down') return filteredFiles.filter(f => isDownStatus(stackStatuses[f])); if (filterChip === 'updates') return filteredFiles.filter(f => !!stackUpdates[f]); return filteredFiles; }, [filteredFiles, filterChip, stackStatuses, stackUpdates]); @@ -343,7 +356,7 @@ export function useStackListState() { }, [bulkMode, toggleBulkMode]); const remoteStackResults = useMemo(() => { - const out: Record> = {}; + const out: Record> = {}; for (const hit of remoteSearchHits) { (out[hit.nodeId] ??= []).push({ file: hit.file, status: hit.status }); } @@ -371,6 +384,7 @@ export function useStackListState() { searchQuery, setSearchQuery, stackStatuses, setStackStatuses, stackPorts, setStackPorts, + stackCounts, labels, stackLabelMap, filterChip, setFilterChip, diff --git a/frontend/src/components/GlobalCommandPalette.tsx b/frontend/src/components/GlobalCommandPalette.tsx index b7579be3..9df8bff2 100644 --- a/frontend/src/components/GlobalCommandPalette.tsx +++ b/frontend/src/components/GlobalCommandPalette.tsx @@ -33,6 +33,7 @@ const statusDot: Record = { running: 'bg-success', exited: 'bg-muted-foreground', unknown: 'bg-muted-foreground/60', + partial: 'bg-warning', }; interface PaletteState { diff --git a/frontend/src/components/__tests__/GlobalCommandPalette.test.tsx b/frontend/src/components/__tests__/GlobalCommandPalette.test.tsx index 5583b6b2..64124f40 100644 --- a/frontend/src/components/__tests__/GlobalCommandPalette.test.tsx +++ b/frontend/src/components/__tests__/GlobalCommandPalette.test.tsx @@ -149,6 +149,21 @@ describe('GlobalCommandPalette', () => { expect(screen.queryByText('No results.')).not.toBeInTheDocument(); }); + it('renders a partial stack hit with the amber status dot', () => { + hookReturn = { + hits: [{ nodeId: 2, nodeName: 'opsix', file: 'web.yml', status: 'partial' }], + failedNodes: [], + loading: false, + }; + renderPalette(); + open(); + type('web'); + // The dialog renders in a portal outside the render container, so scope the + // dot lookup to the hit row reached via screen. + const row = screen.getByText('web.yml').closest('[cmdk-item]'); + expect(row?.querySelector('.bg-warning')).not.toBeNull(); + }); + it('renders stack hits and caps the list with an overflow line', () => { const hits: StackHit[] = Array.from({ length: 55 }, (_, i) => ({ nodeId: 2, diff --git a/frontend/src/components/dashboard/StackHealthTable.tsx b/frontend/src/components/dashboard/StackHealthTable.tsx index f1ab4738..d893db4c 100644 --- a/frontend/src/components/dashboard/StackHealthTable.tsx +++ b/frontend/src/components/dashboard/StackHealthTable.tsx @@ -4,6 +4,7 @@ import { Sparkline } from '@/components/ui/sparkline'; import { ChevronLeft, ChevronRight, Layers } from 'lucide-react'; import type { StackStatusEntry, MetricPoint, StackCpuSeries } from './types'; import { aggregateCurrentUsage } from './aggregateCurrentUsage'; +import { classifyRow, type RowState } from './classifyRow'; interface StackHealthTableProps { stackStatuses: Record; @@ -14,8 +15,6 @@ interface StackHealthTableProps { } const PAGE_SIZE = 8; -const WARN = 80; -const CRIT = 90; // Shared by the header and data rows so their columns stay aligned. The // `max-md:min-w` keeps both at the same width below md, where the card scrolls // horizontally; desktop is unaffected by the `max-md:` prefix. @@ -37,15 +36,6 @@ function formatUptime(seconds: number): string { return `${Math.max(1, Math.floor(seconds))}s`; } -type RowState = 'healthy' | 'warn' | 'error'; - -function classifyRow(status: StackStatusEntry['status'], peakCpu: number): RowState { - if (status === 'exited') return 'error'; - if (peakCpu >= CRIT) return 'error'; - if (peakCpu >= WARN) return 'warn'; - return 'healthy'; -} - const stateDot: Record = { healthy: 'bg-success', warn: 'bg-warning', diff --git a/frontend/src/components/dashboard/__tests__/StackHealthTable.test.tsx b/frontend/src/components/dashboard/__tests__/StackHealthTable.test.tsx new file mode 100644 index 00000000..8572ccb7 --- /dev/null +++ b/frontend/src/components/dashboard/__tests__/StackHealthTable.test.tsx @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { classifyRow } from '../classifyRow'; + +describe('classifyRow', () => { + it('marks a partially-crashed stack as warn (degraded), not healthy', () => { + expect(classifyRow('partial', 0)).toBe('warn'); + }); + + it('marks an exited stack as error', () => { + expect(classifyRow('exited', 0)).toBe('error'); + }); + + it('marks a running stack with low CPU as healthy', () => { + expect(classifyRow('running', 0)).toBe('healthy'); + }); + + it('escalates a partial stack with critical CPU to error', () => { + expect(classifyRow('partial', 95)).toBe('error'); + }); +}); diff --git a/frontend/src/components/dashboard/classifyRow.ts b/frontend/src/components/dashboard/classifyRow.ts new file mode 100644 index 00000000..4ceb0c98 --- /dev/null +++ b/frontend/src/components/dashboard/classifyRow.ts @@ -0,0 +1,15 @@ +import type { StackStatusEntry } from './types'; + +export type RowState = 'healthy' | 'warn' | 'error'; + +const WARN = 80; +const CRIT = 90; + +export function classifyRow(status: StackStatusEntry['status'], peakCpu: number): RowState { + if (status === 'exited') return 'error'; + if (peakCpu >= CRIT) return 'error'; + // A partially-crashed stack is degraded, not down: surface it as a warning + // (the same amber as the sidebar PT pill) unless CPU is already critical. + if (status === 'partial' || peakCpu >= WARN) return 'warn'; + return 'healthy'; +} diff --git a/frontend/src/components/dashboard/types.ts b/frontend/src/components/dashboard/types.ts index 195ab68e..5bd837f0 100644 --- a/frontend/src/components/dashboard/types.ts +++ b/frontend/src/components/dashboard/types.ts @@ -71,7 +71,7 @@ export interface NotificationItem { } export interface StackStatusEntry { - status: 'running' | 'exited' | 'unknown'; + status: 'running' | 'exited' | 'unknown' | 'partial'; mainPort?: number; /** Unix seconds of the oldest running container (approximates stack uptime). */ runningSince?: number; diff --git a/frontend/src/components/sidebar/StackList.tsx b/frontend/src/components/sidebar/StackList.tsx index 9b481bc7..9b60cc0c 100644 --- a/frontend/src/components/sidebar/StackList.tsx +++ b/frontend/src/components/sidebar/StackList.tsx @@ -32,6 +32,7 @@ export interface StackListProps { searchQuery: string; stackLabelMap: Record; stackStatuses: Record; + stackCounts: Record; stackUpdates: Record; gitSourcePendingMap: Record; pinnedFiles: string[]; @@ -117,7 +118,7 @@ interface StackListBulkProps { export function StackList(props: StackListProps & StackListBulkProps) { const { - files, isLoading, selectedFile, searchQuery, stackLabelMap, stackStatuses, + files, isLoading, selectedFile, searchQuery, stackLabelMap, stackStatuses, stackCounts, stackUpdates, gitSourcePendingMap, pinnedFiles, isCollapsed, toggleCollapse, isBusy, getDisplayName, onSelectFile, buildMenuCtx, bulkMode, selectedFiles, onToggleSelect, @@ -176,6 +177,8 @@ export function StackList(props: StackListProps & StackListBulkProps) { file={file} displayName={getDisplayName(file)} status={stackStatuses[file] ?? 'unknown'} + running={stackCounts[file]?.running} + total={stackCounts[file]?.total} isBusy={isBusy(file)} isActive={selectedFile === file} labels={stackLabelMap[file] ?? []} diff --git a/frontend/src/components/sidebar/StackRow.tsx b/frontend/src/components/sidebar/StackRow.tsx index 92f6df0a..6c75f5c1 100644 --- a/frontend/src/components/sidebar/StackRow.tsx +++ b/frontend/src/components/sidebar/StackRow.tsx @@ -13,6 +13,9 @@ interface StackRowProps { file: string; displayName: string; status: StackRowStatus; + // Running/total container counts (set for any stack with containers); consumed only for the partial-stack pill tooltip. + running?: number; + total?: number; isBusy: boolean; isActive: boolean; labels: Label[]; @@ -43,7 +46,7 @@ const MAX_VISIBLE_LABELS = 3; export function StackRow(props: StackRowProps) { const { - file, displayName, status, isBusy, isActive, labels, + file, displayName, status, running, total, isBusy, isActive, labels, hasUpdate, hasGitPending, onSelect, kebabSlot, bulkMode = false, isSelected = false, onToggleSelect, } = props; @@ -88,9 +91,15 @@ export function StackRow(props: StackRowProps) { )} - {/* Status pill */} + {/* Status pill. Partial stacks add a hover tooltip with the running/total count. */} - {isBusy ? : statusText(status)} + {isBusy ? ( + + ) : status === 'partial' && running !== undefined && total !== undefined ? ( + {statusText(status)}} label={`${running}/${total} running`} /> + ) : ( + statusText(status) + )} {/* Stack name */} diff --git a/frontend/src/components/sidebar/__tests__/StackRow.test.tsx b/frontend/src/components/sidebar/__tests__/StackRow.test.tsx index 6c239334..1470164e 100644 --- a/frontend/src/components/sidebar/__tests__/StackRow.test.tsx +++ b/frontend/src/components/sidebar/__tests__/StackRow.test.tsx @@ -36,6 +36,26 @@ describe('StackRow', () => { expect(screen.getByText('--')).toBeInTheDocument(); }); + it('renders PT with the amber class for partial', () => { + const { container } = render(); + expect(screen.getByText('PT')).toBeInTheDocument(); + expect(container.querySelector('.text-warning')).not.toBeNull(); + }); + + it('wraps the partial pill in a hover tooltip', () => { + // jsdom does not mount the cursor-follow label, so assert the PT trigger is + // wrapped in the RowTooltip cursor-container; the visible "3/5 running" + // tooltip text is verified in the Playwright drive. + const { container } = render(); + expect(screen.getByText('PT').closest('[data-slot="cursor-container"]')).not.toBeNull(); + expect(container.querySelector('[data-slot="cursor-container"]')).not.toBeNull(); + }); + + it('does not wrap a non-partial pill in a tooltip', () => { + render(); + expect(screen.getByText('UP').closest('[data-slot="cursor-container"]')).toBeNull(); + }); + it('renders cyan rail only when active', () => { const { rerender } = render(); expect(screen.getByTestId('stack-row')).not.toHaveClass('bg-accent/[0.07]'); diff --git a/frontend/src/components/sidebar/__tests__/stack-status-utils.test.ts b/frontend/src/components/sidebar/__tests__/stack-status-utils.test.ts new file mode 100644 index 00000000..405157ec --- /dev/null +++ b/frontend/src/components/sidebar/__tests__/stack-status-utils.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { statusText, statusColor, isDownStatus } from '../stack-status-utils'; + +describe('stack-status-utils', () => { + describe('statusText', () => { + it('maps each status to its pill label', () => { + expect(statusText('running')).toBe('UP'); + expect(statusText('exited')).toBe('DN'); + expect(statusText('partial')).toBe('PT'); + expect(statusText('unknown')).toBe('--'); + }); + }); + + describe('statusColor', () => { + it('maps partial to the amber warning token', () => { + expect(statusColor('partial', false)).toBe('text-warning'); + }); + it('uses the muted spinner color while busy regardless of status', () => { + expect(statusColor('partial', true)).toBe('text-muted-foreground'); + }); + }); + + describe('isDownStatus', () => { + it('treats exited and partial as needing attention', () => { + expect(isDownStatus('exited')).toBe(true); + expect(isDownStatus('partial')).toBe(true); + }); + it('does not treat running, unknown, or a missing status as down', () => { + expect(isDownStatus('running')).toBe(false); + expect(isDownStatus('unknown')).toBe(false); + expect(isDownStatus(undefined)).toBe(false); + }); + }); +}); diff --git a/frontend/src/components/sidebar/stack-status-utils.ts b/frontend/src/components/sidebar/stack-status-utils.ts index 117babd7..6855ca55 100644 --- a/frontend/src/components/sidebar/stack-status-utils.ts +++ b/frontend/src/components/sidebar/stack-status-utils.ts @@ -1,8 +1,9 @@ -export type StackRowStatus = 'running' | 'exited' | 'unknown'; +export type StackRowStatus = 'running' | 'exited' | 'unknown' | 'partial'; export function statusText(status: StackRowStatus): string { if (status === 'running') return 'UP'; if (status === 'exited') return 'DN'; + if (status === 'partial') return 'PT'; return '--'; } @@ -10,5 +11,11 @@ export function statusColor(status: StackRowStatus, isBusy: boolean): string { if (isBusy) return 'text-muted-foreground'; if (status === 'running') return 'text-success'; if (status === 'exited') return 'text-destructive'; + if (status === 'partial') return 'text-warning'; return 'text-stat-icon'; } + +/** Stacks the Down filter surfaces: fully stopped, or partially crashed. */ +export function isDownStatus(status: StackRowStatus | undefined): boolean { + return status === 'exited' || status === 'partial'; +} diff --git a/frontend/src/hooks/useCrossNodeStackSearch.ts b/frontend/src/hooks/useCrossNodeStackSearch.ts index 705b80cd..4f091eb0 100644 --- a/frontend/src/hooks/useCrossNodeStackSearch.ts +++ b/frontend/src/hooks/useCrossNodeStackSearch.ts @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useNodes } from '@/context/NodeContext'; import { fetchForNode } from '@/lib/api'; -export type StackStatus = 'running' | 'exited' | 'unknown'; +export type StackStatus = 'running' | 'exited' | 'unknown' | 'partial'; export interface StackStatusInfo { status: StackStatus;