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.
This commit is contained in:
Anso
2026-06-24 19:57:49 -04:00
committed by GitHub
parent 2eafee3594
commit bb4ddde35a
22 changed files with 423 additions and 40 deletions
@@ -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<string, StackStatusEntry>;
@@ -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<RowState, string> = {
healthy: 'bg-success',
warn: 'bg-warning',
@@ -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');
});
});
@@ -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';
}
+1 -1
View File
@@ -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;