mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 03:59:41 +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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user