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
+2
View File
@@ -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<string, StackRowStatus | undefined>,
stackCounts,
stackUpdates,
gitSourcePendingMap,
pinnedFiles: pinned,
@@ -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<string, number | undefined>) {
function makeOptions(
activeNode: Node | null,
stackPorts: Record<string, number | undefined>,
stackStatuses: Record<string, string> = { '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');
});
});
@@ -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,
@@ -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();
@@ -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',
@@ -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<StackStatus>({});
const [stackPorts, setStackPorts] = useState<Record<string, number | undefined>>({});
const [stackCounts, setStackCounts] = useState<StackCounts>({});
const [labels, setLabels] = useState<StackLabel[]>([]);
const [stackLabelMap, setStackLabelMap] = useState<Record<string, StackLabel[]>>({});
const [filterChip, setFilterChip] = useState<FilterChip>('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<string, 'running' | 'exited' | 'unknown'> | null = null;
let bulkStatuses: Record<string, StackRowStatus> | null = null;
const bulkPorts: Record<string, number | undefined> = {};
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<number, Array<{ file: string; status: 'running' | 'exited' | 'unknown' }>> = {};
const out: Record<number, Array<{ file: string; status: StackRowStatus }>> = {};
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,
@@ -33,6 +33,7 @@ const statusDot: Record<StackStatus, string> = {
running: 'bg-success',
exited: 'bg-muted-foreground',
unknown: 'bg-muted-foreground/60',
partial: 'bg-warning',
};
interface PaletteState {
@@ -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,
@@ -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;
@@ -32,6 +32,7 @@ export interface StackListProps {
searchQuery: string;
stackLabelMap: Record<string, Label[]>;
stackStatuses: Record<string, StackRowStatus | undefined>;
stackCounts: Record<string, { running: number; total: number } | undefined>;
stackUpdates: Record<string, boolean>;
gitSourcePendingMap: Record<string, boolean>;
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] ?? []}
+12 -3
View File
@@ -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) {
)}
</span>
{/* Status pill */}
{/* Status pill. Partial stacks add a hover tooltip with the running/total count. */}
<span className={cn('font-mono text-[10px] shrink-0 w-[22px] flex items-center', statusColor(status, isBusy))}>
{isBusy ? <Loader2 className="w-3 h-3 animate-spin" strokeWidth={2} /> : statusText(status)}
{isBusy ? (
<Loader2 className="w-3 h-3 animate-spin" strokeWidth={2} />
) : status === 'partial' && running !== undefined && total !== undefined ? (
<RowTooltip trigger={<span>{statusText(status)}</span>} label={`${running}/${total} running`} />
) : (
statusText(status)
)}
</span>
{/* Stack name */}
@@ -36,6 +36,26 @@ describe('StackRow', () => {
expect(screen.getByText('--')).toBeInTheDocument();
});
it('renders PT with the amber class for partial', () => {
const { container } = render(<StackRow {...base({ status: 'partial', running: 3, total: 5 })} />);
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(<StackRow {...base({ status: 'partial', running: 3, total: 5 })} />);
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(<StackRow {...base({ status: 'running' })} />);
expect(screen.getByText('UP').closest('[data-slot="cursor-container"]')).toBeNull();
});
it('renders cyan rail only when active', () => {
const { rerender } = render(<StackRow {...base({ isActive: false })} />);
expect(screen.getByTestId('stack-row')).not.toHaveClass('bg-accent/[0.07]');
@@ -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);
});
});
});
@@ -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';
}