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
+5
View File
@@ -105,6 +105,8 @@ export default function EditorLayout() {
envFiles,
selectedEnvFile,
containers,
containersLoadStatus,
containersLoadError,
activeTab, setActiveTab,
logsMode, setLogsMode,
gitSourceOpen, setGitSourceOpen,
@@ -616,6 +618,9 @@ export default function EditorLayout() {
stackName={stackName}
isDarkMode={isDarkMode}
containers={containers}
containersLoadStatus={containersLoadStatus}
containersLoadError={containersLoadError}
onRetryContainersLoad={() => { void stackActions.retryContainersLoad(); }}
containerStats={containerStats}
containerStatsError={containerStatsError}
content={content}
@@ -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);
}
@@ -48,6 +48,9 @@ export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection
<StackHealthTable
stackStatuses={data.stackStatuses}
stackStatusesLoadStatus={data.stackStatusesLoadStatus}
stackStatusesLoadError={data.stackStatusesLoadError}
onRetryStackStatuses={data.retryStackStatuses}
metrics={data.metrics}
stackCpuSeries={data.stackCpuSeries}
onNavigateToStack={onNavigateToStack ?? NOOP}
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Sparkline } from '@/components/ui/sparkline';
import { ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Layers } from 'lucide-react';
import { AlertCircle, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Layers, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { StackStatusEntry, MetricPoint, StackCpuSeries } from './types';
import { Skeleton } from '@/components/ui/skeleton';
import type { StackStatusEntry, MetricPoint, StackCpuSeries, StackStatusesLoadStatus } from './types';
import type { StackUpdateInfo } from '@/types/imageUpdates';
import { aggregateCurrentUsage } from './aggregateCurrentUsage';
import { classifyRow, type RowState } from './classifyRow';
@@ -11,6 +12,9 @@ import { updateAvailableBadge, updateAvailableLabel } from '@/lib/updateAvailabl
interface StackHealthTableProps {
stackStatuses: Record<string, StackStatusEntry>;
stackStatusesLoadStatus: StackStatusesLoadStatus;
stackStatusesLoadError: string | null;
onRetryStackStatuses?: () => void;
metrics: MetricPoint[];
stackCpuSeries: Record<string, StackCpuSeries>;
onNavigateToStack: (stackFile: string) => void;
@@ -84,6 +88,9 @@ const sparkStroke: Record<RowState, string> = {
export function StackHealthTable({
stackStatuses,
stackStatusesLoadStatus,
stackStatusesLoadError,
onRetryStackStatuses,
metrics,
stackCpuSeries,
onNavigateToStack,
@@ -173,6 +180,36 @@ export function StackHealthTable({
const stackCount = Object.keys(stackStatuses).length;
if (stackStatusesLoadStatus === 'idle' || stackStatusesLoadStatus === 'loading') {
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-5 space-y-3">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
if (stackStatusesLoadStatus === 'error') {
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel py-10">
<div className="flex flex-col items-center justify-center gap-3 text-stat-subtitle">
<AlertCircle className="h-8 w-8 text-stat-icon" strokeWidth={1.5} aria-hidden />
<p className="text-sm text-center px-4">
{stackStatusesLoadError ?? 'Could not load stack health.'}
</p>
{onRetryStackStatuses && (
<Button type="button" variant="outline" size="sm" onClick={onRetryStackStatuses}>
<RefreshCw className="w-4 h-4" />
Retry
</Button>
)}
</div>
</div>
);
}
if (stackCount === 0) {
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel py-10">
@@ -0,0 +1,52 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { StackHealthTable } from '../StackHealthTable';
describe('StackHealthTable load states', () => {
it('does not show empty copy while loading', () => {
render(
<StackHealthTable
stackStatuses={{}}
stackStatusesLoadStatus="loading"
stackStatusesLoadError={null}
metrics={[]}
stackCpuSeries={{}}
onNavigateToStack={vi.fn()}
/>,
);
expect(screen.queryByText(/No stacks found/i)).toBeNull();
});
it('shows empty copy only after success', () => {
render(
<StackHealthTable
stackStatuses={{}}
stackStatusesLoadStatus="success"
stackStatusesLoadError={null}
metrics={[]}
stackCpuSeries={{}}
onNavigateToStack={vi.fn()}
/>,
);
expect(screen.getByText(/No stacks found/i)).toBeInTheDocument();
});
it('shows retry on error', async () => {
const onRetry = vi.fn();
const user = userEvent.setup();
render(
<StackHealthTable
stackStatuses={{}}
stackStatusesLoadStatus="error"
stackStatusesLoadError="Could not load stack health."
onRetryStackStatuses={onRetry}
metrics={[]}
stackCpuSeries={{}}
onNavigateToStack={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', { name: /retry/i }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
@@ -14,17 +14,16 @@ vi.mock('@/context/NodeContext', () => ({
useNodes: () => useNodesMock(),
}));
// `visibilityInterval` from the live utils library uses
// `document.visibilityState`, which jsdom treats as `prerender` until a
// listener is attached. The polling tests below assert mount-time fetches and
// the debounced refetch path; the long-running interval ticks themselves are
// covered by the existing useNextAutoUpdateRun suite. Replace with a no-op
// cleanup so the hook does not retain a real timer across tests.
// Statuses soft-poll uses visibilityInterval (setInterval). Use a real timer so
// tests can advance past the 10s cadence; skip document.visibility wiring.
vi.mock('@/lib/utils', async () => {
const actual = await vi.importActual<typeof import('@/lib/utils')>('@/lib/utils');
return {
...actual,
visibilityInterval: () => () => {},
visibilityInterval: (fn: () => void, ms: number) => {
const id = setInterval(fn, ms);
return () => clearInterval(id);
},
};
});
@@ -112,3 +111,275 @@ describe('useDashboardData state-invalidate handling', () => {
expect(apiFetchMock).not.toHaveBeenCalled();
});
});
describe('useDashboardData stackStatuses load states', () => {
it('reaches success with an empty map without treating deferral as empty UI state', async () => {
let resolveStatuses: ((r: Response) => void) | null = null;
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return new Promise<Response>((resolve) => { resolveStatuses = resolve; });
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); });
expect(result.current.stackStatusesLoadStatus).toBe('loading');
await act(async () => {
resolveStatuses?.(okJson({}));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual({});
});
it('surfaces error on failed statuses fetch and recovers on retry', async () => {
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return Promise.resolve(new Response('nope', { status: 500 }));
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
expect(result.current.stackStatusesLoadStatus).toBe('error');
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') return Promise.resolve(okJson({}));
return Promise.resolve(okJson(null));
});
await act(async () => {
result.current.retryStackStatuses();
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
});
it('ignores an older soft success after a newer foreground retry', async () => {
const resolvers: Array<(r: Response) => void> = [];
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return new Promise<Response>((resolve) => { resolvers.push(resolve); });
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); });
expect(resolvers).toHaveLength(1);
await act(async () => {
resolvers[0](okJson({}));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
act(() => { fireInvalidate({ scope: 'container' }); });
await act(async () => { vi.advanceTimersByTime(300); });
expect(resolvers).toHaveLength(2);
await act(async () => {
result.current.retryStackStatuses();
await Promise.resolve();
});
expect(resolvers).toHaveLength(3);
const softMap = { 'old.yml': { status: 'exited' as const } };
const retryMap = { 'web.yml': { status: 'running' as const } };
await act(async () => {
resolvers[1](okJson(softMap));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatuses).toEqual({});
await act(async () => {
resolvers[2](okJson(retryMap));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual(retryMap);
});
it('ignores an older soft failure after a newer foreground retry success', async () => {
const resolvers: Array<(r: Response) => void> = [];
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return new Promise<Response>((resolve) => { resolvers.push(resolve); });
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); });
expect(resolvers).toHaveLength(1);
await act(async () => {
resolvers[0](okJson({}));
await Promise.resolve();
await Promise.resolve();
});
act(() => { fireInvalidate({ scope: 'container' }); });
await act(async () => { vi.advanceTimersByTime(300); });
expect(resolvers).toHaveLength(2);
await act(async () => {
result.current.retryStackStatuses();
await Promise.resolve();
});
expect(resolvers).toHaveLength(3);
const retryMap = { 'web.yml': { status: 'running' as const } };
await act(async () => {
resolvers[2](okJson(retryMap));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatuses).toEqual(retryMap);
await act(async () => {
resolvers[1](new Response('nope', { status: 500 }));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual(retryMap);
});
it('lets a slow foreground statuses response commit after soft poll and invalidate ticks', async () => {
const resolvers: Array<(r: Response) => void> = [];
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return new Promise<Response>((resolve) => { resolvers.push(resolve); });
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); });
expect(resolvers).toHaveLength(1);
expect(result.current.stackStatusesLoadStatus).toBe('loading');
act(() => { fireInvalidate({ scope: 'container' }); });
await act(async () => { vi.advanceTimersByTime(300); });
expect(resolvers).toHaveLength(1);
await act(async () => { vi.advanceTimersByTime(10000); });
expect(resolvers).toHaveLength(1);
await act(async () => { vi.advanceTimersByTime(10000); });
expect(resolvers).toHaveLength(1);
const settled = { 'web.yml': { status: 'running' as const } };
await act(async () => {
resolvers[0](okJson(settled));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual(settled);
});
it('turns a soft poll failure into a recoverable error when the prior success was empty', async () => {
const resolvers: Array<(r: Response) => void> = [];
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return new Promise<Response>((resolve) => { resolvers.push(resolve); });
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); });
expect(resolvers).toHaveLength(1);
// Foreground load settles on confirmed-empty.
await act(async () => {
resolvers[0](okJson({}));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual({});
// A subsequent soft poll fails: this must not stay a silent empty state.
act(() => { fireInvalidate({ scope: 'container' }); });
await act(async () => { vi.advanceTimersByTime(300); });
expect(resolvers).toHaveLength(2);
await act(async () => {
resolvers[1](new Response('nope', { status: 500 }));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('error');
});
it('drops a malformed per-stack entry instead of crashing, keeping the rest of a valid response', async () => {
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return Promise.resolve(okJson({
'web.yml': { status: 'running' },
'broken.yml': null,
'also-broken.yml': 'running',
}));
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual({ 'web.yml': { status: 'running' } });
});
it('treats a non-empty response where every entry is malformed as an error, not confirmed-empty', async () => {
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return Promise.resolve(okJson({ 'a.yml': null, 'b.yml': 'running' }));
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
expect(result.current.stackStatusesLoadStatus).toBe('error');
expect(result.current.stackStatuses).toEqual({});
});
});
@@ -96,11 +96,16 @@ export interface StackCpuSeries {
latestValue: number;
}
export type StackStatusesLoadStatus = 'idle' | 'loading' | 'success' | 'error';
export interface DashboardData {
stats: Stats;
systemStats: SystemStats | null;
metrics: MetricPoint[];
stackStatuses: Record<string, StackStatusEntry>;
stackStatusesLoadStatus: StackStatusesLoadStatus;
stackStatusesLoadError: string | null;
retryStackStatuses: () => void;
lastSyncAt: number | null;
nodeCount: number;
stackCpuSeries: Record<string, StackCpuSeries>;
@@ -9,6 +9,7 @@ import type {
StackStatusEntry,
DashboardData,
StackCpuSeries,
StackStatusesLoadStatus,
} from './types';
const DEFAULT_STATS: Stats = { active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 };
@@ -90,6 +91,20 @@ export function buildNetHistory(
// is chosen so a single transient hiccup does not trip the indicator.
const METRICS_STALE_THRESHOLD = 3;
const VALID_STACK_STATUS_VALUES = new Set(['running', 'exited', 'unknown', 'partial']);
// A malformed per-stack entry (null, a bare string, or an object missing
// `status`) must never reach the table renderer, which indexes straight into
// `entry.status` and other fields without a null check.
function isValidStatusEntry(value: unknown): value is StackStatusEntry {
return (
!!value
&& typeof value === 'object'
&& !Array.isArray(value)
&& VALID_STACK_STATUS_VALUES.has((value as { status?: unknown }).status as string)
);
}
export function useDashboardData(): DashboardData {
const { activeNode, nodes } = useNodes();
const nodeId = activeNode?.id;
@@ -98,6 +113,8 @@ export function useDashboardData(): DashboardData {
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [metrics, setMetrics] = useState<MetricPoint[]>([]);
const [stackStatuses, setStackStatuses] = useState<Record<string, StackStatusEntry>>({});
const [stackStatusesLoadStatus, setStackStatusesLoadStatus] = useState<StackStatusesLoadStatus>('idle');
const [stackStatusesLoadError, setStackStatusesLoadError] = useState<string | null>(null);
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
const [metricsStale, setMetricsStale] = useState(false);
@@ -106,6 +123,27 @@ export function useDashboardData(): DashboardData {
const nodeIdRef = useRef(nodeId);
useEffect(() => { nodeIdRef.current = nodeId; }, [nodeId]);
// Whether the last committed success held a non-empty map. Soft poll failures
// keep the prior map only in that case; a confirmed-empty fleet must surface a
// recoverable error instead. Set from each committed success and reset on node
// change, so commitStackStatusesFailure (a useCallback that does not depend on
// stackStatuses) can read it without the map identity.
const hadNonEmptyStatusesRef = useRef(false);
// Latest-request arbitration for /stacks/statuses: polling, invalidation,
// mount, and Retry can overlap; only the current generation may commit.
const stackStatusesFetchGenRef = useRef(0);
// Soft poll/invalidation must not start while any statuses request is in
// flight. Fixed-interval ticks would otherwise bump generation forever and
// starve a slow foreground hydration. Foreground (mount/retry/node change)
// always starts and supersedes obsolete work.
const stackStatusesInFlightRef = useRef(false);
useEffect(() => {
hadNonEmptyStatusesRef.current = false;
}, [nodeId]);
useEffect(() => () => {
stackStatusesFetchGenRef.current += 1;
}, []);
// Consecutive failure counters per live-metrics endpoint. Either reaching
// METRICS_STALE_THRESHOLD trips the metricsStale indicator; the first
// successful response on the failing endpoint clears its own counter and,
@@ -190,19 +228,136 @@ export function useDashboardData(): DashboardData {
return cleanup;
}, [nodeId, fetchJson]);
// Stack statuses: 10s polling, resets on node change
// Stack statuses: 10s polling, resets on node change. Foreground / retry
// expose loading and recoverable error; soft poll failures after success keep
// the prior map so the dashboard never flashes a false empty state.
const isCurrentStatusesFetch = useCallback((
currentNodeId: number | undefined,
generation: number,
) => (
nodeIdRef.current === currentNodeId
&& stackStatusesFetchGenRef.current === generation
), []);
const commitStackStatusesSuccess = useCallback((
currentNodeId: number | undefined,
generation: number,
data: Record<string, StackStatusEntry>,
) => {
if (!isCurrentStatusesFetch(currentNodeId, generation)) return;
setStackStatuses(data);
setStackStatusesLoadStatus('success');
setStackStatusesLoadError(null);
hadNonEmptyStatusesRef.current = Object.keys(data).length > 0;
}, [isCurrentStatusesFetch]);
const commitStackStatusesFailure = useCallback((
currentNodeId: number | undefined,
generation: number,
mode: 'foreground' | 'soft',
failureMessage: string,
) => {
if (!isCurrentStatusesFetch(currentNodeId, generation)) return;
// Soft: prior non-empty rows stay visible on a transient failure. Prior
// confirmed-empty becomes a recoverable error so a soft failure can never
// look identical to "no stacks".
if (mode === 'soft' && hadNonEmptyStatusesRef.current) return;
setStackStatusesLoadStatus('error');
setStackStatusesLoadError(failureMessage);
}, [isCurrentStatusesFetch]);
const fetchStackStatuses = useCallback(async (
currentNodeId: number | undefined,
mode: 'foreground' | 'soft',
) => {
if (nodeIdRef.current !== currentNodeId) return;
if (mode === 'soft' && stackStatusesInFlightRef.current) return;
const generation = ++stackStatusesFetchGenRef.current;
stackStatusesInFlightRef.current = true;
if (mode === 'foreground') {
setStackStatusesLoadStatus('loading');
setStackStatusesLoadError(null);
}
try {
const res = await apiFetch('/stacks/statuses');
if (!isCurrentStatusesFetch(currentNodeId, generation)) return;
if (!res.ok) {
commitStackStatusesFailure(
currentNodeId,
generation,
mode,
`Could not load stack health (${res.status}).`,
);
return;
}
const body: unknown = await res.json();
if (!isCurrentStatusesFetch(currentNodeId, generation)) return;
if (body && typeof body === 'object' && !Array.isArray(body)) {
// Drop any entry isValidStatusEntry rejects rather than trusting the
// whole map: one bad entry must not crash or misrepresent the rest of
// a valid response.
const rawEntries = Object.entries(body as Record<string, unknown>);
const sanitized: Record<string, StackStatusEntry> = {};
for (const [file, entry] of rawEntries) {
if (isValidStatusEntry(entry)) {
sanitized[file] = entry;
} else {
console.error('[Dashboard] Dropped malformed stack status entry:', file, entry);
}
}
// A non-empty map where every entry failed validation is a malformed
// response, not a confirmed-empty fleet: committing it as success
// would be indistinguishable from a genuine empty fleet.
if (rawEntries.length > 0 && Object.keys(sanitized).length === 0) {
commitStackStatusesFailure(
currentNodeId,
generation,
mode,
'Stack health response was invalid.',
);
return;
}
commitStackStatusesSuccess(currentNodeId, generation, sanitized);
return;
}
commitStackStatusesFailure(
currentNodeId,
generation,
mode,
'Stack health response was invalid.',
);
} catch {
if (!isCurrentStatusesFetch(currentNodeId, generation)) return;
commitStackStatusesFailure(
currentNodeId,
generation,
mode,
'Could not load stack health.',
);
} finally {
// Only the latest generation clears the gate. A superseded request that
// finishes later must not reopen soft polling while a newer fetch is live.
if (stackStatusesFetchGenRef.current === generation) {
stackStatusesInFlightRef.current = false;
}
}
}, [commitStackStatusesSuccess, commitStackStatusesFailure, isCurrentStatusesFetch]);
const retryStackStatuses = useCallback(() => {
void fetchStackStatuses(nodeIdRef.current, 'foreground');
}, [fetchStackStatuses]);
useEffect(() => {
setStackStatuses({}); // eslint-disable-line react-hooks/set-state-in-effect
setStackStatusesLoadStatus('loading');
setStackStatusesLoadError(null);
const currentNodeId = nodeId;
const fetchStatuses = async () => {
if (nodeIdRef.current !== currentNodeId) return;
const data = await fetchJson<Record<string, StackStatusEntry>>('/stacks/statuses');
if (data && nodeIdRef.current === currentNodeId) setStackStatuses(data);
};
fetchStatuses();
const cleanup = visibilityInterval(fetchStatuses, 10000);
void fetchStackStatuses(currentNodeId, 'foreground');
const cleanup = visibilityInterval(() => {
void fetchStackStatuses(currentNodeId, 'soft');
}, 10000);
return cleanup;
}, [nodeId, fetchJson]);
}, [nodeId, fetchStackStatuses]);
// React to live `state-invalidate` signals from /ws/notifications: when a
// Docker container event fires (start/stop/die/restart/health), the layout
@@ -219,10 +374,9 @@ export function useDashboardData(): DashboardData {
let invalidateTimer: ReturnType<typeof setTimeout> | null = null;
const refresh = async () => {
if (!active || nodeIdRef.current !== currentNodeId) return;
const [statsData, sysData, statusesData] = await Promise.all([
const [statsData, sysData] = await Promise.all([
fetchJson<Stats>('/stats'),
fetchJson<SystemStats>('/system/stats'),
fetchJson<Record<string, StackStatusEntry>>('/stacks/statuses'),
]);
// Re-check after the await: an unmount or node switch may have
// happened while the fetches were in flight, in which case the
@@ -233,7 +387,7 @@ export function useDashboardData(): DashboardData {
setLastSyncAt(Date.now());
}
if (sysData) setSystemStats(sysData);
if (statusesData) setStackStatuses(statusesData);
await fetchStackStatuses(currentNodeId, 'soft');
};
const onInvalidate = () => {
if (!active || nodeIdRef.current !== currentNodeId) return;
@@ -249,7 +403,7 @@ export function useDashboardData(): DashboardData {
window.removeEventListener('sencho:state-invalidate', onInvalidate);
if (invalidateTimer) clearTimeout(invalidateTimer);
};
}, [nodeId, fetchJson]);
}, [nodeId, fetchJson, fetchStackStatuses]);
const stackCpuSeries = useMemo<Record<string, StackCpuSeries>>(() => {
if (metrics.length === 0) return {};
@@ -337,6 +491,9 @@ export function useDashboardData(): DashboardData {
systemStats,
metrics,
stackStatuses,
stackStatusesLoadStatus,
stackStatusesLoadError,
retryStackStatuses,
lastSyncAt,
nodeCount: nodes.length,
stackCpuSeries,
@@ -141,6 +141,63 @@ export function MobileDashboard({ notifications, headerActions, onNavigateToStac
? `avg ${cpuAvg.toFixed(0)}% last 10m · peak ${cpuPeak.toFixed(0)}%${cpuPeakLabel ? ` @ ${cpuPeakLabel}` : ''}`
: 'collecting metrics…';
let stackHealthBody: ReactNode;
if (data.stackStatusesLoadStatus === 'idle' || data.stackStatusesLoadStatus === 'loading') {
stackHealthBody = (
<p className="px-1 py-4 font-mono text-[12px] text-stat-subtitle">Loading stacks</p>
);
} else if (data.stackStatusesLoadStatus === 'error') {
stackHealthBody = (
<div className="flex flex-col items-start gap-2 px-1 py-4">
<p className="font-mono text-[12px] text-stat-subtitle">
{data.stackStatusesLoadError ?? 'Could not load stack health.'}
</p>
<button
type="button"
onClick={data.retryStackStatuses}
className="font-mono text-[12px] text-brand underline-offset-2 hover:underline"
>
Retry
</button>
</div>
);
} else if (visibleRows.length === 0) {
stackHealthBody = (
<p className="px-1 py-4 font-mono text-[12px] text-stat-subtitle">No stacks yet.</p>
);
} else {
stackHealthBody = (
<div className="flex flex-col gap-px">
{visibleRows.map(row => (
<button
key={row.file}
type="button"
onClick={() => onNavigateToStack(row.file)}
className={`flex min-h-11 items-center gap-2.5 rounded-[7px] px-2.5 py-[9px] text-left ${ROW_TINT[row.state]}`}
>
<StateDot tone={ROW_TONE[row.state]} size={7} glow={row.state !== 'healthy'} />
<span className="min-w-0 flex-1">
<span className="block truncate font-mono text-[13px] text-stat-value">{row.name}</span>
<span className="block truncate font-mono text-[10px] text-stat-icon">{activeNodeName}</span>
</span>
<span className="block h-[18px] w-[60px] shrink-0">
{row.points.length > 1 ? (
<MSparkline values={row.points} height={18} color={ROW_STROKE[row.state]} peak={false} />
) : (
<span className="block h-full w-full border-b border-dashed border-hairline" />
)}
</span>
<span
className={`w-[34px] shrink-0 text-right font-mono tabular-nums text-[12px] ${row.cpu >= 80 ? 'text-warning' : 'text-stat-subtitle'}`}
>
{`${row.cpu.toFixed(0)}%`}
</span>
</button>
))}
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
<Masthead
@@ -202,38 +259,7 @@ export function MobileDashboard({ notifications, headerActions, onNavigateToStac
<SectionHead right={<button type="button" onClick={onViewAllStacks} className="text-brand">view all </button>}>
stack health
</SectionHead>
{visibleRows.length === 0 ? (
<p className="px-1 py-4 font-mono text-[12px] text-stat-subtitle">No stacks yet.</p>
) : (
<div className="flex flex-col gap-px">
{visibleRows.map(row => (
<button
key={row.file}
type="button"
onClick={() => onNavigateToStack(row.file)}
className={`flex min-h-11 items-center gap-2.5 rounded-[7px] px-2.5 py-[9px] text-left ${ROW_TINT[row.state]}`}
>
<StateDot tone={ROW_TONE[row.state]} size={7} glow={row.state !== 'healthy'} />
<span className="min-w-0 flex-1">
<span className="block truncate font-mono text-[13px] text-stat-value">{row.name}</span>
<span className="block truncate font-mono text-[10px] text-stat-icon">{activeNodeName}</span>
</span>
<span className="block h-[18px] w-[60px] shrink-0">
{row.points.length > 1 ? (
<MSparkline values={row.points} height={18} color={ROW_STROKE[row.state]} peak={false} />
) : (
<span className="block h-full w-full border-b border-dashed border-hairline" />
)}
</span>
<span
className={`w-[34px] shrink-0 text-right font-mono tabular-nums text-[12px] ${row.cpu >= 80 ? 'text-warning' : 'text-stat-subtitle'}`}
>
{`${row.cpu.toFixed(0)}%`}
</span>
</button>
))}
</div>
)}
{stackHealthBody}
</div>
</div>
</div>
+11 -5
View File
@@ -18,6 +18,7 @@ import type { StacksLoadStatus } from '@/components/EditorLayout/hooks/useStackL
import { Button } from '@/components/ui/button';
import { useLabelMuteActions } from '@/hooks/useMuteRuleActions';
import { LabelGroupMuteKebab } from '@/components/mute/MuteMenuItems';
import { isStacksListLoading } from './stacksLoadUi';
interface RemoteNodeResult {
nodeId: number;
@@ -169,7 +170,7 @@ export function StackList(props: StackListProps & StackListBulkProps) {
useStackKeyboardShortcuts(selectedFile, buildMenuCtx);
if (isLoading) {
if (isStacksListLoading(isLoading, stacksLoadStatus)) {
return (
<div className="space-y-2 px-2 mt-2">
<Skeleton className="h-8 w-full" />
@@ -196,10 +197,15 @@ export function StackList(props: StackListProps & StackListBulkProps) {
);
}
// First-run prompt only when the node has no stacks at all: no search text and
// no active filter chip, so a filter that happens to match nothing does not
// masquerade as an empty fleet.
if (files.length === 0 && !searchQuery.trim() && filterChip === 'all') {
// First-run prompt only after a successful list load with zero stacks: no
// search text and no active filter chip, so a filter that matches nothing is
// not mistaken for an empty fleet, and idle/loading never looks like first-run.
if (
stacksLoadStatus === 'success'
&& files.length === 0
&& !searchQuery.trim()
&& filterChip === 'all'
) {
return (
<DiscoveryEmptyState
onOpenAdopt={onOpenAdopt}
@@ -11,6 +11,7 @@ import { StackList, type StackListProps } from './StackList';
import type { FilterChip } from './sidebar-types';
import type { BulkAction } from '@/hooks/useBulkStackActions';
import type { SidebarActivitySummary } from './useSidebarActivitySummary';
import { isStacksListSettled } from './stacksLoadUi';
export interface StackSidebarProps {
isDarkMode: boolean;
@@ -99,7 +100,11 @@ export function StackSidebar(props: StackSidebarProps) {
/>
)}
<ScrollArea block className="flex-1 px-2 pb-2">
<div data-stacks-loaded={list.isLoading ? 'false' : 'true'}>
<div
data-stacks-loaded={
isStacksListSettled(list.isLoading, list.stacksLoadStatus) ? 'true' : 'false'
}
>
<StackList {...list} bulkMode={bulkMode} selectedFiles={selectedFiles} onToggleSelect={onToggleSelect} />
</div>
</ScrollArea>
@@ -0,0 +1,106 @@
import type React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { StackList } from '../StackList';
import { isStacksListSettled, isStacksListLoading } from '../stacksLoadUi';
type StackListRenderProps = React.ComponentProps<typeof StackList>;
vi.mock('../DiscoveryEmptyState', () => ({
DiscoveryEmptyState: () => <div data-testid="discovery-empty">No compose projects yet</div>,
}));
vi.mock('@/hooks/useStackKeyboardShortcuts', () => ({
useStackKeyboardShortcuts: () => {},
}));
vi.mock('@/hooks/useMuteRuleActions', () => ({
useLabelMuteActions: () => ({ canMute: false }),
}));
function baseProps(over: Partial<StackListRenderProps> = {}): StackListRenderProps {
return {
files: [],
isLoading: false,
selectedFile: null,
searchQuery: '',
stackLabelMap: {},
stackStatuses: {},
stackCounts: {},
stackUpdates: {},
gitSourcePendingMap: {},
pinnedFiles: [],
isCollapsed: () => false,
toggleCollapse: () => {},
isBusy: () => false,
getDisplayName: (f) => f,
onSelectFile: () => {},
buildMenuCtx: () => ({}) as never,
remoteResults: [],
remoteLoading: false,
remoteFailedNodes: [],
onSelectRemoteFile: () => {},
filterChip: 'all',
stacksLoadStatus: 'idle',
stacksLoadError: null,
bulkMode: false,
selectedFiles: new Set<string>(),
onToggleSelect: () => {},
...over,
};
}
describe('stacksLoadUi helpers', () => {
it('treats success while isLoading as unsettled', () => {
expect(isStacksListSettled(true, 'success')).toBe(false);
expect(isStacksListLoading(true, 'success')).toBe(true);
});
it('settles only when not loading and status is success or error', () => {
expect(isStacksListSettled(false, 'success')).toBe(true);
expect(isStacksListSettled(false, 'error')).toBe(true);
expect(isStacksListSettled(false, 'idle')).toBe(false);
expect(isStacksListSettled(false, 'loading')).toBe(false);
});
});
describe('StackList load gating', () => {
it('does not mount discovery empty while idle', () => {
render(<StackList {...baseProps({ stacksLoadStatus: 'idle', isLoading: false, files: [] })} />);
expect(screen.queryByTestId('discovery-empty')).toBeNull();
});
it('does not mount discovery empty while loading even if status is already success', () => {
render(
<StackList
{...baseProps({ stacksLoadStatus: 'success', isLoading: true, files: [] })}
/>,
);
expect(screen.queryByTestId('discovery-empty')).toBeNull();
});
it('mounts discovery empty only after successful empty load', () => {
render(
<StackList
{...baseProps({ stacksLoadStatus: 'success', isLoading: false, files: [] })}
/>,
);
expect(screen.getByTestId('discovery-empty')).toBeInTheDocument();
});
it('shows retry on error with empty files', () => {
render(
<StackList
{...baseProps({
stacksLoadStatus: 'error',
stacksLoadError: 'Could not load stacks for this node.',
isLoading: false,
files: [],
onRetryStacksLoad: vi.fn(),
})}
/>,
);
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
expect(screen.queryByTestId('discovery-empty')).toBeNull();
});
});
@@ -0,0 +1,17 @@
import type { StacksLoadStatus } from '@/components/EditorLayout/hooks/useStackListState';
/** True when the sidebar stack list request has finished for the active node. */
export function isStacksListSettled(
isLoading: boolean,
stacksLoadStatus: StacksLoadStatus | undefined,
): boolean {
return !isLoading && (stacksLoadStatus === 'success' || stacksLoadStatus === 'error');
}
/** True while the sidebar should show the stack-list skeleton. */
export function isStacksListLoading(
isLoading: boolean,
stacksLoadStatus: StacksLoadStatus | undefined,
): boolean {
return isLoading || stacksLoadStatus === 'idle' || stacksLoadStatus === 'loading' || stacksLoadStatus == null;
}