mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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) {
|
||||
if (containersResult.ok) {
|
||||
detailContainersPendingRef.current = {
|
||||
attemptId,
|
||||
token: `${filename}:${containerCount}`,
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user