fix: prevent false empty states during stack hydration (#1659)

* fix: prevent false empty states during stack hydration

Only show confirmed-empty UI after successful stack, status, and container
fetches. Distinguish loading and recoverable error states in the sidebar,
dashboard, and container health panel.

* fix: arbitrate overlapping stack status and container fetches

Prevent older dashboard status and same-owner container responses from overwriting newer load state after concurrent poll, invalidation, retry, or lifecycle refresh.

* fix: do not let soft status polls starve slow foreground loads

Skip soft /stacks/statuses poll and invalidation while a statuses request is already in flight so a deferred foreground hydration can still commit after the ten-second cadence.

* fix(stacks): surface recoverable errors for confirmed-empty soft failures

Sidebar and dashboard soft (background) refresh failures after a
confirmed-empty state silently kept showing the empty/adopt prompt
instead of a recoverable error, since only the error message was set
without flipping the load status. Also reject malformed non-array
/stacks responses instead of coercing them into a confirmed-empty list,
and drop malformed per-stack status entries before they reach the
dashboard table, which previously crashed the entire app on a null
entry.

* fix(stacks): close two review-found gaps in the load-failure fix

A non-empty stack-statuses map where every entry failed validation was
still committed as a confirmed-empty success; it now surfaces as a
recoverable error instead, and dropped entries are logged. The sidebar's
background-failure helper also checked a stale closure snapshot of the
file list, which could wipe a list that had just loaded non-empty in the
same attempt if the follow-up statuses fetch then failed; it now tracks
the freshest committed list for that decision. Also collapses two refs
tracking dashboard status-map emptiness into one.
This commit is contained in:
Anso
2026-07-22 08:01:59 -04:00
committed by GitHub
parent a3edee5e6a
commit b06dfd7175
21 changed files with 1498 additions and 148 deletions
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Sparkline } from '@/components/ui/sparkline';
import { ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Layers } from 'lucide-react';
import { AlertCircle, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, Layers, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { StackStatusEntry, MetricPoint, StackCpuSeries } from './types';
import { Skeleton } from '@/components/ui/skeleton';
import type { StackStatusEntry, MetricPoint, StackCpuSeries, StackStatusesLoadStatus } from './types';
import type { StackUpdateInfo } from '@/types/imageUpdates';
import { aggregateCurrentUsage } from './aggregateCurrentUsage';
import { classifyRow, type RowState } from './classifyRow';
@@ -11,6 +12,9 @@ import { updateAvailableBadge, updateAvailableLabel } from '@/lib/updateAvailabl
interface StackHealthTableProps {
stackStatuses: Record<string, StackStatusEntry>;
stackStatusesLoadStatus: StackStatusesLoadStatus;
stackStatusesLoadError: string | null;
onRetryStackStatuses?: () => void;
metrics: MetricPoint[];
stackCpuSeries: Record<string, StackCpuSeries>;
onNavigateToStack: (stackFile: string) => void;
@@ -84,6 +88,9 @@ const sparkStroke: Record<RowState, string> = {
export function StackHealthTable({
stackStatuses,
stackStatusesLoadStatus,
stackStatusesLoadError,
onRetryStackStatuses,
metrics,
stackCpuSeries,
onNavigateToStack,
@@ -173,6 +180,36 @@ export function StackHealthTable({
const stackCount = Object.keys(stackStatuses).length;
if (stackStatusesLoadStatus === 'idle' || stackStatusesLoadStatus === 'loading') {
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-5 space-y-3">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
);
}
if (stackStatusesLoadStatus === 'error') {
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel py-10">
<div className="flex flex-col items-center justify-center gap-3 text-stat-subtitle">
<AlertCircle className="h-8 w-8 text-stat-icon" strokeWidth={1.5} aria-hidden />
<p className="text-sm text-center px-4">
{stackStatusesLoadError ?? 'Could not load stack health.'}
</p>
{onRetryStackStatuses && (
<Button type="button" variant="outline" size="sm" onClick={onRetryStackStatuses}>
<RefreshCw className="w-4 h-4" />
Retry
</Button>
)}
</div>
</div>
);
}
if (stackCount === 0) {
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel py-10">
@@ -0,0 +1,52 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { StackHealthTable } from '../StackHealthTable';
describe('StackHealthTable load states', () => {
it('does not show empty copy while loading', () => {
render(
<StackHealthTable
stackStatuses={{}}
stackStatusesLoadStatus="loading"
stackStatusesLoadError={null}
metrics={[]}
stackCpuSeries={{}}
onNavigateToStack={vi.fn()}
/>,
);
expect(screen.queryByText(/No stacks found/i)).toBeNull();
});
it('shows empty copy only after success', () => {
render(
<StackHealthTable
stackStatuses={{}}
stackStatusesLoadStatus="success"
stackStatusesLoadError={null}
metrics={[]}
stackCpuSeries={{}}
onNavigateToStack={vi.fn()}
/>,
);
expect(screen.getByText(/No stacks found/i)).toBeInTheDocument();
});
it('shows retry on error', async () => {
const onRetry = vi.fn();
const user = userEvent.setup();
render(
<StackHealthTable
stackStatuses={{}}
stackStatusesLoadStatus="error"
stackStatusesLoadError="Could not load stack health."
onRetryStackStatuses={onRetry}
metrics={[]}
stackCpuSeries={{}}
onNavigateToStack={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', { name: /retry/i }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
@@ -14,17 +14,16 @@ vi.mock('@/context/NodeContext', () => ({
useNodes: () => useNodesMock(),
}));
// `visibilityInterval` from the live utils library uses
// `document.visibilityState`, which jsdom treats as `prerender` until a
// listener is attached. The polling tests below assert mount-time fetches and
// the debounced refetch path; the long-running interval ticks themselves are
// covered by the existing useNextAutoUpdateRun suite. Replace with a no-op
// cleanup so the hook does not retain a real timer across tests.
// Statuses soft-poll uses visibilityInterval (setInterval). Use a real timer so
// tests can advance past the 10s cadence; skip document.visibility wiring.
vi.mock('@/lib/utils', async () => {
const actual = await vi.importActual<typeof import('@/lib/utils')>('@/lib/utils');
return {
...actual,
visibilityInterval: () => () => {},
visibilityInterval: (fn: () => void, ms: number) => {
const id = setInterval(fn, ms);
return () => clearInterval(id);
},
};
});
@@ -112,3 +111,275 @@ describe('useDashboardData state-invalidate handling', () => {
expect(apiFetchMock).not.toHaveBeenCalled();
});
});
describe('useDashboardData stackStatuses load states', () => {
it('reaches success with an empty map without treating deferral as empty UI state', async () => {
let resolveStatuses: ((r: Response) => void) | null = null;
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return new Promise<Response>((resolve) => { resolveStatuses = resolve; });
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); });
expect(result.current.stackStatusesLoadStatus).toBe('loading');
await act(async () => {
resolveStatuses?.(okJson({}));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual({});
});
it('surfaces error on failed statuses fetch and recovers on retry', async () => {
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return Promise.resolve(new Response('nope', { status: 500 }));
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
expect(result.current.stackStatusesLoadStatus).toBe('error');
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') return Promise.resolve(okJson({}));
return Promise.resolve(okJson(null));
});
await act(async () => {
result.current.retryStackStatuses();
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
});
it('ignores an older soft success after a newer foreground retry', async () => {
const resolvers: Array<(r: Response) => void> = [];
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return new Promise<Response>((resolve) => { resolvers.push(resolve); });
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); });
expect(resolvers).toHaveLength(1);
await act(async () => {
resolvers[0](okJson({}));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
act(() => { fireInvalidate({ scope: 'container' }); });
await act(async () => { vi.advanceTimersByTime(300); });
expect(resolvers).toHaveLength(2);
await act(async () => {
result.current.retryStackStatuses();
await Promise.resolve();
});
expect(resolvers).toHaveLength(3);
const softMap = { 'old.yml': { status: 'exited' as const } };
const retryMap = { 'web.yml': { status: 'running' as const } };
await act(async () => {
resolvers[1](okJson(softMap));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatuses).toEqual({});
await act(async () => {
resolvers[2](okJson(retryMap));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual(retryMap);
});
it('ignores an older soft failure after a newer foreground retry success', async () => {
const resolvers: Array<(r: Response) => void> = [];
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return new Promise<Response>((resolve) => { resolvers.push(resolve); });
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); });
expect(resolvers).toHaveLength(1);
await act(async () => {
resolvers[0](okJson({}));
await Promise.resolve();
await Promise.resolve();
});
act(() => { fireInvalidate({ scope: 'container' }); });
await act(async () => { vi.advanceTimersByTime(300); });
expect(resolvers).toHaveLength(2);
await act(async () => {
result.current.retryStackStatuses();
await Promise.resolve();
});
expect(resolvers).toHaveLength(3);
const retryMap = { 'web.yml': { status: 'running' as const } };
await act(async () => {
resolvers[2](okJson(retryMap));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatuses).toEqual(retryMap);
await act(async () => {
resolvers[1](new Response('nope', { status: 500 }));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual(retryMap);
});
it('lets a slow foreground statuses response commit after soft poll and invalidate ticks', async () => {
const resolvers: Array<(r: Response) => void> = [];
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return new Promise<Response>((resolve) => { resolvers.push(resolve); });
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); });
expect(resolvers).toHaveLength(1);
expect(result.current.stackStatusesLoadStatus).toBe('loading');
act(() => { fireInvalidate({ scope: 'container' }); });
await act(async () => { vi.advanceTimersByTime(300); });
expect(resolvers).toHaveLength(1);
await act(async () => { vi.advanceTimersByTime(10000); });
expect(resolvers).toHaveLength(1);
await act(async () => { vi.advanceTimersByTime(10000); });
expect(resolvers).toHaveLength(1);
const settled = { 'web.yml': { status: 'running' as const } };
await act(async () => {
resolvers[0](okJson(settled));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual(settled);
});
it('turns a soft poll failure into a recoverable error when the prior success was empty', async () => {
const resolvers: Array<(r: Response) => void> = [];
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return new Promise<Response>((resolve) => { resolvers.push(resolve); });
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); });
expect(resolvers).toHaveLength(1);
// Foreground load settles on confirmed-empty.
await act(async () => {
resolvers[0](okJson({}));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual({});
// A subsequent soft poll fails: this must not stay a silent empty state.
act(() => { fireInvalidate({ scope: 'container' }); });
await act(async () => { vi.advanceTimersByTime(300); });
expect(resolvers).toHaveLength(2);
await act(async () => {
resolvers[1](new Response('nope', { status: 500 }));
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.stackStatusesLoadStatus).toBe('error');
});
it('drops a malformed per-stack entry instead of crashing, keeping the rest of a valid response', async () => {
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return Promise.resolve(okJson({
'web.yml': { status: 'running' },
'broken.yml': null,
'also-broken.yml': 'running',
}));
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
expect(result.current.stackStatusesLoadStatus).toBe('success');
expect(result.current.stackStatuses).toEqual({ 'web.yml': { status: 'running' } });
});
it('treats a non-empty response where every entry is malformed as an error, not confirmed-empty', async () => {
apiFetchMock.mockImplementation((endpoint: string) => {
if (endpoint === '/stats') return Promise.resolve(okJson(STATS_PAYLOAD));
if (endpoint === '/system/stats') return Promise.resolve(okJson(SYS_PAYLOAD));
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
if (endpoint === '/stacks/statuses') {
return Promise.resolve(okJson({ 'a.yml': null, 'b.yml': 'running' }));
}
return Promise.resolve(okJson(null));
});
const { result } = renderHook(() => useDashboardData());
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
expect(result.current.stackStatusesLoadStatus).toBe('error');
expect(result.current.stackStatuses).toEqual({});
});
});
@@ -96,11 +96,16 @@ export interface StackCpuSeries {
latestValue: number;
}
export type StackStatusesLoadStatus = 'idle' | 'loading' | 'success' | 'error';
export interface DashboardData {
stats: Stats;
systemStats: SystemStats | null;
metrics: MetricPoint[];
stackStatuses: Record<string, StackStatusEntry>;
stackStatusesLoadStatus: StackStatusesLoadStatus;
stackStatusesLoadError: string | null;
retryStackStatuses: () => void;
lastSyncAt: number | null;
nodeCount: number;
stackCpuSeries: Record<string, StackCpuSeries>;
@@ -9,6 +9,7 @@ import type {
StackStatusEntry,
DashboardData,
StackCpuSeries,
StackStatusesLoadStatus,
} from './types';
const DEFAULT_STATS: Stats = { active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 };
@@ -90,6 +91,20 @@ export function buildNetHistory(
// is chosen so a single transient hiccup does not trip the indicator.
const METRICS_STALE_THRESHOLD = 3;
const VALID_STACK_STATUS_VALUES = new Set(['running', 'exited', 'unknown', 'partial']);
// A malformed per-stack entry (null, a bare string, or an object missing
// `status`) must never reach the table renderer, which indexes straight into
// `entry.status` and other fields without a null check.
function isValidStatusEntry(value: unknown): value is StackStatusEntry {
return (
!!value
&& typeof value === 'object'
&& !Array.isArray(value)
&& VALID_STACK_STATUS_VALUES.has((value as { status?: unknown }).status as string)
);
}
export function useDashboardData(): DashboardData {
const { activeNode, nodes } = useNodes();
const nodeId = activeNode?.id;
@@ -98,6 +113,8 @@ export function useDashboardData(): DashboardData {
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [metrics, setMetrics] = useState<MetricPoint[]>([]);
const [stackStatuses, setStackStatuses] = useState<Record<string, StackStatusEntry>>({});
const [stackStatusesLoadStatus, setStackStatusesLoadStatus] = useState<StackStatusesLoadStatus>('idle');
const [stackStatusesLoadError, setStackStatusesLoadError] = useState<string | null>(null);
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
const [metricsStale, setMetricsStale] = useState(false);
@@ -106,6 +123,27 @@ export function useDashboardData(): DashboardData {
const nodeIdRef = useRef(nodeId);
useEffect(() => { nodeIdRef.current = nodeId; }, [nodeId]);
// Whether the last committed success held a non-empty map. Soft poll failures
// keep the prior map only in that case; a confirmed-empty fleet must surface a
// recoverable error instead. Set from each committed success and reset on node
// change, so commitStackStatusesFailure (a useCallback that does not depend on
// stackStatuses) can read it without the map identity.
const hadNonEmptyStatusesRef = useRef(false);
// Latest-request arbitration for /stacks/statuses: polling, invalidation,
// mount, and Retry can overlap; only the current generation may commit.
const stackStatusesFetchGenRef = useRef(0);
// Soft poll/invalidation must not start while any statuses request is in
// flight. Fixed-interval ticks would otherwise bump generation forever and
// starve a slow foreground hydration. Foreground (mount/retry/node change)
// always starts and supersedes obsolete work.
const stackStatusesInFlightRef = useRef(false);
useEffect(() => {
hadNonEmptyStatusesRef.current = false;
}, [nodeId]);
useEffect(() => () => {
stackStatusesFetchGenRef.current += 1;
}, []);
// Consecutive failure counters per live-metrics endpoint. Either reaching
// METRICS_STALE_THRESHOLD trips the metricsStale indicator; the first
// successful response on the failing endpoint clears its own counter and,
@@ -190,19 +228,136 @@ export function useDashboardData(): DashboardData {
return cleanup;
}, [nodeId, fetchJson]);
// Stack statuses: 10s polling, resets on node change
// Stack statuses: 10s polling, resets on node change. Foreground / retry
// expose loading and recoverable error; soft poll failures after success keep
// the prior map so the dashboard never flashes a false empty state.
const isCurrentStatusesFetch = useCallback((
currentNodeId: number | undefined,
generation: number,
) => (
nodeIdRef.current === currentNodeId
&& stackStatusesFetchGenRef.current === generation
), []);
const commitStackStatusesSuccess = useCallback((
currentNodeId: number | undefined,
generation: number,
data: Record<string, StackStatusEntry>,
) => {
if (!isCurrentStatusesFetch(currentNodeId, generation)) return;
setStackStatuses(data);
setStackStatusesLoadStatus('success');
setStackStatusesLoadError(null);
hadNonEmptyStatusesRef.current = Object.keys(data).length > 0;
}, [isCurrentStatusesFetch]);
const commitStackStatusesFailure = useCallback((
currentNodeId: number | undefined,
generation: number,
mode: 'foreground' | 'soft',
failureMessage: string,
) => {
if (!isCurrentStatusesFetch(currentNodeId, generation)) return;
// Soft: prior non-empty rows stay visible on a transient failure. Prior
// confirmed-empty becomes a recoverable error so a soft failure can never
// look identical to "no stacks".
if (mode === 'soft' && hadNonEmptyStatusesRef.current) return;
setStackStatusesLoadStatus('error');
setStackStatusesLoadError(failureMessage);
}, [isCurrentStatusesFetch]);
const fetchStackStatuses = useCallback(async (
currentNodeId: number | undefined,
mode: 'foreground' | 'soft',
) => {
if (nodeIdRef.current !== currentNodeId) return;
if (mode === 'soft' && stackStatusesInFlightRef.current) return;
const generation = ++stackStatusesFetchGenRef.current;
stackStatusesInFlightRef.current = true;
if (mode === 'foreground') {
setStackStatusesLoadStatus('loading');
setStackStatusesLoadError(null);
}
try {
const res = await apiFetch('/stacks/statuses');
if (!isCurrentStatusesFetch(currentNodeId, generation)) return;
if (!res.ok) {
commitStackStatusesFailure(
currentNodeId,
generation,
mode,
`Could not load stack health (${res.status}).`,
);
return;
}
const body: unknown = await res.json();
if (!isCurrentStatusesFetch(currentNodeId, generation)) return;
if (body && typeof body === 'object' && !Array.isArray(body)) {
// Drop any entry isValidStatusEntry rejects rather than trusting the
// whole map: one bad entry must not crash or misrepresent the rest of
// a valid response.
const rawEntries = Object.entries(body as Record<string, unknown>);
const sanitized: Record<string, StackStatusEntry> = {};
for (const [file, entry] of rawEntries) {
if (isValidStatusEntry(entry)) {
sanitized[file] = entry;
} else {
console.error('[Dashboard] Dropped malformed stack status entry:', file, entry);
}
}
// A non-empty map where every entry failed validation is a malformed
// response, not a confirmed-empty fleet: committing it as success
// would be indistinguishable from a genuine empty fleet.
if (rawEntries.length > 0 && Object.keys(sanitized).length === 0) {
commitStackStatusesFailure(
currentNodeId,
generation,
mode,
'Stack health response was invalid.',
);
return;
}
commitStackStatusesSuccess(currentNodeId, generation, sanitized);
return;
}
commitStackStatusesFailure(
currentNodeId,
generation,
mode,
'Stack health response was invalid.',
);
} catch {
if (!isCurrentStatusesFetch(currentNodeId, generation)) return;
commitStackStatusesFailure(
currentNodeId,
generation,
mode,
'Could not load stack health.',
);
} finally {
// Only the latest generation clears the gate. A superseded request that
// finishes later must not reopen soft polling while a newer fetch is live.
if (stackStatusesFetchGenRef.current === generation) {
stackStatusesInFlightRef.current = false;
}
}
}, [commitStackStatusesSuccess, commitStackStatusesFailure, isCurrentStatusesFetch]);
const retryStackStatuses = useCallback(() => {
void fetchStackStatuses(nodeIdRef.current, 'foreground');
}, [fetchStackStatuses]);
useEffect(() => {
setStackStatuses({}); // eslint-disable-line react-hooks/set-state-in-effect
setStackStatusesLoadStatus('loading');
setStackStatusesLoadError(null);
const currentNodeId = nodeId;
const fetchStatuses = async () => {
if (nodeIdRef.current !== currentNodeId) return;
const data = await fetchJson<Record<string, StackStatusEntry>>('/stacks/statuses');
if (data && nodeIdRef.current === currentNodeId) setStackStatuses(data);
};
fetchStatuses();
const cleanup = visibilityInterval(fetchStatuses, 10000);
void fetchStackStatuses(currentNodeId, 'foreground');
const cleanup = visibilityInterval(() => {
void fetchStackStatuses(currentNodeId, 'soft');
}, 10000);
return cleanup;
}, [nodeId, fetchJson]);
}, [nodeId, fetchStackStatuses]);
// React to live `state-invalidate` signals from /ws/notifications: when a
// Docker container event fires (start/stop/die/restart/health), the layout
@@ -219,10 +374,9 @@ export function useDashboardData(): DashboardData {
let invalidateTimer: ReturnType<typeof setTimeout> | null = null;
const refresh = async () => {
if (!active || nodeIdRef.current !== currentNodeId) return;
const [statsData, sysData, statusesData] = await Promise.all([
const [statsData, sysData] = await Promise.all([
fetchJson<Stats>('/stats'),
fetchJson<SystemStats>('/system/stats'),
fetchJson<Record<string, StackStatusEntry>>('/stacks/statuses'),
]);
// Re-check after the await: an unmount or node switch may have
// happened while the fetches were in flight, in which case the
@@ -233,7 +387,7 @@ export function useDashboardData(): DashboardData {
setLastSyncAt(Date.now());
}
if (sysData) setSystemStats(sysData);
if (statusesData) setStackStatuses(statusesData);
await fetchStackStatuses(currentNodeId, 'soft');
};
const onInvalidate = () => {
if (!active || nodeIdRef.current !== currentNodeId) return;
@@ -249,7 +403,7 @@ export function useDashboardData(): DashboardData {
window.removeEventListener('sencho:state-invalidate', onInvalidate);
if (invalidateTimer) clearTimeout(invalidateTimer);
};
}, [nodeId, fetchJson]);
}, [nodeId, fetchJson, fetchStackStatuses]);
const stackCpuSeries = useMemo<Record<string, StackCpuSeries>>(() => {
if (metrics.length === 0) return {};
@@ -337,6 +491,9 @@ export function useDashboardData(): DashboardData {
systemStats,
metrics,
stackStatuses,
stackStatusesLoadStatus,
stackStatusesLoadError,
retryStackStatuses,
lastSyncAt,
nodeCount: nodes.length,
stackCpuSeries,