fix: prevent false empty states during stack hydration (#1659)

* fix: prevent false empty states during stack hydration

Only show confirmed-empty UI after successful stack, status, and container
fetches. Distinguish loading and recoverable error states in the sidebar,
dashboard, and container health panel.

* fix: arbitrate overlapping stack status and container fetches

Prevent older dashboard status and same-owner container responses from overwriting newer load state after concurrent poll, invalidation, retry, or lifecycle refresh.

* fix: do not let soft status polls starve slow foreground loads

Skip soft /stacks/statuses poll and invalidation while a statuses request is already in flight so a deferred foreground hydration can still commit after the ten-second cadence.

* fix(stacks): surface recoverable errors for confirmed-empty soft failures

Sidebar and dashboard soft (background) refresh failures after a
confirmed-empty state silently kept showing the empty/adopt prompt
instead of a recoverable error, since only the error message was set
without flipping the load status. Also reject malformed non-array
/stacks responses instead of coercing them into a confirmed-empty list,
and drop malformed per-stack status entries before they reach the
dashboard table, which previously crashed the entire app on a null
entry.

* fix(stacks): close two review-found gaps in the load-failure fix

A non-empty stack-statuses map where every entry failed validation was
still committed as a confirmed-empty success; it now surfaces as a
recoverable error instead, and dropped entries are logged. The sidebar's
background-failure helper also checked a stale closure snapshot of the
file list, which could wipe a list that had just loaded non-empty in the
same attempt if the follow-up statuses fetch then failed; it now tracks
the freshest committed list for that decision. Also collapses two refs
tracking dashboard status-map emptiness into one.
This commit is contained in:
Anso
2026-07-22 08:01:59 -04:00
committed by GitHub
parent a3edee5e6a
commit b06dfd7175
21 changed files with 1498 additions and 148 deletions
@@ -135,6 +135,9 @@ export interface EditorViewProps {
envFiles: string[];
selectedEnvFile: string;
isFileLoading: boolean;
containersLoadStatus?: 'idle' | 'loading' | 'success' | 'error';
containersLoadError?: string | null;
onRetryContainersLoad?: () => void;
backupInfo: { exists: boolean; timestamp: number | null };
gitSourcePendingMap: Record<string, boolean>;
notifications: NotificationItem[];
@@ -244,6 +247,9 @@ export function EditorView(props: EditorViewProps) {
envFiles,
selectedEnvFile,
isFileLoading,
containersLoadStatus = 'success',
containersLoadError = null,
onRetryContainersLoad,
backupInfo,
gitSourcePendingMap,
notifications,
@@ -475,6 +481,9 @@ export function EditorView(props: EditorViewProps) {
onRequestServiceUpdate={onRequestServiceUpdate}
containersExpanded={containersExpanded}
onToggleContainersExpand={toggleContainersExpand}
containersLoadStatus={containersLoadStatus}
containersLoadError={containersLoadError}
onRetryContainersLoad={onRetryContainersLoad}
key={`${activeNode?.id ?? 'local'}:${stackName}`}
/>
</ScrollArea>
@@ -494,6 +503,9 @@ export function EditorView(props: EditorViewProps) {
serviceUpdateStatuses={serviceUpdateStatuses}
serviceUpdateInProgress={serviceUpdateInProgress}
onRequestServiceUpdate={onRequestServiceUpdate}
containersLoadStatus={containersLoadStatus}
containersLoadError={containersLoadError}
onRetryContainersLoad={onRetryContainersLoad}
key={`${activeNode?.id ?? 'local'}:${stackName}`}
/>
</CardContent>
@@ -233,6 +233,9 @@ export function MobileStackDetail(props: EditorViewProps) {
serviceUpdateStatuses={serviceUpdateStatuses}
serviceUpdateInProgress={serviceUpdateInProgress}
onRequestServiceUpdate={onRequestServiceUpdate}
containersLoadStatus={props.containersLoadStatus}
containersLoadError={props.containersLoadError}
onRetryContainersLoad={props.onRetryContainersLoad}
key={`${activeNode?.id ?? 'local'}:${stackName}`}
/>
</div>
@@ -450,3 +450,63 @@ describe('declared-service headers (multi-service only)', () => {
expect(onToggle).toHaveBeenCalledTimes(1);
});
});
describe('containers load states', () => {
it('does not show empty copy while loading', () => {
render(
<ContainersHealth
safeContainers={[]}
containerStats={{}}
containerStatsError={null}
isAdmin
activeNode={LOCAL_NODE}
openLogViewer={vi.fn()}
openBashModal={vi.fn()}
serviceAction={vi.fn()}
containersLoadStatus="loading"
/>,
);
expect(screen.queryByText(/No containers running for this stack/i)).toBeNull();
expect(screen.queryByText(/No containers running for this service/i)).toBeNull();
});
it('shows confirmed empty only after success', () => {
render(
<ContainersHealth
safeContainers={[]}
containerStats={{}}
containerStatsError={null}
isAdmin
activeNode={LOCAL_NODE}
openLogViewer={vi.fn()}
openBashModal={vi.fn()}
serviceAction={vi.fn()}
containersLoadStatus="success"
/>,
);
expect(screen.getByText(/No containers running for this stack/i)).toBeInTheDocument();
});
it('shows error and invokes retry once', async () => {
const onRetry = vi.fn();
const user = userEvent.setup();
render(
<ContainersHealth
safeContainers={[]}
containerStats={{}}
containerStatsError={null}
isAdmin
activeNode={LOCAL_NODE}
openLogViewer={vi.fn()}
openBashModal={vi.fn()}
serviceAction={vi.fn()}
containersLoadStatus="error"
containersLoadError="Could not load containers."
onRetryContainersLoad={onRetry}
/>,
);
expect(screen.queryByText(/No containers running for this stack/i)).toBeNull();
await user.click(screen.getByRole('button', { name: /retry/i }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
@@ -21,9 +21,12 @@ import {
List,
Maximize2,
Minimize2,
AlertCircle,
RefreshCw
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '../ui/button';
import { Skeleton } from '../ui/skeleton';
import { CardTitle } from '../ui/card';
import {
DropdownMenu,
@@ -317,6 +320,9 @@ export interface ContainersHealthProps {
onRequestServiceUpdate?: (serviceName: string, mode: 'update' | 'rebuild') => void;
containersExpanded?: boolean;
onToggleContainersExpand?: () => void;
containersLoadStatus?: 'idle' | 'loading' | 'success' | 'error';
containersLoadError?: string | null;
onRetryContainersLoad?: () => void;
}
// Per-container health strip: status badge, uptime, ports, and CPU/Mem/Net
@@ -336,6 +342,9 @@ export function ContainersHealth({
onRequestServiceUpdate,
containersExpanded,
onToggleContainersExpand,
containersLoadStatus = 'success',
containersLoadError = null,
onRetryContainersLoad,
}: ContainersHealthProps) {
// Multi-service only (§12): a single-service stack keeps the existing flat
// layout untouched, including its per-container Start/Stop/Restart kebab.
@@ -642,6 +651,35 @@ export function ContainersHealth({
);
};
const showConfirmedEmpty = containersLoadStatus === 'success' && safeContainers.length === 0;
if (containersLoadStatus === 'idle' || containersLoadStatus === 'loading') {
return (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
);
}
if (containersLoadStatus === 'error') {
return (
<div className="rounded-lg border border-card-border bg-card/40 p-4 text-center">
<AlertCircle className="mx-auto mb-2 h-8 w-8 text-muted-foreground" aria-hidden />
<p className="mb-3 text-sm text-muted-foreground">
{containersLoadError ?? 'Could not load containers.'}
</p>
{onRetryContainersLoad && (
<Button type="button" variant="outline" size="sm" onClick={onRetryContainersLoad}>
<RefreshCw className="h-4 w-4" />
Retry
</Button>
)}
</div>
);
}
return (
<div>
{containerStatsError && safeContainers.length > 0 && (
@@ -754,7 +792,7 @@ export function ContainersHealth({
);
})}
</div>
) : safeContainers.length === 0 ? (
) : showConfirmedEmpty ? (
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
) : (
<div className="flex flex-col gap-2">
@@ -31,6 +31,8 @@ export function useEditorViewState() {
const [envFiles, setEnvFiles] = useState<string[]>([]);
const [selectedEnvFile, setSelectedEnvFile] = useState<string>('');
const [containers, setContainers] = useState<ContainerInfo[]>([]);
const [containersLoadStatus, setContainersLoadStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [containersLoadError, setContainersLoadError] = useState<string | null>(null);
// Declared-service facts for the loaded stack, from the effective Compose
// model. Empty for a single-service stack, an older node without the
// service-scoped-update capability, or a render failure; all three cases
@@ -67,6 +69,8 @@ export function useEditorViewState() {
envFiles, setEnvFiles,
selectedEnvFile, setSelectedEnvFile,
containers, setContainers,
containersLoadStatus, setContainersLoadStatus,
containersLoadError, setContainersLoadError,
effectiveServices, setEffectiveServices,
serviceUpdateInProgress, setServiceUpdateInProgress,
activeTab, setActiveTab,
@@ -43,6 +43,11 @@ function makeEditorState(over: Partial<EditorState> = {}): EditorState {
setEditingCompose: vi.fn(),
setActiveTab: vi.fn(),
setContainers: vi.fn(),
containers: [],
containersLoadStatus: 'idle' as const,
containersLoadError: null as string | null,
setContainersLoadStatus: vi.fn(),
setContainersLoadError: vi.fn(),
setEnvFiles: vi.fn(),
setSelectedEnvFile: vi.fn(),
setEnvExists: vi.fn(),
@@ -1219,6 +1224,258 @@ describe('useStackActions.openStackApp', () => {
});
});
describe('container fetch contract', () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
});
it('soft refresh prior empty transitions to error instead of confirmed empty', async () => {
const setContainersLoadStatus = vi.fn();
const setContainersLoadError = vi.fn();
vi.mocked(apiFetch).mockResolvedValue(new Response('fail', { status: 500 }));
const { result } = setup({
editorState: {
containers: [],
containersLoadStatus: 'success',
containersLoadError: null,
setContainersLoadStatus,
setContainersLoadError,
} as never,
});
let ok = true;
await act(async () => {
ok = await result.current.refreshSelectedContainers('web', 'web.yml');
});
expect(ok).toBe(false);
expect(setContainersLoadStatus).toHaveBeenCalledWith('error');
expect(setContainersLoadError).toHaveBeenCalled();
});
it('soft refresh preserves prior non-empty containers on failure', async () => {
const setContainers = vi.fn();
const prior = [{ Id: 'abc', Names: ['/web'], State: 'running' }];
vi.mocked(apiFetch).mockResolvedValue(new Response('fail', { status: 500 }));
const { result } = setup({
editorState: {
containers: prior,
containersLoadStatus: 'success',
containersLoadError: null,
setContainers,
} as never,
});
await act(async () => {
await result.current.refreshSelectedContainers('web', 'web.yml');
});
expect(setContainers).not.toHaveBeenCalled();
});
it('malformed 200 is not treated as success empty in foreground retry', async () => {
const setContainersLoadStatus = vi.fn();
const setContainersLoadError = vi.fn();
vi.mocked(apiFetch).mockResolvedValue(
new Response(JSON.stringify({ not: 'an-array' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const { result } = setup({
editorState: {
containers: [],
setContainersLoadStatus,
setContainersLoadError,
setContainers: vi.fn(),
} as never,
});
await act(async () => {
await result.current.retryContainersLoad();
});
expect(setContainersLoadStatus).toHaveBeenCalledWith('error');
});
it('same-owner soft refreshes: older success does not overwrite newer', async () => {
const resolvers: Array<(r: Response) => void> = [];
vi.mocked(apiFetch).mockImplementation((endpoint: unknown) => {
if (typeof endpoint === 'string' && endpoint.includes('/containers')) {
return new Promise<Response>((resolve) => { resolvers.push(resolve); });
}
return Promise.resolve(new Response('[]', {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
});
const setContainers = vi.fn();
const setContainersLoadStatus = vi.fn();
const { result } = setup({
editorState: {
containers: [],
containersLoadStatus: 'success',
containersLoadError: null,
setContainers,
setContainersLoadStatus,
setContainersLoadError: vi.fn(),
} as never,
});
let olderPromise!: Promise<boolean>;
let newerPromise!: Promise<boolean>;
await act(async () => {
olderPromise = result.current.refreshSelectedContainers('web', 'web.yml');
});
await act(async () => {
newerPromise = result.current.refreshSelectedContainers('web', 'web.yml');
});
expect(resolvers).toHaveLength(2);
const older = [{ Id: 'old', Names: ['/old'], State: 'running' }];
const newer = [{ Id: 'new', Names: ['/new'], State: 'running' }];
const json = (body: unknown) => new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
await act(async () => {
resolvers[1](json(newer));
await newerPromise;
});
expect(setContainers).toHaveBeenLastCalledWith(newer);
expect(setContainersLoadStatus).toHaveBeenCalledWith('success');
setContainers.mockClear();
setContainersLoadStatus.mockClear();
await act(async () => {
resolvers[0](json(older));
await olderPromise;
});
expect(setContainers).not.toHaveBeenCalled();
expect(setContainersLoadStatus).not.toHaveBeenCalled();
});
it('deferred response after stack switch does not apply setters', async () => {
let resolveContainers: ((r: Response) => void) | null = null;
vi.mocked(apiFetch).mockImplementation((endpoint: unknown) => {
if (typeof endpoint === 'string' && endpoint.includes('/containers')) {
return new Promise<Response>((resolve) => { resolveContainers = resolve; });
}
return Promise.resolve(new Response('[]', {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
});
const setContainers = vi.fn();
const setContainersLoadStatus = vi.fn();
const editorState = makeEditorState({
containers: [],
containersLoadStatus: 'success',
containersLoadError: null,
setContainers,
setContainersLoadStatus,
setContainersLoadError: vi.fn(),
});
const { result, rerender } = renderHook(
({ selectedFile }) =>
useStackActions({
editorState,
stackListState: makeStackListState({ selectedFile }),
navState: { setActiveView: vi.fn() } as unknown as NavState,
overlayState: makeOverlay(),
activeNode: { id: 1, type: 'local' } as Parameters<typeof useStackActions>[0]['activeNode'],
setActiveNode: vi.fn(),
nodes: [],
runWithLog,
getLastDeployOutputLine: () => undefined,
diffPreviewEnabled: false,
canEditStack: () => true,
onDeletedOpenStack: vi.fn(),
}),
{ initialProps: { selectedFile: 'web.yml' as string | null } },
);
let refreshPromise!: Promise<boolean>;
await act(async () => {
refreshPromise = result.current.refreshSelectedContainers('web', 'web.yml');
});
expect(resolveContainers).not.toBeNull();
rerender({ selectedFile: 'api.yml' });
await act(async () => { await Promise.resolve(); });
await act(async () => {
resolveContainers?.(new Response(JSON.stringify([{ Id: 'stale', Names: ['/web'], State: 'running' }]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
await refreshPromise;
});
expect(setContainers).not.toHaveBeenCalled();
expect(setContainersLoadStatus).not.toHaveBeenCalledWith('success');
});
it('deferred response after node switch does not apply setters', async () => {
let resolveContainers: ((r: Response) => void) | null = null;
vi.mocked(apiFetch).mockImplementation((endpoint: unknown) => {
if (typeof endpoint === 'string' && endpoint.includes('/containers')) {
return new Promise<Response>((resolve) => { resolveContainers = resolve; });
}
return Promise.resolve(new Response('[]', {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
});
const setContainers = vi.fn();
const setContainersLoadStatus = vi.fn();
const editorState = makeEditorState({
containers: [],
containersLoadStatus: 'success',
containersLoadError: null,
setContainers,
setContainersLoadStatus,
setContainersLoadError: vi.fn(),
});
type NodeArg = Parameters<typeof useStackActions>[0]['activeNode'];
const { result, rerender } = renderHook(
({ activeNode }) =>
useStackActions({
editorState,
stackListState: makeStackListState({ selectedFile: 'web.yml' }),
navState: { setActiveView: vi.fn() } as unknown as NavState,
overlayState: makeOverlay(),
activeNode,
setActiveNode: vi.fn(),
nodes: [],
runWithLog,
getLastDeployOutputLine: () => undefined,
diffPreviewEnabled: false,
canEditStack: () => true,
onDeletedOpenStack: vi.fn(),
}),
{
initialProps: {
activeNode: { id: 1, type: 'local' } as NodeArg,
},
},
);
let refreshPromise!: Promise<boolean>;
await act(async () => {
refreshPromise = result.current.refreshSelectedContainers('web', 'web.yml');
});
rerender({ activeNode: { id: 2, type: 'remote', api_url: 'http://192.168.1.50:1852' } as NodeArg });
await act(async () => { await Promise.resolve(); });
await act(async () => {
resolveContainers?.(new Response(JSON.stringify([{ Id: 'stale', Names: ['/web'], State: 'running' }]), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}));
await refreshPromise;
});
expect(setContainers).not.toHaveBeenCalled();
expect(setContainersLoadStatus).not.toHaveBeenCalledWith('success');
});
});
describe('useStackActions.deleteStack', () => {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
@@ -6,6 +6,7 @@ import {
beginSpan,
endSpan,
flushPendingCommit,
markMilestone,
type PendingCommit,
type SpanHandle,
} from '@/lib/hydrationTiming';
@@ -22,7 +23,7 @@ import type { RunWithLogParams } from '@/context/DeployFeedbackContext';
import { parsePath } from '@/lib/router/senchoRoute';
import { resolveEnvFilePath } from '@/lib/router/envRoute';
import type { EditorTab, RouteStackLoadResult } from '@/lib/router/routeTypes';
import type { StackAction, RecoverableAction, FailureClassification } from '../EditorView';
import type { StackAction, RecoverableAction, FailureClassification, ContainerInfo } from '../EditorView';
import type { NotificationItem } from '../../dashboard/types';
import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog';
import type {
@@ -423,12 +424,27 @@ export function useStackActions(options: UseStackActionsOptions) {
// activeView unchanged) still re-run the commit effect.
const [detailVisibleEpoch, setDetailVisibleEpoch] = useState(0);
// Live ownership for container fetches: render-closure comparisons after an
// await can accept a response for a stack/node that is no longer active.
const selectedFileRef = useRef(stackListState.selectedFile);
const activeNodeIdRef = useRef(activeNode?.id);
const containersRef = useRef(editorState.containers);
// Same-owner arbitration: soft refresh, Retry, and detail load can overlap
// for one stack/node; only the newest generation may apply success or failure.
const containersFetchGenRef = useRef(0);
useEffect(() => {
selectedFileRef.current = stackListState.selectedFile;
activeNodeIdRef.current = activeNode?.id;
containersRef.current = editorState.containers;
});
useEffect(() => {
return () => {
if (checkUpdatesIntervalRef.current !== null) {
clearInterval(checkUpdatesIntervalRef.current);
}
loadFileAbortRef.current?.abort();
containersFetchGenRef.current += 1;
};
}, []);
@@ -514,31 +530,174 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.setSelectedEnvFile('');
editorState.setEnvExists(false);
editorState.setContainers([]);
editorState.setContainersLoadStatus('idle');
editorState.setContainersLoadError(null);
containersFetchGenRef.current += 1;
editorState.setEffectiveServices([]);
editorState.setServiceUpdateInProgress(null);
editorState.setIsEditing(false);
};
type ContainersFetchMode = 'foreground' | 'soft';
type ContainersFetchResult =
| { ok: true; containers: ContainerInfo[] }
| { ok: false; reason: 'http' | 'malformed' | 'network' | 'aborted' | 'stale'; error?: string };
type ContainersFetchOwnership = {
signal?: AbortSignal;
attemptId?: string;
expectedFile: string;
expectedNodeId: number | undefined;
generation: number;
};
const ownershipStillValid = (
ownership: ContainersFetchOwnership,
): 'ok' | 'aborted' | 'stale' => {
if (ownership.signal?.aborted) return 'aborted';
if (selectedFileRef.current !== ownership.expectedFile) return 'stale';
if (activeNodeIdRef.current !== ownership.expectedNodeId) return 'stale';
if (containersFetchGenRef.current !== ownership.generation) return 'stale';
if (
ownership.attemptId !== undefined
&& detailAttemptRef.current !== ownership.attemptId
) {
return 'stale';
}
return 'ok';
};
const applyContainersFetchFailure = (mode: ContainersFetchMode, message: string) => {
if (mode === 'foreground') {
editorState.setContainers([]);
editorState.setContainersLoadStatus('error');
editorState.setContainersLoadError(message);
return;
}
// Soft: prior non-empty cards stay visible. Prior confirmed-empty becomes a
// recoverable error so soft failure never keeps "No containers running".
if (containersRef.current.length === 0) {
editorState.setContainersLoadStatus('error');
editorState.setContainersLoadError(message);
}
};
const fetchStackContainers = async (
stackFile: string,
mode: ContainersFetchMode,
ownership: Omit<ContainersFetchOwnership, 'generation'>,
): Promise<ContainersFetchResult> => {
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
const owned: ContainersFetchOwnership = {
...ownership,
generation: ++containersFetchGenRef.current,
};
let headersSpan: SpanHandle | null = null;
let bodySpan: SpanHandle | null = null;
if (mode === 'foreground') {
editorState.setContainersLoadStatus('loading');
editorState.setContainersLoadError(null);
}
try {
headersSpan = owned.attemptId
? beginSpan('fetch_headers', { attemptId: owned.attemptId })
: null;
const containersRes = await apiFetch(`/stacks/${stackName}/containers`, {
signal: owned.signal,
nodeId: owned.expectedNodeId ?? null,
});
const hopProxied = containersRes.headers.get('x-sencho-proxy') === '1';
if (headersSpan !== null) {
endSpan(headersSpan, { proxied: hopProxied, detail: { status: containersRes.status } });
headersSpan = null;
}
const afterHeaders = ownershipStillValid(owned);
if (afterHeaders !== 'ok') {
return { ok: false, reason: afterHeaders };
}
if (!containersRes.ok) {
const message = `Could not load containers (${containersRes.status}).`;
applyContainersFetchFailure(mode, message);
return { ok: false, reason: 'http', error: message };
}
bodySpan = owned.attemptId
? beginSpan('body_decode', { attemptId: owned.attemptId, proxied: hopProxied })
: null;
const conts: unknown = await containersRes.json();
if (bodySpan !== null) {
endSpan(bodySpan);
bodySpan = null;
}
const afterBody = ownershipStillValid(owned);
if (afterBody !== 'ok') {
return { ok: false, reason: afterBody };
}
if (!Array.isArray(conts)) {
const message = 'Container list response was invalid.';
applyContainersFetchFailure(mode, message);
return { ok: false, reason: 'malformed', error: message };
}
const list = conts as ContainerInfo[];
const dispatchSpan = owned.attemptId
? beginSpan('state_dispatch', { attemptId: owned.attemptId, proxied: hopProxied })
: null;
editorState.setContainers(list);
editorState.setContainersLoadStatus('success');
editorState.setContainersLoadError(null);
if (dispatchSpan !== null) endSpan(dispatchSpan);
return { ok: true, containers: list };
} catch (error) {
if (headersSpan !== null) endSpan(headersSpan, { outcome: 'error' });
if (bodySpan !== null) endSpan(bodySpan, { outcome: 'error' });
if (isAbortError(error) || owned.signal?.aborted) {
return { ok: false, reason: 'aborted' };
}
const afterCatch = ownershipStillValid(owned);
if (afterCatch !== 'ok') {
return { ok: false, reason: afterCatch };
}
console.error('Failed to load containers:', error);
const message = 'Could not load containers.';
applyContainersFetchFailure(mode, message);
return { ok: false, reason: 'network', error: message };
}
};
// Re-sync the open stack's container list. Used after both successful and
// failed/stalled operations so the detail never shows containers that no
// longer reflect reality. Returns true only when the live list was fetched;
// false on a non-applicable stack, a non-ok response, or a network error, so
// callers (e.g. the recovery panel's Refresh) can report the real outcome.
const refreshSelectedContainers = async (stackName: string, stackFile: string): Promise<boolean> => {
if (stackListState.selectedFile !== stackFile) return false;
try {
const res = await apiFetch(`/stacks/${stackName}/containers`);
if (!res.ok) return false;
const conts = await res.json();
editorState.setContainers(Array.isArray(conts) ? conts : []);
return true;
} catch {
// Non-critical when called from an action's failure path: refreshStacks(true)
// in the caller's finally still reconciles the sidebar status.
return false;
}
// stackName is kept for call-site clarity; the fetch derives the name from stackFile.
const refreshSelectedContainers = async (_stackName: string, stackFile: string): Promise<boolean> => {
if (selectedFileRef.current !== stackFile) return false;
const result = await fetchStackContainers(stackFile, 'soft', {
expectedFile: stackFile,
expectedNodeId: activeNodeIdRef.current,
});
return result.ok;
};
const retryContainersLoad = async () => {
const stackFile = selectedFileRef.current;
if (!stackFile) return;
await fetchStackContainers(stackFile, 'foreground', {
expectedFile: stackFile,
expectedNodeId: activeNodeIdRef.current,
});
};
const loadContainerState = (
filename: string,
signal?: AbortSignal,
attemptId?: string,
): Promise<ContainersFetchResult> =>
fetchStackContainers(filename, 'foreground', {
signal,
attemptId,
expectedFile: filename,
expectedNodeId: activeNodeIdRef.current,
});
// Stack operations whose failure produces a recovery panel. A failed
// stop/start/delete is not recoverable through retry/restart/rollback.
const RECOVERABLE_ACTIONS: readonly StackAction[] = ['deploy', 'update', 'restart', 'rollback'];
@@ -659,50 +818,6 @@ export function useStackActions(options: UseStackActionsOptions) {
}
};
const loadContainerState = async (
filename: string,
signal?: AbortSignal,
attemptId?: string,
proxied?: boolean,
): Promise<number> => {
let headersSpan: SpanHandle | null = null;
let bodySpan: SpanHandle | null = null;
try {
headersSpan = attemptId
? beginSpan('fetch_headers', { attemptId, proxied })
: null;
const containersRes = await apiFetch(`/stacks/${filename}/containers`, { signal });
const hopProxied = containersRes.headers.get('x-sencho-proxy') === '1' || proxied === true;
if (headersSpan !== null) {
endSpan(headersSpan, { proxied: hopProxied, detail: { status: containersRes.status } });
headersSpan = null;
}
if (signal?.aborted) return 0;
bodySpan = attemptId
? beginSpan('body_decode', { attemptId, proxied: hopProxied })
: null;
const conts = await containersRes.json();
if (bodySpan !== null) {
endSpan(bodySpan);
bodySpan = null;
}
const list = Array.isArray(conts) ? conts : [];
const dispatchSpan = attemptId
? beginSpan('state_dispatch', { attemptId, proxied: hopProxied })
: null;
editorState.setContainers(list);
if (dispatchSpan !== null) endSpan(dispatchSpan);
return list.length;
} catch (error) {
if (headersSpan !== null) endSpan(headersSpan, { outcome: 'error' });
if (bodySpan !== null) endSpan(bodySpan, { outcome: 'error' });
if (isAbortError(error)) return 0;
console.error('Failed to load containers:', error);
editorState.setContainers([]);
return 0;
}
};
const loadBackupState = async (filename: string, signal?: AbortSignal) => {
try {
const backupRes = await apiFetch(`/stacks/${filename}/backup`, { signal });
@@ -777,6 +892,15 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.setIsEditing(false);
editorState.setEditingCompose(false);
editorState.setActiveTab('compose');
// Clear prior stack health before the first request so compose/env hydration
// never shows another stack's containers or service grouping. Bump the
// containers fetch generation so an in-flight soft refresh cannot rewrite
// this cleared state before loadContainerState claims a newer generation.
editorState.setContainers([]);
editorState.setEffectiveServices([]);
editorState.setContainersLoadError(null);
editorState.setContainersLoadStatus('loading');
containersFetchGenRef.current += 1;
let headersSpan: SpanHandle | null = null;
let bodySpan: SpanHandle | null = null;
try {
@@ -804,13 +928,22 @@ export function useStackActions(options: UseStackActionsOptions) {
detailVisiblePendingRef.current = { attemptId, token: filename, proxied };
setDetailVisibleEpoch((n) => n + 1);
const envFiles = await loadEnvState(filename, signal);
const containerCount = await loadContainerState(filename, signal, attemptId, proxied);
const containersResult = await loadContainerState(filename, signal, attemptId);
if (!signal.aborted) {
detailContainersPendingRef.current = {
attemptId,
token: `${filename}:${containerCount}`,
proxied,
};
if (containersResult.ok) {
detailContainersPendingRef.current = {
attemptId,
token: `${filename}:${containersResult.containers.length}`,
proxied,
};
} else if (containersResult.reason !== 'aborted' && containersResult.reason !== 'stale') {
markMilestone('detail_containers_ready', {
attemptId,
outcome: 'error',
proxied,
detail: { reason: containersResult.reason },
});
}
}
await loadBackupState(filename, signal);
await loadEffectiveServicesState(filename, signal);
@@ -839,6 +972,8 @@ export function useStackActions(options: UseStackActionsOptions) {
editorState.setOriginalEnvContent('');
editorState.setEnvEtag(null);
editorState.setContainers([]);
editorState.setContainersLoadStatus('idle');
editorState.setContainersLoadError(null);
editorState.setEffectiveServices([]);
return { ok: false };
} finally {
@@ -1600,9 +1735,8 @@ export function useStackActions(options: UseStackActionsOptions) {
const label =
action === 'restart' ? 'restarted' : action === 'stop' ? 'stopped' : 'started';
toast.success(`Service "${serviceName}" ${label}`);
const cr = await apiFetch(`/stacks/${stackName}/containers`);
const conts = await cr.json();
editorState.setContainers(Array.isArray(conts) ? conts : []);
const selected = selectedFileRef.current;
if (selected) await refreshSelectedContainers(stackName, selected);
} catch (e) {
console.error(`Failed to ${action} service "${serviceName}":`, e);
toast.error((e as Error).message || `Failed to ${action} service "${serviceName}"`);
@@ -2114,6 +2248,7 @@ export function useStackActions(options: UseStackActionsOptions) {
openStackApp,
resetEditorState,
refreshSelectedContainers,
retryContainersLoad,
refreshGitSourcePending,
loadFile,
loadFileForRoute,
@@ -0,0 +1,140 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
const apiFetchMock = vi.fn();
vi.mock('@/lib/api', () => ({
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
const useNodesMock = vi.fn();
vi.mock('@/context/NodeContext', () => ({
useNodes: () => useNodesMock(),
}));
import { useStackListState } from './useStackListState';
function okJson(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
function notFound(): Response {
return new Response('not found', { status: 404 });
}
beforeEach(() => {
apiFetchMock.mockReset();
useNodesMock.mockReset();
useNodesMock.mockReturnValue({
activeNode: { id: 1, name: 'Local', type: 'local' },
nodes: [{ id: 1, name: 'Local', type: 'local' }],
});
});
describe('useStackListState.refreshStacks failure classification', () => {
it('rejects a malformed (non-array) successful /stacks response as an error, not confirmed-empty', async () => {
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stacks') return Promise.resolve(okJson({}));
return Promise.resolve(notFound());
});
const { result } = renderHook(() => useStackListState());
await act(async () => {
await result.current.refreshStacks();
});
expect(result.current.stacksLoadStatus).toBe('error');
expect(result.current.files).toEqual([]);
});
it('surfaces a recoverable error on a background failure after the list was already confirmed empty', async () => {
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stacks') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') return Promise.resolve(okJson({}));
return Promise.resolve(notFound());
});
const { result } = renderHook(() => useStackListState());
await act(async () => {
await result.current.refreshStacks();
});
expect(result.current.stacksLoadStatus).toBe('success');
expect(result.current.files).toEqual([]);
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stacks') return Promise.resolve(new Response('fail', { status: 500 }));
return Promise.resolve(notFound());
});
await act(async () => {
await result.current.refreshStacks(true);
});
expect(result.current.stacksLoadStatus).toBe('error');
});
it('preserves a non-empty list on a background failure (soft-refresh semantics unchanged)', async () => {
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stacks') return Promise.resolve(okJson(['web.yml']));
if (endpoint === '/stacks/statuses') return Promise.resolve(okJson({ 'web.yml': { status: 'running' } }));
return Promise.resolve(notFound());
});
const { result } = renderHook(() => useStackListState());
await act(async () => {
await result.current.refreshStacks();
});
expect(result.current.stacksLoadStatus).toBe('success');
expect(result.current.files).toEqual(['web.yml']);
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stacks') return Promise.resolve(new Response('fail', { status: 500 }));
return Promise.resolve(notFound());
});
await act(async () => {
await result.current.refreshStacks(true);
});
expect(result.current.stacksLoadStatus).toBe('success');
expect(result.current.files).toEqual(['web.yml']);
expect(result.current.stacksLoadError).toBe('Could not load stacks (500)');
});
it('keeps a list that just loaded non-empty when the follow-up statuses fetch throws in the same background refresh', async () => {
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stacks') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') return Promise.resolve(okJson({}));
return Promise.resolve(notFound());
});
const { result } = renderHook(() => useStackListState());
await act(async () => {
await result.current.refreshStacks();
});
expect(result.current.files).toEqual([]);
// A background refresh discovers a real stack, but decoding the
// follow-up /stacks/statuses call throws. The just-committed non-empty
// list must survive: only the closure-stale `files` from before this
// call was empty, not the list this attempt just fetched.
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stacks') return Promise.resolve(okJson(['web.yml']));
if (endpoint === '/stacks/statuses') return Promise.reject(new Error('network error'));
return Promise.resolve(notFound());
});
await act(async () => {
await result.current.refreshStacks(true);
});
expect(result.current.files).toEqual(['web.yml']);
});
});
@@ -234,6 +234,30 @@ export function useStackListState() {
setStacksLoadError(null);
}
// Tracks the most recently committed list for this attempt: `files` (the
// render-time closure) is stale once the list itself has just succeeded
// within this same call, e.g. the list decodes fine but the follow-up
// /stacks/statuses decode then throws. Seeded from `files` so a failure
// that happens before the list ever loads still consults the prior state.
let latestFileList = files;
// Soft (background) failure keeps a non-empty list visible, matching the
// soft-failure handling in applyContainersFetchFailure (useStackActions.ts).
// A list that was already confirmed empty must not stay masquerading as
// empty: it becomes a recoverable error instead, since a soft failure is
// otherwise indistinguishable from "still no stacks".
const applyStacksFailure = (message: string): string[] => {
if (background && hadSuccessfulListRef.current && latestFileList.length > 0) {
setStacksLoadError(message);
return latestFileList;
}
setFiles([]);
setFilesNodeId(fetchNodeId);
setStacksLoadStatus('error');
setStacksLoadError(message);
return [];
};
const headersSpan = beginSpan('fetch_headers', { attemptId, background });
let bodySpan: SpanHandle | null = null;
try {
@@ -242,22 +266,17 @@ export function useStackListState() {
endSpan(headersSpan, { proxied, detail: { status: res.status } });
if (stale()) { abortAttempt(attemptId); return []; }
if (!res.ok) {
const message = `Could not load stacks (${res.status})`;
if (background && hadSuccessfulListRef.current) {
setStacksLoadError(message);
return files;
}
setFiles([]);
setFilesNodeId(fetchNodeId);
setStacksLoadStatus('error');
setStacksLoadError(message);
return [];
return applyStacksFailure(`Could not load stacks (${res.status})`);
}
bodySpan = beginSpan('body_decode', { attemptId, background, proxied });
const data = await res.json();
endSpan(bodySpan);
bodySpan = null;
const fileList: string[] = Array.isArray(data) ? data : [];
if (!Array.isArray(data)) {
return applyStacksFailure('Stack list response was invalid.');
}
const fileList: string[] = data;
latestFileList = fileList;
const listDispatch = beginSpan('state_dispatch', { attemptId, background, proxied });
setFiles(fileList);
setFilesNodeId(fetchNodeId);
@@ -344,15 +363,7 @@ export function useStackListState() {
if (listSucceeded && !background) {
markMilestone('list_hydrated', { attemptId, outcome: 'error', proxied });
}
if (background && hadSuccessfulListRef.current) {
setStacksLoadError(message);
return files;
}
setFiles([]);
setFilesNodeId(fetchNodeId);
setStacksLoadStatus('error');
setStacksLoadError(message);
return [];
return applyStacksFailure(message);
} finally {
setIsLoading(false);
}