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
@@ -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,