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
@@ -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';
}