mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 09:24:09 +00:00
fix(dashboard): debounce state-invalidate refetches (#1209)
* fix(dashboard): debounce state-invalidate refetches and drop redundant listener useDashboardData fired three immediate HTTP requests (/stats, /system/stats, /stacks/statuses) for every Docker container event. A burst restart of a 50-container stack produced ~150 instant requests against the local instance with no throttle. Add a 250 ms trailing-edge debounce so an event storm collapses into a single coalesced refresh, mirroring the precedent in useNextAutoUpdateRun. The cleanup function now also clears any pending debounce timer so a late event cannot fire after the dashboard unmounts. useConfigurationStatus subscribed to the same event but its data is built from settings and policy tables (agents, alert rules, auto-heal, scheduled tasks, scan policies, backup config), none of which change on container state. Drop the listener entirely; the 60 s poll catches rare settings edits with acceptable latency. * fix(dashboard): scope settings-event listener back into useConfigurationStatus Address two follow-up findings from independent review of the earlier commit on this branch. 1. Restore a filtered sencho:state-invalidate listener in useConfigurationStatus. The earlier commit dropped the listener wholesale to keep container-event bursts from refetching settings data, but that also silenced the only settings-affecting event in the current taxonomy: action='auto-update-settings-changed' (emitted from backend/src/routes/stacks.ts when a user toggles a stack's auto-update setting). With the listener gone, the Configuration Status row for Auto-update stacks could sit stale until the 60 s poll. The new listener mirrors the precedent in useNextAutoUpdateRun: filter on the single configuration-relevant action, trailing-edge debounce 250 ms. 2. Add an `active` flag to the useDashboardData state-invalidate effect. Cleanup already clears the pending debounce timer, but a refresh() already in flight could still call setters after unmount because the awaited Promise.all has no abort hook. The flag is checked both before the await and after, matching the cleanup shape used by useNextAutoUpdateRun. Tests cover both: the configuration listener now ignores scope='stack' and scope='image-updates' bursts and refetches once on a settings-changed burst.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
|
||||
const apiFetchMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
|
||||
}));
|
||||
|
||||
const useNodesMock = vi.fn();
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => useNodesMock(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/utils')>('@/lib/utils');
|
||||
return {
|
||||
...actual,
|
||||
visibilityInterval: () => () => {},
|
||||
};
|
||||
});
|
||||
|
||||
import { useConfigurationStatus } from '../useConfigurationStatus';
|
||||
|
||||
function okJson(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function fireInvalidate(detail: { scope?: string; action?: string } = {}) {
|
||||
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
apiFetchMock.mockReset();
|
||||
apiFetchMock.mockImplementation(() => Promise.resolve(okJson({
|
||||
tier: 'community',
|
||||
variant: null,
|
||||
notifications: { agents: {}, alertRules: 0, routingRules: { count: 0, enabledCount: 0, locked: true, requiredTier: 'skipper' } },
|
||||
automation: {
|
||||
autoHeal: { total: 0, enabled: 0 },
|
||||
autoUpdate: { enabled: 0, total: 0 },
|
||||
scheduledTasks: { total: 0, enabled: 0, locked: true, requiredTier: 'admiral' },
|
||||
webhooks: { total: 0, enabled: 0, locked: true, requiredTier: 'skipper' },
|
||||
},
|
||||
security: {
|
||||
mfaEnabled: null,
|
||||
ssoEnabled: false,
|
||||
ssoProvider: null,
|
||||
scanPolicies: { total: 0, enabled: 0, locked: true, requiredTier: 'skipper' },
|
||||
},
|
||||
thresholds: { cpuLimit: 90, ramLimit: 90, diskLimit: 90, dockerJanitorGb: 5, globalCrash: false },
|
||||
backup: { provider: 'disabled', autoUpload: false, locked: false },
|
||||
})));
|
||||
useNodesMock.mockReset();
|
||||
useNodesMock.mockReturnValue({
|
||||
activeNode: { id: 1, name: 'Local', type: 'local' },
|
||||
nodes: [{ id: 1, name: 'Local', type: 'local' }],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('useConfigurationStatus state-invalidate handling', () => {
|
||||
it('does not refetch on container or image-update state-invalidate events', async () => {
|
||||
renderHook(() => useConfigurationStatus());
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
const baseline = apiFetchMock.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 5; i += 1) fireInvalidate({ scope: 'stack' });
|
||||
for (let i = 0; i < 5; i += 1) fireInvalidate({ scope: 'image-updates' });
|
||||
});
|
||||
await act(async () => { vi.advanceTimersByTime(2_000); });
|
||||
|
||||
// Neither container churn nor image-update bursts mutate the
|
||||
// configuration payload; the filtered listener must ignore them.
|
||||
expect(apiFetchMock.mock.calls.length).toBe(baseline);
|
||||
});
|
||||
|
||||
it('refetches once on a settings-affecting auto-update-settings-changed event', async () => {
|
||||
renderHook(() => useConfigurationStatus());
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
const baseline = apiFetchMock.mock.calls.length;
|
||||
|
||||
act(() => {
|
||||
// Burst three settings-change events; the debounce should collapse
|
||||
// them into a single refetch.
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
fireInvalidate({ action: 'auto-update-settings-changed' });
|
||||
});
|
||||
// Before debounce window elapses, no new fetch.
|
||||
expect(apiFetchMock.mock.calls.length).toBe(baseline);
|
||||
|
||||
await act(async () => { vi.advanceTimersByTime(300); });
|
||||
expect(apiFetchMock.mock.calls.length).toBe(baseline + 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
|
||||
const apiFetchMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
|
||||
}));
|
||||
|
||||
// Stable mock for the node context: ID `1` is the active node so the hook can
|
||||
// resolve a nodeId on mount without rendering the full provider tree.
|
||||
const useNodesMock = vi.fn();
|
||||
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.
|
||||
vi.mock('@/lib/utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/utils')>('@/lib/utils');
|
||||
return {
|
||||
...actual,
|
||||
visibilityInterval: () => () => {},
|
||||
};
|
||||
});
|
||||
|
||||
import { useDashboardData } from '../useDashboardData';
|
||||
|
||||
function okJson(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function fireInvalidate(detail: { scope?: string; action?: string } = {}) {
|
||||
window.dispatchEvent(new CustomEvent('sencho:state-invalidate', { detail }));
|
||||
}
|
||||
|
||||
const STATS_PAYLOAD = { active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 };
|
||||
const SYS_PAYLOAD = {
|
||||
cpu: { usage: '0', cores: 4 },
|
||||
memory: { total: 0, used: 0, free: 0, usagePercent: '0' },
|
||||
disk: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
apiFetchMock.mockReset();
|
||||
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 === '/stacks/statuses') return Promise.resolve(okJson({}));
|
||||
if (endpoint === '/metrics/historical') return Promise.resolve(okJson([]));
|
||||
return Promise.resolve(okJson(null));
|
||||
});
|
||||
useNodesMock.mockReset();
|
||||
useNodesMock.mockReturnValue({
|
||||
activeNode: { id: 1, name: 'Local', type: 'local' },
|
||||
nodes: [{ id: 1, name: 'Local', type: 'local' }],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function countFetchCalls(endpoint: string): number {
|
||||
return apiFetchMock.mock.calls.filter((call) => call[0] === endpoint).length;
|
||||
}
|
||||
|
||||
describe('useDashboardData state-invalidate handling', () => {
|
||||
it('debounces a burst of state-invalidate events into a single refetch', async () => {
|
||||
renderHook(() => useDashboardData());
|
||||
// Drain mount-time polls.
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
const baselineStats = countFetchCalls('/stats');
|
||||
const baselineSys = countFetchCalls('/system/stats');
|
||||
const baselineStatuses = countFetchCalls('/stacks/statuses');
|
||||
|
||||
act(() => {
|
||||
// Burst: 10 container events in rapid succession.
|
||||
for (let i = 0; i < 10; i += 1) fireInvalidate({ scope: 'container' });
|
||||
});
|
||||
|
||||
// Before debounce window elapses, no new fetches.
|
||||
expect(countFetchCalls('/stats') - baselineStats).toBe(0);
|
||||
expect(countFetchCalls('/system/stats') - baselineSys).toBe(0);
|
||||
expect(countFetchCalls('/stacks/statuses') - baselineStatuses).toBe(0);
|
||||
|
||||
await act(async () => { vi.advanceTimersByTime(300); });
|
||||
|
||||
// After debounce: exactly one refetch per endpoint, regardless of burst size.
|
||||
expect(countFetchCalls('/stats') - baselineStats).toBe(1);
|
||||
expect(countFetchCalls('/system/stats') - baselineSys).toBe(1);
|
||||
expect(countFetchCalls('/stacks/statuses') - baselineStatuses).toBe(1);
|
||||
});
|
||||
|
||||
it('cleans up the debounce timer on unmount so no late refetch fires', async () => {
|
||||
const { unmount } = renderHook(() => useDashboardData());
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
apiFetchMock.mockClear();
|
||||
|
||||
act(() => { fireInvalidate({ scope: 'container' }); });
|
||||
unmount();
|
||||
await act(async () => { vi.advanceTimersByTime(500); });
|
||||
|
||||
expect(apiFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,10 @@ import { useNodes } from '@/context/NodeContext';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { visibilityInterval } from '@/lib/utils';
|
||||
|
||||
// Trailing-edge debounce window for filtered settings-event refetches,
|
||||
// matching the precedent in useNextAutoUpdateRun.
|
||||
const INVALIDATE_DEBOUNCE_MS = 250;
|
||||
|
||||
interface AgentStatus {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
@@ -64,6 +68,9 @@ export function useConfigurationStatus() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Configuration data is derived from settings/policy tables (agents,
|
||||
// alert rules, auto-heal policies, scheduled tasks, scan policies, cloud
|
||||
// backup config). The 60 s poll catches settings drift on its own.
|
||||
useEffect(() => {
|
||||
setStatus(null);
|
||||
setLoading(true);
|
||||
@@ -73,10 +80,30 @@ export function useConfigurationStatus() {
|
||||
return visibilityInterval(guard, 60_000);
|
||||
}, [nodeId, fetchStatus]);
|
||||
|
||||
// Filter `sencho:state-invalidate` so only settings-affecting events
|
||||
// refetch the configuration; the high-frequency `scope: 'stack'` and
|
||||
// `scope: 'image-updates'` container/image bursts are ignored. Today the
|
||||
// only such settings event is `auto-update-settings-changed` (emitted by
|
||||
// the stack auto-update toggle). The filter is debounced and the
|
||||
// configuration response includes the toggled state, so a user editing
|
||||
// the setting sees the row update under a second instead of waiting up
|
||||
// to a minute.
|
||||
useEffect(() => {
|
||||
const handler = () => void fetchStatus();
|
||||
window.addEventListener('sencho:state-invalidate', handler);
|
||||
return () => window.removeEventListener('sencho:state-invalidate', handler);
|
||||
let invalidateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const onInvalidate = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ action?: string; scope?: string }>).detail;
|
||||
if (detail?.action !== 'auto-update-settings-changed') return;
|
||||
if (invalidateTimer) clearTimeout(invalidateTimer);
|
||||
invalidateTimer = setTimeout(() => {
|
||||
invalidateTimer = null;
|
||||
void fetchStatus();
|
||||
}, INVALIDATE_DEBOUNCE_MS);
|
||||
};
|
||||
window.addEventListener('sencho:state-invalidate', onInvalidate);
|
||||
return () => {
|
||||
window.removeEventListener('sencho:state-invalidate', onInvalidate);
|
||||
if (invalidateTimer) clearTimeout(invalidateTimer);
|
||||
};
|
||||
}, [fetchStatus]);
|
||||
|
||||
return { status, loading };
|
||||
|
||||
@@ -14,6 +14,10 @@ import type {
|
||||
const DEFAULT_STATS: Stats = { active: 0, managed: 0, unmanaged: 0, exited: 0, total: 0 };
|
||||
const SPARK_BUCKETS = 20;
|
||||
const SPARK_WINDOW_MS = 10 * 60 * 1000;
|
||||
// Trailing-edge debounce window for live state-invalidate refetches. Matches
|
||||
// useNextAutoUpdateRun so dashboard surfaces feel "live" without amplifying a
|
||||
// container-event burst into one HTTP request per event.
|
||||
const INVALIDATE_DEBOUNCE_MS = 250;
|
||||
|
||||
function bucketCpu(points: MetricPoint[], windowMs: number, buckets: number): number[] {
|
||||
if (points.length === 0) return Array(buckets).fill(0);
|
||||
@@ -166,20 +170,27 @@ export function useDashboardData(): DashboardData {
|
||||
// React to live `state-invalidate` signals from /ws/notifications: when a
|
||||
// Docker container event fires (start/stop/die/restart/health), the layout
|
||||
// re-broadcasts the envelope as a window CustomEvent. Refetch the cheap
|
||||
// data (stats, system, statuses) immediately so the dashboard header and
|
||||
// sidebar status update in well under a second instead of waiting for the
|
||||
// next polling tick. Historical metrics are intentionally skipped — they
|
||||
// are a 10-minute trend, not a live indicator.
|
||||
// data (stats, system, statuses) so the dashboard header and sidebar status
|
||||
// update in well under a second instead of waiting for the next polling
|
||||
// tick. Historical metrics are skipped — they are a 10-minute trend, not a
|
||||
// live indicator. The refetch is trailing-edge debounced so an event storm
|
||||
// (e.g. a 50-container stack restart) collapses to a single coalesced
|
||||
// refresh instead of one HTTP request per event.
|
||||
useEffect(() => {
|
||||
const currentNodeId = nodeId;
|
||||
const onInvalidate = async () => {
|
||||
if (nodeIdRef.current !== currentNodeId) return;
|
||||
let active = true;
|
||||
let invalidateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const refresh = async () => {
|
||||
if (!active || nodeIdRef.current !== currentNodeId) return;
|
||||
const [statsData, sysData, statusesData] = await Promise.all([
|
||||
fetchJson<Stats>('/stats'),
|
||||
fetchJson<SystemStats>('/system/stats'),
|
||||
fetchJson<Record<string, StackStatusEntry>>('/stacks/statuses'),
|
||||
]);
|
||||
if (nodeIdRef.current !== currentNodeId) return;
|
||||
// Re-check after the await: an unmount or node switch may have
|
||||
// happened while the fetches were in flight, in which case the
|
||||
// resulting setState calls would land on a stale render tree.
|
||||
if (!active || nodeIdRef.current !== currentNodeId) return;
|
||||
if (statsData) {
|
||||
setStats(statsData);
|
||||
setLastSyncAt(Date.now());
|
||||
@@ -187,8 +198,20 @@ export function useDashboardData(): DashboardData {
|
||||
if (sysData) setSystemStats(sysData);
|
||||
if (statusesData) setStackStatuses(statusesData);
|
||||
};
|
||||
const onInvalidate = () => {
|
||||
if (!active || nodeIdRef.current !== currentNodeId) return;
|
||||
if (invalidateTimer) clearTimeout(invalidateTimer);
|
||||
invalidateTimer = setTimeout(() => {
|
||||
invalidateTimer = null;
|
||||
void refresh();
|
||||
}, INVALIDATE_DEBOUNCE_MS);
|
||||
};
|
||||
window.addEventListener('sencho:state-invalidate', onInvalidate);
|
||||
return () => window.removeEventListener('sencho:state-invalidate', onInvalidate);
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener('sencho:state-invalidate', onInvalidate);
|
||||
if (invalidateTimer) clearTimeout(invalidateTimer);
|
||||
};
|
||||
}, [nodeId, fetchJson]);
|
||||
|
||||
const stackCpuSeries = useMemo<Record<string, StackCpuSeries>>(() => {
|
||||
|
||||
Reference in New Issue
Block a user