mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 01:43:55 +00:00
fix(dashboard): decouple FleetHeartbeat refresh from the active local node (#1210)
useFleetHeartbeat keyed its effect on activeNode.id and reset its state on every node switch, even though /fleet/overview returns a fleet-wide payload that does not change when the user pivots their active local node. The result was a needless flicker back to the skeleton card and an extra HTTP request on every node pivot. Drop the nodeId dependency and the stale-node guard ref. The 30 s visibility-interval poll remains, so transient remote-node offline state still surfaces within one polling cycle.
This commit is contained in:
@@ -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<typeof import('@/lib/utils')>('@/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);
|
||||
});
|
||||
});
|
||||
@@ -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<FleetNodeOverview[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user