From b06dfd71754242e6a09104aa9b61f38ba1bcbc7c Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 22 Jul 2026 08:01:59 -0400 Subject: [PATCH] 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. --- frontend/src/components/EditorLayout.tsx | 5 + .../components/EditorLayout/EditorView.tsx | 12 + .../EditorLayout/MobileStackDetail.tsx | 3 + .../__tests__/ContainersHealth.test.tsx | 60 ++++ .../EditorLayout/editor-view-blocks.tsx | 40 ++- .../EditorLayout/hooks/useEditorViewState.ts | 4 + .../hooks/useStackActions.test.ts | 257 ++++++++++++++++ .../EditorLayout/hooks/useStackActions.ts | 269 +++++++++++++---- .../hooks/useStackListState.test.ts | 140 +++++++++ .../EditorLayout/hooks/useStackListState.ts | 51 ++-- frontend/src/components/HomeDashboard.tsx | 3 + .../components/dashboard/StackHealthTable.tsx | 41 ++- .../StackHealthTable.loadStates.test.tsx | 52 ++++ .../__tests__/useDashboardData.test.tsx | 285 +++++++++++++++++- frontend/src/components/dashboard/types.ts | 5 + .../components/dashboard/useDashboardData.ts | 183 ++++++++++- .../src/components/mobile/MobileDashboard.tsx | 90 ++++-- frontend/src/components/sidebar/StackList.tsx | 16 +- .../src/components/sidebar/StackSidebar.tsx | 7 +- .../sidebar/__tests__/StackList.test.tsx | 106 +++++++ .../src/components/sidebar/stacksLoadUi.ts | 17 ++ 21 files changed, 1498 insertions(+), 148 deletions(-) create mode 100644 frontend/src/components/EditorLayout/hooks/useStackListState.test.ts create mode 100644 frontend/src/components/dashboard/__tests__/StackHealthTable.loadStates.test.tsx create mode 100644 frontend/src/components/sidebar/__tests__/StackList.test.tsx create mode 100644 frontend/src/components/sidebar/stacksLoadUi.ts diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 3411a01b..6ceec945 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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} diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 57c2d93b..4488f336 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -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; 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}`} /> @@ -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}`} /> diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index cc23a6fe..fd57963b 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -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}`} /> diff --git a/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx b/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx index 2cf4dfe8..4d2dd253 100644 --- a/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/ContainersHealth.test.tsx @@ -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( + , + ); + 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( + , + ); + 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( + , + ); + expect(screen.queryByText(/No containers running for this stack/i)).toBeNull(); + await user.click(screen.getByRole('button', { name: /retry/i })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/EditorLayout/editor-view-blocks.tsx b/frontend/src/components/EditorLayout/editor-view-blocks.tsx index e8952d98..c70bb0b3 100644 --- a/frontend/src/components/EditorLayout/editor-view-blocks.tsx +++ b/frontend/src/components/EditorLayout/editor-view-blocks.tsx @@ -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 ( +
+ + + +
+ ); + } + + if (containersLoadStatus === 'error') { + return ( +
+ +

+ {containersLoadError ?? 'Could not load containers.'} +

+ {onRetryContainersLoad && ( + + )} +
+ ); + } + return (
{containerStatsError && safeContainers.length > 0 && ( @@ -754,7 +792,7 @@ export function ContainersHealth({ ); })}
- ) : safeContainers.length === 0 ? ( + ) : showConfirmedEmpty ? (
No containers running for this stack.
) : (
diff --git a/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts b/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts index 9fe30ca2..e2f09f98 100644 --- a/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts +++ b/frontend/src/components/EditorLayout/hooks/useEditorViewState.ts @@ -31,6 +31,8 @@ export function useEditorViewState() { const [envFiles, setEnvFiles] = useState([]); const [selectedEnvFile, setSelectedEnvFile] = useState(''); const [containers, setContainers] = useState([]); + const [containersLoadStatus, setContainersLoadStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + const [containersLoadError, setContainersLoadError] = useState(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, diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index 559cb860..56812468 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -43,6 +43,11 @@ function makeEditorState(over: Partial = {}): 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((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; + let newerPromise!: Promise; + 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((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[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; + 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((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[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; + 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(); diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index 3ced55ee..18f9beff 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -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, + ): Promise => { + 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 => { - 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 => { + 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 => + 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 => { - 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, diff --git a/frontend/src/components/EditorLayout/hooks/useStackListState.test.ts b/frontend/src/components/EditorLayout/hooks/useStackListState.test.ts new file mode 100644 index 00000000..719953e6 --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useStackListState.test.ts @@ -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']); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useStackListState.ts b/frontend/src/components/EditorLayout/hooks/useStackListState.ts index 0d598ffd..020f6fcd 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackListState.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackListState.ts @@ -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); } diff --git a/frontend/src/components/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index 60323cec..6e716a29 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -48,6 +48,9 @@ export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection ; + stackStatusesLoadStatus: StackStatusesLoadStatus; + stackStatusesLoadError: string | null; + onRetryStackStatuses?: () => void; metrics: MetricPoint[]; stackCpuSeries: Record; onNavigateToStack: (stackFile: string) => void; @@ -84,6 +88,9 @@ const sparkStroke: Record = { 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 ( +
+ + + + +
+ ); + } + + if (stackStatusesLoadStatus === 'error') { + return ( +
+
+ +

+ {stackStatusesLoadError ?? 'Could not load stack health.'} +

+ {onRetryStackStatuses && ( + + )} +
+
+ ); + } + if (stackCount === 0) { return (
diff --git a/frontend/src/components/dashboard/__tests__/StackHealthTable.loadStates.test.tsx b/frontend/src/components/dashboard/__tests__/StackHealthTable.loadStates.test.tsx new file mode 100644 index 00000000..3b0f374b --- /dev/null +++ b/frontend/src/components/dashboard/__tests__/StackHealthTable.loadStates.test.tsx @@ -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( + , + ); + expect(screen.queryByText(/No stacks found/i)).toBeNull(); + }); + + it('shows empty copy only after success', () => { + render( + , + ); + expect(screen.getByText(/No stacks found/i)).toBeInTheDocument(); + }); + + it('shows retry on error', async () => { + const onRetry = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole('button', { name: /retry/i })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/dashboard/__tests__/useDashboardData.test.tsx b/frontend/src/components/dashboard/__tests__/useDashboardData.test.tsx index d7b5af4f..bb4bb29c 100644 --- a/frontend/src/components/dashboard/__tests__/useDashboardData.test.tsx +++ b/frontend/src/components/dashboard/__tests__/useDashboardData.test.tsx @@ -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('@/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((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((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((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((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((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({}); + }); +}); diff --git a/frontend/src/components/dashboard/types.ts b/frontend/src/components/dashboard/types.ts index c10704c3..0b0632f2 100644 --- a/frontend/src/components/dashboard/types.ts +++ b/frontend/src/components/dashboard/types.ts @@ -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; + stackStatusesLoadStatus: StackStatusesLoadStatus; + stackStatusesLoadError: string | null; + retryStackStatuses: () => void; lastSyncAt: number | null; nodeCount: number; stackCpuSeries: Record; diff --git a/frontend/src/components/dashboard/useDashboardData.ts b/frontend/src/components/dashboard/useDashboardData.ts index b0e0dbf5..54fd31dd 100644 --- a/frontend/src/components/dashboard/useDashboardData.ts +++ b/frontend/src/components/dashboard/useDashboardData.ts @@ -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(null); const [metrics, setMetrics] = useState([]); const [stackStatuses, setStackStatuses] = useState>({}); + const [stackStatusesLoadStatus, setStackStatusesLoadStatus] = useState('idle'); + const [stackStatusesLoadError, setStackStatusesLoadError] = useState(null); const [lastSyncAt, setLastSyncAt] = useState(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, + ) => { + 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); + const sanitized: Record = {}; + 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>('/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 | 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'), fetchJson('/system/stats'), - fetchJson>('/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>(() => { if (metrics.length === 0) return {}; @@ -337,6 +491,9 @@ export function useDashboardData(): DashboardData { systemStats, metrics, stackStatuses, + stackStatusesLoadStatus, + stackStatusesLoadError, + retryStackStatuses, lastSyncAt, nodeCount: nodes.length, stackCpuSeries, diff --git a/frontend/src/components/mobile/MobileDashboard.tsx b/frontend/src/components/mobile/MobileDashboard.tsx index fcaa2fae..96936e4a 100644 --- a/frontend/src/components/mobile/MobileDashboard.tsx +++ b/frontend/src/components/mobile/MobileDashboard.tsx @@ -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 = ( +

Loading stacks…

+ ); + } else if (data.stackStatusesLoadStatus === 'error') { + stackHealthBody = ( +
+

+ {data.stackStatusesLoadError ?? 'Could not load stack health.'} +

+ +
+ ); + } else if (visibleRows.length === 0) { + stackHealthBody = ( +

No stacks yet.

+ ); + } else { + stackHealthBody = ( +
+ {visibleRows.map(row => ( + + ))} +
+ ); + } + return (
view all →}> stack health - {visibleRows.length === 0 ? ( -

No stacks yet.

- ) : ( -
- {visibleRows.map(row => ( - - ))} -
- )} + {stackHealthBody}
diff --git a/frontend/src/components/sidebar/StackList.tsx b/frontend/src/components/sidebar/StackList.tsx index 086c8e82..956f0e3f 100644 --- a/frontend/src/components/sidebar/StackList.tsx +++ b/frontend/src/components/sidebar/StackList.tsx @@ -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 (
@@ -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 ( )} -
+
diff --git a/frontend/src/components/sidebar/__tests__/StackList.test.tsx b/frontend/src/components/sidebar/__tests__/StackList.test.tsx new file mode 100644 index 00000000..6946edfb --- /dev/null +++ b/frontend/src/components/sidebar/__tests__/StackList.test.tsx @@ -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; + +vi.mock('../DiscoveryEmptyState', () => ({ + DiscoveryEmptyState: () =>
No compose projects yet
, +})); + +vi.mock('@/hooks/useStackKeyboardShortcuts', () => ({ + useStackKeyboardShortcuts: () => {}, +})); + +vi.mock('@/hooks/useMuteRuleActions', () => ({ + useLabelMuteActions: () => ({ canMute: false }), +})); + +function baseProps(over: Partial = {}): 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(), + 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(); + expect(screen.queryByTestId('discovery-empty')).toBeNull(); + }); + + it('does not mount discovery empty while loading even if status is already success', () => { + render( + , + ); + expect(screen.queryByTestId('discovery-empty')).toBeNull(); + }); + + it('mounts discovery empty only after successful empty load', () => { + render( + , + ); + expect(screen.getByTestId('discovery-empty')).toBeInTheDocument(); + }); + + it('shows retry on error with empty files', () => { + render( + , + ); + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + expect(screen.queryByTestId('discovery-empty')).toBeNull(); + }); +}); diff --git a/frontend/src/components/sidebar/stacksLoadUi.ts b/frontend/src/components/sidebar/stacksLoadUi.ts new file mode 100644 index 00000000..c97add5f --- /dev/null +++ b/frontend/src/components/sidebar/stacksLoadUi.ts @@ -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; +}