Files
sencho/frontend/src/components/dashboard/__tests__/useDashboardData.test.tsx
T
Anso 03a5826f7e 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.
2026-05-25 12:13:17 -04:00

115 lines
4.2 KiB
TypeScript

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();
});
});