mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 12:48:10 +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,
|
envFiles,
|
||||||
selectedEnvFile,
|
selectedEnvFile,
|
||||||
containers,
|
containers,
|
||||||
|
containersLoadStatus,
|
||||||
|
containersLoadError,
|
||||||
activeTab, setActiveTab,
|
activeTab, setActiveTab,
|
||||||
logsMode, setLogsMode,
|
logsMode, setLogsMode,
|
||||||
gitSourceOpen, setGitSourceOpen,
|
gitSourceOpen, setGitSourceOpen,
|
||||||
@@ -616,6 +618,9 @@ export default function EditorLayout() {
|
|||||||
stackName={stackName}
|
stackName={stackName}
|
||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
containers={containers}
|
containers={containers}
|
||||||
|
containersLoadStatus={containersLoadStatus}
|
||||||
|
containersLoadError={containersLoadError}
|
||||||
|
onRetryContainersLoad={() => { void stackActions.retryContainersLoad(); }}
|
||||||
containerStats={containerStats}
|
containerStats={containerStats}
|
||||||
containerStatsError={containerStatsError}
|
containerStatsError={containerStatsError}
|
||||||
content={content}
|
content={content}
|
||||||
|
|||||||
@@ -135,6 +135,9 @@ export interface EditorViewProps {
|
|||||||
envFiles: string[];
|
envFiles: string[];
|
||||||
selectedEnvFile: string;
|
selectedEnvFile: string;
|
||||||
isFileLoading: boolean;
|
isFileLoading: boolean;
|
||||||
|
containersLoadStatus?: 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
containersLoadError?: string | null;
|
||||||
|
onRetryContainersLoad?: () => void;
|
||||||
backupInfo: { exists: boolean; timestamp: number | null };
|
backupInfo: { exists: boolean; timestamp: number | null };
|
||||||
gitSourcePendingMap: Record<string, boolean>;
|
gitSourcePendingMap: Record<string, boolean>;
|
||||||
notifications: NotificationItem[];
|
notifications: NotificationItem[];
|
||||||
@@ -244,6 +247,9 @@ export function EditorView(props: EditorViewProps) {
|
|||||||
envFiles,
|
envFiles,
|
||||||
selectedEnvFile,
|
selectedEnvFile,
|
||||||
isFileLoading,
|
isFileLoading,
|
||||||
|
containersLoadStatus = 'success',
|
||||||
|
containersLoadError = null,
|
||||||
|
onRetryContainersLoad,
|
||||||
backupInfo,
|
backupInfo,
|
||||||
gitSourcePendingMap,
|
gitSourcePendingMap,
|
||||||
notifications,
|
notifications,
|
||||||
@@ -475,6 +481,9 @@ export function EditorView(props: EditorViewProps) {
|
|||||||
onRequestServiceUpdate={onRequestServiceUpdate}
|
onRequestServiceUpdate={onRequestServiceUpdate}
|
||||||
containersExpanded={containersExpanded}
|
containersExpanded={containersExpanded}
|
||||||
onToggleContainersExpand={toggleContainersExpand}
|
onToggleContainersExpand={toggleContainersExpand}
|
||||||
|
containersLoadStatus={containersLoadStatus}
|
||||||
|
containersLoadError={containersLoadError}
|
||||||
|
onRetryContainersLoad={onRetryContainersLoad}
|
||||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||||
/>
|
/>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
@@ -494,6 +503,9 @@ export function EditorView(props: EditorViewProps) {
|
|||||||
serviceUpdateStatuses={serviceUpdateStatuses}
|
serviceUpdateStatuses={serviceUpdateStatuses}
|
||||||
serviceUpdateInProgress={serviceUpdateInProgress}
|
serviceUpdateInProgress={serviceUpdateInProgress}
|
||||||
onRequestServiceUpdate={onRequestServiceUpdate}
|
onRequestServiceUpdate={onRequestServiceUpdate}
|
||||||
|
containersLoadStatus={containersLoadStatus}
|
||||||
|
containersLoadError={containersLoadError}
|
||||||
|
onRetryContainersLoad={onRetryContainersLoad}
|
||||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -233,6 +233,9 @@ export function MobileStackDetail(props: EditorViewProps) {
|
|||||||
serviceUpdateStatuses={serviceUpdateStatuses}
|
serviceUpdateStatuses={serviceUpdateStatuses}
|
||||||
serviceUpdateInProgress={serviceUpdateInProgress}
|
serviceUpdateInProgress={serviceUpdateInProgress}
|
||||||
onRequestServiceUpdate={onRequestServiceUpdate}
|
onRequestServiceUpdate={onRequestServiceUpdate}
|
||||||
|
containersLoadStatus={props.containersLoadStatus}
|
||||||
|
containersLoadError={props.containersLoadError}
|
||||||
|
onRetryContainersLoad={props.onRetryContainersLoad}
|
||||||
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
key={`${activeNode?.id ?? 'local'}:${stackName}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -450,3 +450,63 @@ describe('declared-service headers (multi-service only)', () => {
|
|||||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
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,
|
List,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
|
AlertCircle,
|
||||||
|
RefreshCw
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
|
import { Skeleton } from '../ui/skeleton';
|
||||||
import { CardTitle } from '../ui/card';
|
import { CardTitle } from '../ui/card';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -317,6 +320,9 @@ export interface ContainersHealthProps {
|
|||||||
onRequestServiceUpdate?: (serviceName: string, mode: 'update' | 'rebuild') => void;
|
onRequestServiceUpdate?: (serviceName: string, mode: 'update' | 'rebuild') => void;
|
||||||
containersExpanded?: boolean;
|
containersExpanded?: boolean;
|
||||||
onToggleContainersExpand?: () => void;
|
onToggleContainersExpand?: () => void;
|
||||||
|
containersLoadStatus?: 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
containersLoadError?: string | null;
|
||||||
|
onRetryContainersLoad?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-container health strip: status badge, uptime, ports, and CPU/Mem/Net
|
// Per-container health strip: status badge, uptime, ports, and CPU/Mem/Net
|
||||||
@@ -336,6 +342,9 @@ export function ContainersHealth({
|
|||||||
onRequestServiceUpdate,
|
onRequestServiceUpdate,
|
||||||
containersExpanded,
|
containersExpanded,
|
||||||
onToggleContainersExpand,
|
onToggleContainersExpand,
|
||||||
|
containersLoadStatus = 'success',
|
||||||
|
containersLoadError = null,
|
||||||
|
onRetryContainersLoad,
|
||||||
}: ContainersHealthProps) {
|
}: ContainersHealthProps) {
|
||||||
// Multi-service only (§12): a single-service stack keeps the existing flat
|
// Multi-service only (§12): a single-service stack keeps the existing flat
|
||||||
// layout untouched, including its per-container Start/Stop/Restart kebab.
|
// 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{containerStatsError && safeContainers.length > 0 && (
|
{containerStatsError && safeContainers.length > 0 && (
|
||||||
@@ -754,7 +792,7 @@ export function ContainersHealth({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : safeContainers.length === 0 ? (
|
) : showConfirmedEmpty ? (
|
||||||
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
|
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export function useEditorViewState() {
|
|||||||
const [envFiles, setEnvFiles] = useState<string[]>([]);
|
const [envFiles, setEnvFiles] = useState<string[]>([]);
|
||||||
const [selectedEnvFile, setSelectedEnvFile] = useState<string>('');
|
const [selectedEnvFile, setSelectedEnvFile] = useState<string>('');
|
||||||
const [containers, setContainers] = useState<ContainerInfo[]>([]);
|
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
|
// Declared-service facts for the loaded stack, from the effective Compose
|
||||||
// model. Empty for a single-service stack, an older node without the
|
// model. Empty for a single-service stack, an older node without the
|
||||||
// service-scoped-update capability, or a render failure; all three cases
|
// service-scoped-update capability, or a render failure; all three cases
|
||||||
@@ -67,6 +69,8 @@ export function useEditorViewState() {
|
|||||||
envFiles, setEnvFiles,
|
envFiles, setEnvFiles,
|
||||||
selectedEnvFile, setSelectedEnvFile,
|
selectedEnvFile, setSelectedEnvFile,
|
||||||
containers, setContainers,
|
containers, setContainers,
|
||||||
|
containersLoadStatus, setContainersLoadStatus,
|
||||||
|
containersLoadError, setContainersLoadError,
|
||||||
effectiveServices, setEffectiveServices,
|
effectiveServices, setEffectiveServices,
|
||||||
serviceUpdateInProgress, setServiceUpdateInProgress,
|
serviceUpdateInProgress, setServiceUpdateInProgress,
|
||||||
activeTab, setActiveTab,
|
activeTab, setActiveTab,
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ function makeEditorState(over: Partial<EditorState> = {}): EditorState {
|
|||||||
setEditingCompose: vi.fn(),
|
setEditingCompose: vi.fn(),
|
||||||
setActiveTab: vi.fn(),
|
setActiveTab: vi.fn(),
|
||||||
setContainers: vi.fn(),
|
setContainers: vi.fn(),
|
||||||
|
containers: [],
|
||||||
|
containersLoadStatus: 'idle' as const,
|
||||||
|
containersLoadError: null as string | null,
|
||||||
|
setContainersLoadStatus: vi.fn(),
|
||||||
|
setContainersLoadError: vi.fn(),
|
||||||
setEnvFiles: vi.fn(),
|
setEnvFiles: vi.fn(),
|
||||||
setSelectedEnvFile: vi.fn(),
|
setSelectedEnvFile: vi.fn(),
|
||||||
setEnvExists: 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', () => {
|
describe('useStackActions.deleteStack', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(apiFetch).mockReset();
|
vi.mocked(apiFetch).mockReset();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
beginSpan,
|
beginSpan,
|
||||||
endSpan,
|
endSpan,
|
||||||
flushPendingCommit,
|
flushPendingCommit,
|
||||||
|
markMilestone,
|
||||||
type PendingCommit,
|
type PendingCommit,
|
||||||
type SpanHandle,
|
type SpanHandle,
|
||||||
} from '@/lib/hydrationTiming';
|
} from '@/lib/hydrationTiming';
|
||||||
@@ -22,7 +23,7 @@ import type { RunWithLogParams } from '@/context/DeployFeedbackContext';
|
|||||||
import { parsePath } from '@/lib/router/senchoRoute';
|
import { parsePath } from '@/lib/router/senchoRoute';
|
||||||
import { resolveEnvFilePath } from '@/lib/router/envRoute';
|
import { resolveEnvFilePath } from '@/lib/router/envRoute';
|
||||||
import type { EditorTab, RouteStackLoadResult } from '@/lib/router/routeTypes';
|
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 { NotificationItem } from '../../dashboard/types';
|
||||||
import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog';
|
import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog';
|
||||||
import type {
|
import type {
|
||||||
@@ -423,12 +424,27 @@ export function useStackActions(options: UseStackActionsOptions) {
|
|||||||
// activeView unchanged) still re-run the commit effect.
|
// activeView unchanged) still re-run the commit effect.
|
||||||
const [detailVisibleEpoch, setDetailVisibleEpoch] = useState(0);
|
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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (checkUpdatesIntervalRef.current !== null) {
|
if (checkUpdatesIntervalRef.current !== null) {
|
||||||
clearInterval(checkUpdatesIntervalRef.current);
|
clearInterval(checkUpdatesIntervalRef.current);
|
||||||
}
|
}
|
||||||
loadFileAbortRef.current?.abort();
|
loadFileAbortRef.current?.abort();
|
||||||
|
containersFetchGenRef.current += 1;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -514,31 +530,174 @@ export function useStackActions(options: UseStackActionsOptions) {
|
|||||||
editorState.setSelectedEnvFile('');
|
editorState.setSelectedEnvFile('');
|
||||||
editorState.setEnvExists(false);
|
editorState.setEnvExists(false);
|
||||||
editorState.setContainers([]);
|
editorState.setContainers([]);
|
||||||
|
editorState.setContainersLoadStatus('idle');
|
||||||
|
editorState.setContainersLoadError(null);
|
||||||
|
containersFetchGenRef.current += 1;
|
||||||
editorState.setEffectiveServices([]);
|
editorState.setEffectiveServices([]);
|
||||||
editorState.setServiceUpdateInProgress(null);
|
editorState.setServiceUpdateInProgress(null);
|
||||||
editorState.setIsEditing(false);
|
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
|
// Re-sync the open stack's container list. Used after both successful and
|
||||||
// failed/stalled operations so the detail never shows containers that no
|
// failed/stalled operations so the detail never shows containers that no
|
||||||
// longer reflect reality. Returns true only when the live list was fetched;
|
// 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
|
// 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.
|
// callers (e.g. the recovery panel's Refresh) can report the real outcome.
|
||||||
const refreshSelectedContainers = async (stackName: string, stackFile: string): Promise<boolean> => {
|
// stackName is kept for call-site clarity; the fetch derives the name from stackFile.
|
||||||
if (stackListState.selectedFile !== stackFile) return false;
|
const refreshSelectedContainers = async (_stackName: string, stackFile: string): Promise<boolean> => {
|
||||||
try {
|
if (selectedFileRef.current !== stackFile) return false;
|
||||||
const res = await apiFetch(`/stacks/${stackName}/containers`);
|
const result = await fetchStackContainers(stackFile, 'soft', {
|
||||||
if (!res.ok) return false;
|
expectedFile: stackFile,
|
||||||
const conts = await res.json();
|
expectedNodeId: activeNodeIdRef.current,
|
||||||
editorState.setContainers(Array.isArray(conts) ? conts : []);
|
});
|
||||||
return true;
|
return result.ok;
|
||||||
} 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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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
|
// Stack operations whose failure produces a recovery panel. A failed
|
||||||
// stop/start/delete is not recoverable through retry/restart/rollback.
|
// stop/start/delete is not recoverable through retry/restart/rollback.
|
||||||
const RECOVERABLE_ACTIONS: readonly StackAction[] = ['deploy', 'update', '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) => {
|
const loadBackupState = async (filename: string, signal?: AbortSignal) => {
|
||||||
try {
|
try {
|
||||||
const backupRes = await apiFetch(`/stacks/${filename}/backup`, { signal });
|
const backupRes = await apiFetch(`/stacks/${filename}/backup`, { signal });
|
||||||
@@ -777,6 +892,15 @@ export function useStackActions(options: UseStackActionsOptions) {
|
|||||||
editorState.setIsEditing(false);
|
editorState.setIsEditing(false);
|
||||||
editorState.setEditingCompose(false);
|
editorState.setEditingCompose(false);
|
||||||
editorState.setActiveTab('compose');
|
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 headersSpan: SpanHandle | null = null;
|
||||||
let bodySpan: SpanHandle | null = null;
|
let bodySpan: SpanHandle | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -804,13 +928,22 @@ export function useStackActions(options: UseStackActionsOptions) {
|
|||||||
detailVisiblePendingRef.current = { attemptId, token: filename, proxied };
|
detailVisiblePendingRef.current = { attemptId, token: filename, proxied };
|
||||||
setDetailVisibleEpoch((n) => n + 1);
|
setDetailVisibleEpoch((n) => n + 1);
|
||||||
const envFiles = await loadEnvState(filename, signal);
|
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 (!signal.aborted) {
|
||||||
detailContainersPendingRef.current = {
|
if (containersResult.ok) {
|
||||||
attemptId,
|
detailContainersPendingRef.current = {
|
||||||
token: `${filename}:${containerCount}`,
|
attemptId,
|
||||||
proxied,
|
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 loadBackupState(filename, signal);
|
||||||
await loadEffectiveServicesState(filename, signal);
|
await loadEffectiveServicesState(filename, signal);
|
||||||
@@ -839,6 +972,8 @@ export function useStackActions(options: UseStackActionsOptions) {
|
|||||||
editorState.setOriginalEnvContent('');
|
editorState.setOriginalEnvContent('');
|
||||||
editorState.setEnvEtag(null);
|
editorState.setEnvEtag(null);
|
||||||
editorState.setContainers([]);
|
editorState.setContainers([]);
|
||||||
|
editorState.setContainersLoadStatus('idle');
|
||||||
|
editorState.setContainersLoadError(null);
|
||||||
editorState.setEffectiveServices([]);
|
editorState.setEffectiveServices([]);
|
||||||
return { ok: false };
|
return { ok: false };
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1600,9 +1735,8 @@ export function useStackActions(options: UseStackActionsOptions) {
|
|||||||
const label =
|
const label =
|
||||||
action === 'restart' ? 'restarted' : action === 'stop' ? 'stopped' : 'started';
|
action === 'restart' ? 'restarted' : action === 'stop' ? 'stopped' : 'started';
|
||||||
toast.success(`Service "${serviceName}" ${label}`);
|
toast.success(`Service "${serviceName}" ${label}`);
|
||||||
const cr = await apiFetch(`/stacks/${stackName}/containers`);
|
const selected = selectedFileRef.current;
|
||||||
const conts = await cr.json();
|
if (selected) await refreshSelectedContainers(stackName, selected);
|
||||||
editorState.setContainers(Array.isArray(conts) ? conts : []);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Failed to ${action} service "${serviceName}":`, e);
|
console.error(`Failed to ${action} service "${serviceName}":`, e);
|
||||||
toast.error((e as Error).message || `Failed to ${action} service "${serviceName}"`);
|
toast.error((e as Error).message || `Failed to ${action} service "${serviceName}"`);
|
||||||
@@ -2114,6 +2248,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
|||||||
openStackApp,
|
openStackApp,
|
||||||
resetEditorState,
|
resetEditorState,
|
||||||
refreshSelectedContainers,
|
refreshSelectedContainers,
|
||||||
|
retryContainersLoad,
|
||||||
refreshGitSourcePending,
|
refreshGitSourcePending,
|
||||||
loadFile,
|
loadFile,
|
||||||
loadFileForRoute,
|
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);
|
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 });
|
const headersSpan = beginSpan('fetch_headers', { attemptId, background });
|
||||||
let bodySpan: SpanHandle | null = null;
|
let bodySpan: SpanHandle | null = null;
|
||||||
try {
|
try {
|
||||||
@@ -242,22 +266,17 @@ export function useStackListState() {
|
|||||||
endSpan(headersSpan, { proxied, detail: { status: res.status } });
|
endSpan(headersSpan, { proxied, detail: { status: res.status } });
|
||||||
if (stale()) { abortAttempt(attemptId); return []; }
|
if (stale()) { abortAttempt(attemptId); return []; }
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const message = `Could not load stacks (${res.status})`;
|
return applyStacksFailure(`Could not load stacks (${res.status})`);
|
||||||
if (background && hadSuccessfulListRef.current) {
|
|
||||||
setStacksLoadError(message);
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
setFiles([]);
|
|
||||||
setFilesNodeId(fetchNodeId);
|
|
||||||
setStacksLoadStatus('error');
|
|
||||||
setStacksLoadError(message);
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
bodySpan = beginSpan('body_decode', { attemptId, background, proxied });
|
bodySpan = beginSpan('body_decode', { attemptId, background, proxied });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
endSpan(bodySpan);
|
endSpan(bodySpan);
|
||||||
bodySpan = null;
|
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 });
|
const listDispatch = beginSpan('state_dispatch', { attemptId, background, proxied });
|
||||||
setFiles(fileList);
|
setFiles(fileList);
|
||||||
setFilesNodeId(fetchNodeId);
|
setFilesNodeId(fetchNodeId);
|
||||||
@@ -344,15 +363,7 @@ export function useStackListState() {
|
|||||||
if (listSucceeded && !background) {
|
if (listSucceeded && !background) {
|
||||||
markMilestone('list_hydrated', { attemptId, outcome: 'error', proxied });
|
markMilestone('list_hydrated', { attemptId, outcome: 'error', proxied });
|
||||||
}
|
}
|
||||||
if (background && hadSuccessfulListRef.current) {
|
return applyStacksFailure(message);
|
||||||
setStacksLoadError(message);
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
setFiles([]);
|
|
||||||
setFilesNodeId(fetchNodeId);
|
|
||||||
setStacksLoadStatus('error');
|
|
||||||
setStacksLoadError(message);
|
|
||||||
return [];
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection
|
|||||||
|
|
||||||
<StackHealthTable
|
<StackHealthTable
|
||||||
stackStatuses={data.stackStatuses}
|
stackStatuses={data.stackStatuses}
|
||||||
|
stackStatusesLoadStatus={data.stackStatusesLoadStatus}
|
||||||
|
stackStatusesLoadError={data.stackStatusesLoadError}
|
||||||
|
onRetryStackStatuses={data.retryStackStatuses}
|
||||||
metrics={data.metrics}
|
metrics={data.metrics}
|
||||||
stackCpuSeries={data.stackCpuSeries}
|
stackCpuSeries={data.stackCpuSeries}
|
||||||
onNavigateToStack={onNavigateToStack ?? NOOP}
|
onNavigateToStack={onNavigateToStack ?? NOOP}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Sparkline } from '@/components/ui/sparkline';
|
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 { 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 type { StackUpdateInfo } from '@/types/imageUpdates';
|
||||||
import { aggregateCurrentUsage } from './aggregateCurrentUsage';
|
import { aggregateCurrentUsage } from './aggregateCurrentUsage';
|
||||||
import { classifyRow, type RowState } from './classifyRow';
|
import { classifyRow, type RowState } from './classifyRow';
|
||||||
@@ -11,6 +12,9 @@ import { updateAvailableBadge, updateAvailableLabel } from '@/lib/updateAvailabl
|
|||||||
|
|
||||||
interface StackHealthTableProps {
|
interface StackHealthTableProps {
|
||||||
stackStatuses: Record<string, StackStatusEntry>;
|
stackStatuses: Record<string, StackStatusEntry>;
|
||||||
|
stackStatusesLoadStatus: StackStatusesLoadStatus;
|
||||||
|
stackStatusesLoadError: string | null;
|
||||||
|
onRetryStackStatuses?: () => void;
|
||||||
metrics: MetricPoint[];
|
metrics: MetricPoint[];
|
||||||
stackCpuSeries: Record<string, StackCpuSeries>;
|
stackCpuSeries: Record<string, StackCpuSeries>;
|
||||||
onNavigateToStack: (stackFile: string) => void;
|
onNavigateToStack: (stackFile: string) => void;
|
||||||
@@ -84,6 +88,9 @@ const sparkStroke: Record<RowState, string> = {
|
|||||||
|
|
||||||
export function StackHealthTable({
|
export function StackHealthTable({
|
||||||
stackStatuses,
|
stackStatuses,
|
||||||
|
stackStatusesLoadStatus,
|
||||||
|
stackStatusesLoadError,
|
||||||
|
onRetryStackStatuses,
|
||||||
metrics,
|
metrics,
|
||||||
stackCpuSeries,
|
stackCpuSeries,
|
||||||
onNavigateToStack,
|
onNavigateToStack,
|
||||||
@@ -173,6 +180,36 @@ export function StackHealthTable({
|
|||||||
|
|
||||||
const stackCount = Object.keys(stackStatuses).length;
|
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) {
|
if (stackCount === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel py-10">
|
<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(),
|
useNodes: () => useNodesMock(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// `visibilityInterval` from the live utils library uses
|
// Statuses soft-poll uses visibilityInterval (setInterval). Use a real timer so
|
||||||
// `document.visibilityState`, which jsdom treats as `prerender` until a
|
// tests can advance past the 10s cadence; skip document.visibility wiring.
|
||||||
// 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.
|
|
||||||
vi.mock('@/lib/utils', async () => {
|
vi.mock('@/lib/utils', async () => {
|
||||||
const actual = await vi.importActual<typeof import('@/lib/utils')>('@/lib/utils');
|
const actual = await vi.importActual<typeof import('@/lib/utils')>('@/lib/utils');
|
||||||
return {
|
return {
|
||||||
...actual,
|
...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();
|
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;
|
latestValue: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StackStatusesLoadStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
stats: Stats;
|
stats: Stats;
|
||||||
systemStats: SystemStats | null;
|
systemStats: SystemStats | null;
|
||||||
metrics: MetricPoint[];
|
metrics: MetricPoint[];
|
||||||
stackStatuses: Record<string, StackStatusEntry>;
|
stackStatuses: Record<string, StackStatusEntry>;
|
||||||
|
stackStatusesLoadStatus: StackStatusesLoadStatus;
|
||||||
|
stackStatusesLoadError: string | null;
|
||||||
|
retryStackStatuses: () => void;
|
||||||
lastSyncAt: number | null;
|
lastSyncAt: number | null;
|
||||||
nodeCount: number;
|
nodeCount: number;
|
||||||
stackCpuSeries: Record<string, StackCpuSeries>;
|
stackCpuSeries: Record<string, StackCpuSeries>;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
StackStatusEntry,
|
StackStatusEntry,
|
||||||
DashboardData,
|
DashboardData,
|
||||||
StackCpuSeries,
|
StackCpuSeries,
|
||||||
|
StackStatusesLoadStatus,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const DEFAULT_STATS: Stats = { active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 };
|
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.
|
// is chosen so a single transient hiccup does not trip the indicator.
|
||||||
const METRICS_STALE_THRESHOLD = 3;
|
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 {
|
export function useDashboardData(): DashboardData {
|
||||||
const { activeNode, nodes } = useNodes();
|
const { activeNode, nodes } = useNodes();
|
||||||
const nodeId = activeNode?.id;
|
const nodeId = activeNode?.id;
|
||||||
@@ -98,6 +113,8 @@ export function useDashboardData(): DashboardData {
|
|||||||
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
|
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
|
||||||
const [metrics, setMetrics] = useState<MetricPoint[]>([]);
|
const [metrics, setMetrics] = useState<MetricPoint[]>([]);
|
||||||
const [stackStatuses, setStackStatuses] = useState<Record<string, StackStatusEntry>>({});
|
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 [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
|
||||||
const [metricsStale, setMetricsStale] = useState(false);
|
const [metricsStale, setMetricsStale] = useState(false);
|
||||||
|
|
||||||
@@ -106,6 +123,27 @@ export function useDashboardData(): DashboardData {
|
|||||||
const nodeIdRef = useRef(nodeId);
|
const nodeIdRef = useRef(nodeId);
|
||||||
useEffect(() => { nodeIdRef.current = nodeId; }, [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
|
// Consecutive failure counters per live-metrics endpoint. Either reaching
|
||||||
// METRICS_STALE_THRESHOLD trips the metricsStale indicator; the first
|
// METRICS_STALE_THRESHOLD trips the metricsStale indicator; the first
|
||||||
// successful response on the failing endpoint clears its own counter and,
|
// successful response on the failing endpoint clears its own counter and,
|
||||||
@@ -190,19 +228,136 @@ export function useDashboardData(): DashboardData {
|
|||||||
return cleanup;
|
return cleanup;
|
||||||
}, [nodeId, fetchJson]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setStackStatuses({}); // eslint-disable-line react-hooks/set-state-in-effect
|
setStackStatuses({}); // eslint-disable-line react-hooks/set-state-in-effect
|
||||||
|
setStackStatusesLoadStatus('loading');
|
||||||
|
setStackStatusesLoadError(null);
|
||||||
const currentNodeId = nodeId;
|
const currentNodeId = nodeId;
|
||||||
const fetchStatuses = async () => {
|
void fetchStackStatuses(currentNodeId, 'foreground');
|
||||||
if (nodeIdRef.current !== currentNodeId) return;
|
const cleanup = visibilityInterval(() => {
|
||||||
const data = await fetchJson<Record<string, StackStatusEntry>>('/stacks/statuses');
|
void fetchStackStatuses(currentNodeId, 'soft');
|
||||||
if (data && nodeIdRef.current === currentNodeId) setStackStatuses(data);
|
}, 10000);
|
||||||
};
|
|
||||||
fetchStatuses();
|
|
||||||
const cleanup = visibilityInterval(fetchStatuses, 10000);
|
|
||||||
return cleanup;
|
return cleanup;
|
||||||
}, [nodeId, fetchJson]);
|
}, [nodeId, fetchStackStatuses]);
|
||||||
|
|
||||||
// React to live `state-invalidate` signals from /ws/notifications: when a
|
// React to live `state-invalidate` signals from /ws/notifications: when a
|
||||||
// Docker container event fires (start/stop/die/restart/health), the layout
|
// 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;
|
let invalidateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
if (!active || nodeIdRef.current !== currentNodeId) return;
|
if (!active || nodeIdRef.current !== currentNodeId) return;
|
||||||
const [statsData, sysData, statusesData] = await Promise.all([
|
const [statsData, sysData] = await Promise.all([
|
||||||
fetchJson<Stats>('/stats'),
|
fetchJson<Stats>('/stats'),
|
||||||
fetchJson<SystemStats>('/system/stats'),
|
fetchJson<SystemStats>('/system/stats'),
|
||||||
fetchJson<Record<string, StackStatusEntry>>('/stacks/statuses'),
|
|
||||||
]);
|
]);
|
||||||
// Re-check after the await: an unmount or node switch may have
|
// Re-check after the await: an unmount or node switch may have
|
||||||
// happened while the fetches were in flight, in which case the
|
// happened while the fetches were in flight, in which case the
|
||||||
@@ -233,7 +387,7 @@ export function useDashboardData(): DashboardData {
|
|||||||
setLastSyncAt(Date.now());
|
setLastSyncAt(Date.now());
|
||||||
}
|
}
|
||||||
if (sysData) setSystemStats(sysData);
|
if (sysData) setSystemStats(sysData);
|
||||||
if (statusesData) setStackStatuses(statusesData);
|
await fetchStackStatuses(currentNodeId, 'soft');
|
||||||
};
|
};
|
||||||
const onInvalidate = () => {
|
const onInvalidate = () => {
|
||||||
if (!active || nodeIdRef.current !== currentNodeId) return;
|
if (!active || nodeIdRef.current !== currentNodeId) return;
|
||||||
@@ -249,7 +403,7 @@ export function useDashboardData(): DashboardData {
|
|||||||
window.removeEventListener('sencho:state-invalidate', onInvalidate);
|
window.removeEventListener('sencho:state-invalidate', onInvalidate);
|
||||||
if (invalidateTimer) clearTimeout(invalidateTimer);
|
if (invalidateTimer) clearTimeout(invalidateTimer);
|
||||||
};
|
};
|
||||||
}, [nodeId, fetchJson]);
|
}, [nodeId, fetchJson, fetchStackStatuses]);
|
||||||
|
|
||||||
const stackCpuSeries = useMemo<Record<string, StackCpuSeries>>(() => {
|
const stackCpuSeries = useMemo<Record<string, StackCpuSeries>>(() => {
|
||||||
if (metrics.length === 0) return {};
|
if (metrics.length === 0) return {};
|
||||||
@@ -337,6 +491,9 @@ export function useDashboardData(): DashboardData {
|
|||||||
systemStats,
|
systemStats,
|
||||||
metrics,
|
metrics,
|
||||||
stackStatuses,
|
stackStatuses,
|
||||||
|
stackStatusesLoadStatus,
|
||||||
|
stackStatusesLoadError,
|
||||||
|
retryStackStatuses,
|
||||||
lastSyncAt,
|
lastSyncAt,
|
||||||
nodeCount: nodes.length,
|
nodeCount: nodes.length,
|
||||||
stackCpuSeries,
|
stackCpuSeries,
|
||||||
|
|||||||
@@ -141,6 +141,63 @@ export function MobileDashboard({ notifications, headerActions, onNavigateToStac
|
|||||||
? `avg ${cpuAvg.toFixed(0)}% last 10m · peak ${cpuPeak.toFixed(0)}%${cpuPeakLabel ? ` @ ${cpuPeakLabel}` : ''}`
|
? `avg ${cpuAvg.toFixed(0)}% last 10m · peak ${cpuPeak.toFixed(0)}%${cpuPeakLabel ? ` @ ${cpuPeakLabel}` : ''}`
|
||||||
: 'collecting metrics…';
|
: '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 (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
<Masthead
|
<Masthead
|
||||||
@@ -202,38 +259,7 @@ export function MobileDashboard({ notifications, headerActions, onNavigateToStac
|
|||||||
<SectionHead right={<button type="button" onClick={onViewAllStacks} className="text-brand">view all →</button>}>
|
<SectionHead right={<button type="button" onClick={onViewAllStacks} className="text-brand">view all →</button>}>
|
||||||
stack health
|
stack health
|
||||||
</SectionHead>
|
</SectionHead>
|
||||||
{visibleRows.length === 0 ? (
|
{stackHealthBody}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { StacksLoadStatus } from '@/components/EditorLayout/hooks/useStackL
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useLabelMuteActions } from '@/hooks/useMuteRuleActions';
|
import { useLabelMuteActions } from '@/hooks/useMuteRuleActions';
|
||||||
import { LabelGroupMuteKebab } from '@/components/mute/MuteMenuItems';
|
import { LabelGroupMuteKebab } from '@/components/mute/MuteMenuItems';
|
||||||
|
import { isStacksListLoading } from './stacksLoadUi';
|
||||||
|
|
||||||
interface RemoteNodeResult {
|
interface RemoteNodeResult {
|
||||||
nodeId: number;
|
nodeId: number;
|
||||||
@@ -169,7 +170,7 @@ export function StackList(props: StackListProps & StackListBulkProps) {
|
|||||||
|
|
||||||
useStackKeyboardShortcuts(selectedFile, buildMenuCtx);
|
useStackKeyboardShortcuts(selectedFile, buildMenuCtx);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isStacksListLoading(isLoading, stacksLoadStatus)) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 px-2 mt-2">
|
<div className="space-y-2 px-2 mt-2">
|
||||||
<Skeleton className="h-8 w-full" />
|
<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
|
// First-run prompt only after a successful list load with zero stacks: no
|
||||||
// no active filter chip, so a filter that happens to match nothing does not
|
// search text and no active filter chip, so a filter that matches nothing is
|
||||||
// masquerade as an empty fleet.
|
// not mistaken for an empty fleet, and idle/loading never looks like first-run.
|
||||||
if (files.length === 0 && !searchQuery.trim() && filterChip === 'all') {
|
if (
|
||||||
|
stacksLoadStatus === 'success'
|
||||||
|
&& files.length === 0
|
||||||
|
&& !searchQuery.trim()
|
||||||
|
&& filterChip === 'all'
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<DiscoveryEmptyState
|
<DiscoveryEmptyState
|
||||||
onOpenAdopt={onOpenAdopt}
|
onOpenAdopt={onOpenAdopt}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { StackList, type StackListProps } from './StackList';
|
|||||||
import type { FilterChip } from './sidebar-types';
|
import type { FilterChip } from './sidebar-types';
|
||||||
import type { BulkAction } from '@/hooks/useBulkStackActions';
|
import type { BulkAction } from '@/hooks/useBulkStackActions';
|
||||||
import type { SidebarActivitySummary } from './useSidebarActivitySummary';
|
import type { SidebarActivitySummary } from './useSidebarActivitySummary';
|
||||||
|
import { isStacksListSettled } from './stacksLoadUi';
|
||||||
|
|
||||||
export interface StackSidebarProps {
|
export interface StackSidebarProps {
|
||||||
isDarkMode: boolean;
|
isDarkMode: boolean;
|
||||||
@@ -99,7 +100,11 @@ export function StackSidebar(props: StackSidebarProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ScrollArea block className="flex-1 px-2 pb-2">
|
<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} />
|
<StackList {...list} bulkMode={bulkMode} selectedFiles={selectedFiles} onToggleSelect={onToggleSelect} />
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</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