fix: classify degraded remote stacks as partial on the compatibility path (#1511)

When a remote node's bulk /stacks/statuses is unavailable or returns the
legacy plain-string format, the sidebar derived a stack's status from its
containers by treating "any container running" as healthy. A stack with one
running and one crashed container was shown green UP and excluded from the
Down filter.

Re-derive status from the per-stack container list in both compatibility
cases, mirroring the backend classifier: a stack with a running container and
a genuinely crashed one (dead, or exited/restarting with a non-zero code) is
now partial, while a cleanly finished one-shot container (exit 0) stays
running. Legacy plain-string bulk responses are routed through this path too,
since they have already collapsed the degraded case.
This commit is contained in:
Anso
2026-06-28 22:01:38 -04:00
committed by GitHub
parent a7144d4e71
commit e9c2c0cf8e
3 changed files with 197 additions and 36 deletions
@@ -9,12 +9,36 @@ import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackAction
import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch';
import { SENCHO_LABELS_CHANGED } from '@/lib/events';
import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards';
import type { StackAction, StackActionResult, ContainerInfo } from '../EditorView';
import type { StackAction, StackActionResult } 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 { isDownStatus, classifyContainersStatus, isBulkStatusObjectFormat } from '../../sidebar/stack-status-utils';
import type { StackRowStatus } from '../../sidebar/stack-status-utils';
/** Compatibility path for remote nodes whose `/stacks/statuses` is absent or
* returns the legacy plain-string format: query each stack's containers and
* classify them so a degraded (partial) stack is not reported as healthy. */
async function deriveStatusesFromContainers(
fileList: string[],
): Promise<Record<string, StackRowStatus>> {
const results = await Promise.allSettled(
fileList.map(async (file) => {
const containersRes = await apiFetch(`/stacks/${file}/containers`);
if (!containersRes.ok) return { file, status: 'unknown' as StackRowStatus };
const containers = await containersRes.json();
return {
file,
status: Array.isArray(containers) ? classifyContainersStatus(containers) : 'unknown',
};
}),
);
const out: Record<string, StackRowStatus> = {};
for (const result of results) {
if (result.status === 'fulfilled') out[result.value.file] = result.value.status;
}
return out;
}
interface StackStatus {
[key: string]: StackRowStatus;
}
@@ -170,50 +194,32 @@ export function useStackListState() {
setFiles(fileList);
setFilesNodeId(fetchNodeId);
// Fetch all stack statuses in a single bulk call (falls back to per-stack queries for older remote nodes)
// Fetch all stack statuses in a single bulk call. Only the current object
// format can express `partial`; a node lacking the endpoint or returning
// the legacy plain-string format is re-derived from per-stack containers
// so a crashed container is not hidden behind a healthy sibling.
const statusRes = await apiFetch('/stacks/statuses');
if (stale()) return fileList;
let bulkStatuses: Record<string, StackRowStatus> | null = null;
let bulkStatuses: Record<string, StackRowStatus> = {};
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, running, total })
for (const [key, val] of Object.entries(raw)) {
if (typeof val === 'string') {
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 };
}
const raw: unknown = statusRes.ok ? await statusRes.json() : null;
if (isBulkStatusObjectFormat(raw)) {
for (const [key, val] of Object.entries(raw as Record<string, StackStatusInfo>)) {
bulkStatuses[key] = val.status;
if (val.mainPort) bulkPorts[key] = val.mainPort;
if (val.running !== undefined && val.total !== undefined) {
bulkCounts[key] = { running: val.running, total: val.total };
}
}
} else {
// Fallback: query each stack individually (remote node may not have bulk endpoint)
const statusResults = await Promise.allSettled(
fileList.map(async (file) => {
const containersRes = await apiFetch(`/stacks/${file}/containers`);
if (!containersRes.ok) return { file, status: 'unknown' as const };
const containers = await containersRes.json();
const hasRunning = Array.isArray(containers) && containers.some((c: ContainerInfo) => c.State === 'running');
return { file, status: hasRunning ? 'running' as const : (Array.isArray(containers) && containers.length > 0 ? 'exited' as const : 'unknown' as const) };
})
);
bulkStatuses = {};
for (const result of statusResults) {
if (result.status === 'fulfilled') {
bulkStatuses[result.value.file] = result.value.status;
}
}
bulkStatuses = await deriveStatusesFromContainers(fileList);
}
setStackStatuses(prev => {
const next: StackStatus = {};
for (const file of fileList) {
const status = bulkStatuses?.[file] ?? 'unknown';
const status = bulkStatuses[file] ?? 'unknown';
next[file] = (file in stackActionsRef.current) ? (prev[file] ?? status) : status;
}
return next;
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { statusText, statusColor, isDownStatus } from '../stack-status-utils';
import { statusText, statusColor, isDownStatus, classifyContainersStatus, isBulkStatusObjectFormat } from '../stack-status-utils';
describe('stack-status-utils', () => {
describe('statusText', () => {
@@ -31,4 +31,98 @@ describe('stack-status-utils', () => {
expect(isDownStatus(undefined)).toBe(false);
});
});
describe('classifyContainersStatus', () => {
it('returns unknown for an empty container list', () => {
expect(classifyContainersStatus([])).toBe('unknown');
});
it('returns running when every container is up', () => {
expect(classifyContainersStatus([
{ State: 'running', Status: 'Up 2 hours' },
{ State: 'running', Status: 'Up 2 hours' },
])).toBe('running');
});
it('returns partial when a container has crashed alongside a running one', () => {
expect(classifyContainersStatus([
{ State: 'running', Status: 'Up 2 hours' },
{ State: 'exited', Status: 'Exited (1) 5 minutes ago' },
])).toBe('partial');
});
it('treats a dead container as a crash even with a running sibling', () => {
expect(classifyContainersStatus([
{ State: 'running', Status: 'Up 2 hours' },
{ State: 'dead', Status: 'Dead' },
])).toBe('partial');
});
it('treats a crash-looping container as partial', () => {
expect(classifyContainersStatus([
{ State: 'running', Status: 'Up 1 minute' },
{ State: 'restarting', Status: 'Restarting (1) 3 seconds ago' },
])).toBe('partial');
});
it('does not degrade a stack for a cleanly finished one-shot container', () => {
expect(classifyContainersStatus([
{ State: 'running', Status: 'Up 2 hours' },
{ State: 'exited', Status: 'Exited (0) 1 hour ago' },
])).toBe('running');
});
it('treats an exited container with an unreadable code as a crash', () => {
expect(classifyContainersStatus([
{ State: 'running', Status: 'Up 2 hours' },
{ State: 'exited', Status: 'Exited' },
])).toBe('partial');
});
it('ignores a non-running, non-failed container (created) when a sibling runs', () => {
expect(classifyContainersStatus([
{ State: 'running', Status: 'Up 2 hours' },
{ State: 'created', Status: 'Created' },
])).toBe('running');
});
it('returns exited when no container is running', () => {
expect(classifyContainersStatus([
{ State: 'exited', Status: 'Exited (1) 5 minutes ago' },
{ State: 'created', Status: 'Created' },
])).toBe('exited');
});
});
describe('isBulkStatusObjectFormat', () => {
it('accepts the current object format', () => {
expect(isBulkStatusObjectFormat({
web: { status: 'running', running: 2, total: 2 },
db: { status: 'partial', running: 1, total: 2 },
})).toBe(true);
});
it('treats an empty object (node with no stacks) as the current format', () => {
expect(isBulkStatusObjectFormat({})).toBe(true);
});
it('rejects the legacy plain-string format', () => {
expect(isBulkStatusObjectFormat({ web: 'running', db: 'exited' })).toBe(false);
});
it('rejects a mixed response with any plain-string entry', () => {
expect(isBulkStatusObjectFormat({
web: { status: 'running' },
db: 'running',
})).toBe(false);
});
it('rejects an object entry missing a status field', () => {
expect(isBulkStatusObjectFormat({ web: { running: 1, total: 1 } })).toBe(false);
});
it('rejects null', () => {
expect(isBulkStatusObjectFormat(null)).toBe(false);
});
});
});
@@ -19,3 +19,64 @@ export function statusColor(status: StackRowStatus, isBusy: boolean): string {
export function isDownStatus(status: StackRowStatus | undefined): boolean {
return status === 'exited' || status === 'partial';
}
/** Minimal container shape needed to classify a stack's status. */
interface ContainerStateInfo {
State: string;
Status?: string;
}
/** Exit code parsed from a Docker status string like "Exited (1) 2 hours ago".
* Returns null when no parenthesized code is present (e.g. "Up 3 hours"). */
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 is a genuine crash rather than a clean finish. Mirrors
* the backend bulk-status classifier so the compatibility fallback agrees with
* a current node's `/stacks/statuses`: a dead container always counts, and an
* exited or restarting one counts only with a non-zero (or unreadable) code, so
* a finished init job (exit 0) does not mark its stack degraded. */
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;
}
/** Whether a parsed `/stacks/statuses` response is the current object format
* (`{ status, ... }` per stack) rather than the legacy plain-string format.
* Only the object format can express `partial`; a legacy plain-string response
* has already collapsed a degraded stack into "running", so it (like a missing
* endpoint) must be re-derived from per-stack containers. An empty object is
* the current format for a node with no stacks. */
export function isBulkStatusObjectFormat(raw: unknown): boolean {
return (
raw !== null &&
typeof raw === 'object' &&
Object.values(raw as Record<string, unknown>).every(
(val) => val !== null && typeof val === 'object' && 'status' in val,
)
);
}
/** Derive a stack's row status from its container list, distinguishing a fully-up
* stack from one that is partially degraded (some running, some crashed). Used by
* the compatibility path for remote nodes whose bulk status endpoint is absent or
* predates partial-status support, where trusting "any container running" would
* show a degraded stack as healthy. */
export function classifyContainersStatus(containers: ContainerStateInfo[]): StackRowStatus {
if (containers.length === 0) return 'unknown';
let running = 0;
let failed = 0;
for (const c of containers) {
if (c.State === 'running') running += 1;
else if (isContainerFailed(c.State, c.Status)) failed += 1;
}
if (running === 0) return 'exited';
return failed > 0 ? 'partial' : 'running';
}