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