diff --git a/frontend/src/components/dashboard/__tests__/useFleetHeartbeat.test.tsx b/frontend/src/components/dashboard/__tests__/useFleetHeartbeat.test.tsx new file mode 100644 index 00000000..0ed306e5 --- /dev/null +++ b/frontend/src/components/dashboard/__tests__/useFleetHeartbeat.test.tsx @@ -0,0 +1,75 @@ +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 { useFleetHeartbeat } from '../useFleetHeartbeat'; + +function okJson(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const FLEET_PAYLOAD = [ + { id: 1, name: 'Local', type: 'local', status: 'online', stats: { active: 3, managed: 3, unmanaged: 0, exited: 0, total: 3 } }, + { id: 2, name: 'Edge', type: 'remote', status: 'online', stats: { active: 1, managed: 1, unmanaged: 0, exited: 0, total: 1 } }, +]; + +beforeEach(() => { + apiFetchMock.mockReset(); + apiFetchMock.mockImplementation(() => Promise.resolve(okJson(FLEET_PAYLOAD))); + useNodesMock.mockReset(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('useFleetHeartbeat node-switch behavior', () => { + it('does not reset state when the active local node changes', async () => { + useNodesMock.mockReturnValue({ + activeNode: { id: 1, name: 'Local', type: 'local' }, + nodes: [{ id: 1, name: 'Local', type: 'local' }, { id: 2, name: 'Edge', type: 'remote' }], + }); + const { result, rerender } = renderHook(() => useFleetHeartbeat()); + + // Wait for the mount-time fetch to land. + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + expect(result.current.loading).toBe(false); + expect(result.current.nodes).toHaveLength(2); + const fetchCountAfterMount = apiFetchMock.mock.calls.length; + + // Switch the active node. Fleet data is fleet-wide so the card should + // keep its current rows and not flicker back to a skeleton state. + useNodesMock.mockReturnValue({ + activeNode: { id: 2, name: 'Edge', type: 'remote' }, + nodes: [{ id: 1, name: 'Local', type: 'local' }, { id: 2, name: 'Edge', type: 'remote' }], + }); + rerender(); + await act(async () => { await Promise.resolve(); }); + + expect(result.current.loading).toBe(false); + expect(result.current.nodes).toHaveLength(2); + // No new fetch should fire on the node switch; the data is fleet-wide. + expect(apiFetchMock.mock.calls.length).toBe(fetchCountAfterMount); + }); +}); diff --git a/frontend/src/components/dashboard/useFleetHeartbeat.ts b/frontend/src/components/dashboard/useFleetHeartbeat.ts index ccc3ba58..a6f21108 100644 --- a/frontend/src/components/dashboard/useFleetHeartbeat.ts +++ b/frontend/src/components/dashboard/useFleetHeartbeat.ts @@ -1,5 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { useNodes } from '@/context/NodeContext'; +import { useState, useEffect, useCallback } from 'react'; import { apiFetch } from '@/lib/api'; import { visibilityInterval } from '@/lib/utils'; @@ -28,11 +27,6 @@ export interface FleetHeartbeatResult { } export function useFleetHeartbeat(): FleetHeartbeatResult { - const { activeNode } = useNodes(); - const nodeId = activeNode?.id; - const nodeIdRef = useRef(nodeId); - useEffect(() => { nodeIdRef.current = nodeId; }, [nodeId]); - const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -55,19 +49,14 @@ export function useFleetHeartbeat(): FleetHeartbeatResult { } }, []); + // /fleet/overview is fleet-wide: the response is the same regardless of + // which local node is active. Re-keying on activeNode would clear the + // card to its skeleton state on every node switch and issue an extra + // unnecessary fetch. useEffect(() => { - setNodes([]); // eslint-disable-line react-hooks/set-state-in-effect - setLoading(true); - setError(null); - const currentNodeId = nodeId; - const guard = () => { - if (nodeIdRef.current === currentNodeId) { - void fetchOverview(); - } - }; - guard(); - return visibilityInterval(guard, 30_000); - }, [nodeId, fetchOverview]); + void fetchOverview(); + return visibilityInterval(() => { void fetchOverview(); }, 30_000); + }, [fetchOverview]); return { nodes, loading, error }; }