diff --git a/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx b/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx new file mode 100644 index 00000000..3ea42190 --- /dev/null +++ b/frontend/src/components/dashboard/__tests__/useConfigurationStatus.test.tsx @@ -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('@/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); + }); +}); diff --git a/frontend/src/components/dashboard/__tests__/useDashboardData.test.tsx b/frontend/src/components/dashboard/__tests__/useDashboardData.test.tsx new file mode 100644 index 00000000..d7b5af4f --- /dev/null +++ b/frontend/src/components/dashboard/__tests__/useDashboardData.test.tsx @@ -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('@/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(); + }); +}); diff --git a/frontend/src/components/dashboard/useConfigurationStatus.ts b/frontend/src/components/dashboard/useConfigurationStatus.ts index c9b62230..4d6b4816 100644 --- a/frontend/src/components/dashboard/useConfigurationStatus.ts +++ b/frontend/src/components/dashboard/useConfigurationStatus.ts @@ -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 | 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 }; diff --git a/frontend/src/components/dashboard/useDashboardData.ts b/frontend/src/components/dashboard/useDashboardData.ts index e3caeb54..2e6345f3 100644 --- a/frontend/src/components/dashboard/useDashboardData.ts +++ b/frontend/src/components/dashboard/useDashboardData.ts @@ -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 | null = null; + const refresh = async () => { + if (!active || nodeIdRef.current !== currentNodeId) return; const [statsData, sysData, statusesData] = await Promise.all([ fetchJson('/stats'), fetchJson('/system/stats'), fetchJson>('/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>(() => {