feat(fleet): show stack-label filtering in Fleet View on every tier (#1268)

* feat(fleet): show stack-label filtering in Fleet View on every tier

Stack labels and their assignments are a Community feature: the per-node
label reads are available to any authenticated user and are already used in
the per-node Stacks view. Fleet View, however, only fetched the fleet-wide
label palette and per-stack chips when the instance was on a paid tier, so a
Community user who had labelled their stacks saw no label dots on node cards
and no Tags filter in the Overview toolbar.

Drop the paid gate on the fleet label fetch so the palette, the per-stack
chips, and the Tags filter render for everyone who has labels. Node-level tag
aggregation (used for topology grouping) stays paid and is unchanged.

* fix(fleet): surface update-status failures and soften the reconnect timeout

Three reliability fixes in the Fleet View update path:

- The fleet update-status poll swallowed fetch errors in an empty catch, so a
  failing poll left a silently stale table with no breadcrumb. It now logs the
  failure (both thrown errors and non-ok HTTP responses) without toasting on
  every tick, and keeps the last-known statuses.
- The local-update reconnecting overlay declared "Update timed out" after five
  minutes, which falsely reported failure when a large image pull simply ran
  longer than the reconnect window. It now shows a non-failure "Taking longer
  than expected" state with a "Reload to check" action, and the timeout is a
  named constant that mirrors the backend update timeout.
- The fleet overview fan-out already logged a node that failed to report; the
  update-status and update-all fan-outs now log the rejected node and reason
  too instead of discarding it.

* test(fleet): backfill Fleet View hook, component, and update-tracker coverage

Adds unit and component coverage for the previously untested Fleet View
surface: all six Overview hooks (overview, update-status, polling cadence,
preferences, fleet labels, node labels), the NodeCard, OverviewTab,
OverviewToolbar, NodeUpdatesSheet, UpdateStatusBadge, and ReconnectingOverlay
components, and the FleetUpdateTrackerService state transitions.

* test(fleet): assert update-status poll preserves last-known statuses on failure

Adds an explicit case that seeds statuses from a successful poll, then fails
the next poll, and verifies the table keeps the seeded statuses (and logs
without toasting) rather than relying on the implementation implicitly.
This commit is contained in:
Anso
2026-06-01 13:20:36 -04:00
committed by GitHub
parent 18762fa74b
commit d8f73f8203
19 changed files with 1022 additions and 13 deletions
@@ -7,9 +7,15 @@ interface ReconnectingOverlayProps {
preUpdateStartedAt: number | null;
}
// Mirrors the backend UPDATE_TIMEOUT_MS (5 minutes) in routes/fleet.ts. Past
// this point we stop asserting the update is in flight and hand control back to
// the operator, but we do not claim failure: a large image pull can legitimately
// run longer than the auto-reload budget.
const RECONNECT_TIMEOUT_SECONDS = 5 * 60;
export function ReconnectingOverlay({ preUpdateStartedAt }: ReconnectingOverlayProps) {
const [elapsed, setElapsed] = useState(0);
const timedOut = elapsed >= 300; // 5 minutes
const timedOut = elapsed >= RECONNECT_TIMEOUT_SECONDS;
useEffect(() => {
const timer = setInterval(() => setElapsed(s => s + 1), 1000);
@@ -54,12 +60,12 @@ export function ReconnectingOverlay({ preUpdateStartedAt }: ReconnectingOverlayP
{timedOut ? (
<>
<AlertTriangle className="w-10 h-10 text-warning mx-auto" strokeWidth={1.5} />
<h2 className="text-lg font-medium">Update timed out</h2>
<h2 className="text-lg font-medium">Taking longer than expected</h2>
<p className="text-sm text-muted-foreground max-w-sm">
The server has not come back within 5 minutes. Check the Docker host directly.
Sencho has not come back online yet. A large image pull can take a while, so the update may still be finishing. Reload to check, or inspect the Docker host if it persists.
</p>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
Try Reloading
Reload to check
</Button>
</>
) : (
@@ -0,0 +1,69 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
const useLicenseMock = vi.fn();
const useAuthMock = vi.fn();
const useNodesMock = vi.fn();
vi.mock('@/context/LicenseContext', () => ({ useLicense: () => useLicenseMock() }));
vi.mock('@/context/AuthContext', () => ({ useAuth: () => useAuthMock() }));
vi.mock('@/context/NodeContext', () => ({ useNodes: () => useNodesMock() }));
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/lib/nodesApi', () => ({ cordonNode: vi.fn(), uncordonNode: vi.fn() }));
vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { NodeCard } from '../NodeCard';
import type { FleetNode } from '../types';
function onlineNode(): FleetNode {
return {
id: 2, name: 'Edge', type: 'remote', status: 'online',
stats: { active: 3, managed: 3, unmanaged: 0, exited: 1, total: 4 },
systemStats: { cpu: { usage: '20.0', cores: 4 }, memory: { total: 100, used: 40, free: 60, usagePercent: '40.0' }, disk: { total: 100, used: 30, free: 70, usagePercent: '30.0' } },
stacks: ['web'], cordoned: false, cordoned_at: null, cordoned_reason: null,
};
}
function offlineNode(): FleetNode {
return { ...onlineNode(), status: 'offline', stats: null, systemStats: null, stacks: null };
}
function baseProps(node: FleetNode) {
return { node, onNavigate: vi.fn() };
}
beforeEach(() => {
useNodesMock.mockReturnValue({ nodes: [] });
useAuthMock.mockReturnValue({ isAdmin: true });
useLicenseMock.mockReturnValue({ isPaid: false, license: null });
});
afterEach(() => vi.clearAllMocks());
describe('NodeCard', () => {
it('renders stats and the online badge for an online node', () => {
render(<NodeCard {...baseProps(onlineNode())} />);
expect(screen.getByText('Online')).toBeInTheDocument();
expect(screen.getByText('Running')).toBeInTheDocument();
expect(screen.queryByText('Node unreachable')).not.toBeInTheDocument();
});
it('shows the unreachable placeholder and hides stats for an offline node', () => {
render(<NodeCard {...baseProps(offlineNode())} />);
expect(screen.getByText('Offline')).toBeInTheDocument();
expect(screen.getByText('Node unreachable')).toBeInTheDocument();
expect(screen.queryByText('Running')).not.toBeInTheDocument();
});
it('hides the actions menu for a non-admiral user', () => {
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'skipper' } });
render(<NodeCard {...baseProps(onlineNode())} />);
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
});
it('exposes the actions menu (cordon entry point) for an admiral user', () => {
useLicenseMock.mockReturnValue({ isPaid: true, license: { variant: 'admiral' } });
render(<NodeCard {...baseProps(onlineNode())} />);
// The actions menu only renders when cordon (admiral-only here) is available.
expect(screen.getByRole('button', { name: 'Node actions' })).toBeInTheDocument();
});
});
@@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
const apiFetchMock = vi.fn();
vi.mock('@/lib/api', () => ({ apiFetch: (...a: unknown[]) => apiFetchMock(...a) }));
import { NodeUpdatesSheet } from '../NodeUpdatesSheet';
import type { NodeUpdateStatus } from '../types';
const STATUSES: NodeUpdateStatus[] = [
{ nodeId: 1, name: 'Local', type: 'local', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: false, updateStatus: 'completed' },
{ nodeId: 2, name: 'Edge', type: 'remote', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: true, updateStatus: null },
{ nodeId: 3, name: 'Db', type: 'remote', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: false, updateStatus: 'failed', error: 'pull failed' },
];
function baseProps(overrides: Partial<React.ComponentProps<typeof NodeUpdatesSheet>> = {}) {
return {
open: true,
onOpenChange: vi.fn(),
checkingUpdates: false,
updateStatuses: STATUSES,
updatingNodeId: null,
fetchUpdateStatus: vi.fn(async () => {}),
triggerNodeUpdate: vi.fn(),
retryNodeUpdate: vi.fn(),
dismissNodeUpdate: vi.fn(),
triggerUpdateAll: vi.fn(async () => {}),
...overrides,
};
}
beforeEach(() => apiFetchMock.mockReset());
afterEach(() => vi.clearAllMocks());
describe('NodeUpdatesSheet', () => {
it('renders the per-node table rows', () => {
render(<NodeUpdatesSheet {...baseProps()} />);
expect(screen.getByText('Local')).toBeInTheDocument();
expect(screen.getByText('Edge')).toBeInTheDocument();
expect(screen.getByText('Db')).toBeInTheDocument();
});
it('shows a checking spinner state', () => {
render(<NodeUpdatesSheet {...baseProps({ checkingUpdates: true })} />);
expect(screen.getByText('Checking for updates...')).toBeInTheDocument();
});
it('shows the empty state with no nodes', () => {
render(<NodeUpdatesSheet {...baseProps({ updateStatuses: [] })} />);
expect(screen.getByText('No nodes found.')).toBeInTheDocument();
});
it('triggers a per-node update from the Update button', () => {
const triggerNodeUpdate = vi.fn();
render(<NodeUpdatesSheet {...baseProps({ triggerNodeUpdate })} />);
fireEvent.click(screen.getByRole('button', { name: /Update$/ }));
expect(triggerNodeUpdate).toHaveBeenCalledWith(2);
});
it('filters the node table by the search box', () => {
render(<NodeUpdatesSheet {...baseProps()} />);
fireEvent.change(screen.getByPlaceholderText('Filter nodes...'), { target: { value: 'edge' } });
expect(screen.getByText('Edge')).toBeInTheDocument();
expect(screen.queryByText('Local')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
// Stub the heavy children so this test exercises OverviewTab's own
// branch-selection logic, not NodeCard/FleetTopology internals.
vi.mock('../NodeCard', () => ({ NodeCard: ({ node }: { node: { name: string } }) => <div data-testid="node-card">{node.name}</div> }));
vi.mock('../../fleet/FleetTopology', () => ({ FleetTopology: () => <div data-testid="topology" /> }));
import { OverviewTab } from '../OverviewTab';
import type { FleetNode, FleetPreferences } from '../types';
const PREFS: FleetPreferences = { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false };
function node(id: number, name: string): FleetNode {
return { id, name, type: 'remote', status: 'online', stats: null, systemStats: null, stacks: null, cordoned: false, cordoned_at: null, cordoned_reason: null };
}
function props(overrides: Partial<React.ComponentProps<typeof OverviewTab>> = {}) {
return {
loading: false,
nodes: [node(1, 'Alpha')],
processedNodes: [node(1, 'Alpha')],
allNodes: [node(1, 'Alpha')],
topologyNodes: [],
viewMode: 'grid' as const,
onViewModeChange: vi.fn(),
searchQuery: '',
onSearchQueryChange: vi.fn(),
prefs: PREFS,
onPrefsChange: vi.fn(),
fleetPalette: [],
labelFilters: new Set<string>(),
onLabelFiltersChange: vi.fn(),
onClearFilters: vi.fn(),
fleetStackLabelMap: {},
updateStatusMap: new Map(),
onNavigateToNode: vi.fn(),
updatingNodeId: null,
isPaid: false,
topologyMode: 'hub' as const,
onTopologyModeChange: vi.fn(),
topologyPositions: {},
onTopologyPositionsChange: vi.fn(),
...overrides,
};
}
describe('OverviewTab', () => {
it('renders node cards when nodes are present', () => {
render(<OverviewTab {...props()} />);
expect(screen.getByTestId('node-card')).toHaveTextContent('Alpha');
});
it('shows the empty state when no nodes are configured', () => {
render(<OverviewTab {...props({ nodes: [], processedNodes: [], allNodes: [] })} />);
expect(screen.getByText('No nodes configured')).toBeInTheDocument();
});
it('shows the no-match state when filters exclude every node', () => {
render(<OverviewTab {...props({ processedNodes: [], allNodes: [] })} />);
expect(screen.getByText('No nodes match your filters')).toBeInTheDocument();
});
it('renders the topology view in topology mode', () => {
render(<OverviewTab {...props({ viewMode: 'topology' })} />);
expect(screen.getByTestId('topology')).toBeInTheDocument();
});
});
@@ -0,0 +1,56 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { OverviewToolbar } from '../OverviewToolbar';
import type { FleetPaletteEntry, FleetPreferences } from '../types';
const PREFS: FleetPreferences = { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false };
function props(overrides: Partial<React.ComponentProps<typeof OverviewToolbar>> = {}) {
return {
viewMode: 'grid' as const,
onViewModeChange: vi.fn(),
searchQuery: '',
onSearchQueryChange: vi.fn(),
prefs: PREFS,
onPrefsChange: vi.fn(),
fleetPalette: [] as FleetPaletteEntry[],
labelFilters: new Set<string>(),
onLabelFiltersChange: vi.fn(),
onClearFilters: vi.fn(),
...overrides,
};
}
describe('OverviewToolbar', () => {
it('shows search and sort controls in grid mode', () => {
render(<OverviewToolbar {...props()} />);
expect(screen.getByPlaceholderText('Search nodes or stacks...')).toBeInTheDocument();
});
it('hides grid controls in topology mode', () => {
render(<OverviewToolbar {...props({ viewMode: 'topology' })} />);
expect(screen.queryByPlaceholderText('Search nodes or stacks...')).not.toBeInTheDocument();
});
it('forwards search input changes', () => {
const onSearchQueryChange = vi.fn();
render(<OverviewToolbar {...props({ onSearchQueryChange })} />);
fireEvent.change(screen.getByPlaceholderText('Search nodes or stacks...'), { target: { value: 'web' } });
expect(onSearchQueryChange).toHaveBeenCalledWith('web');
});
it('exposes the Tags filter once a palette exists (no tier gate)', () => {
const palette: FleetPaletteEntry[] = [{ key: 'prod|rose', name: 'prod', color: 'rose' }];
render(<OverviewToolbar {...props({ fleetPalette: palette })} />);
fireEvent.click(screen.getByRole('button', { name: /Filters/ }));
// "Tags" appears as both the section label and the multiselect placeholder.
expect(screen.getAllByText('Tags').length).toBeGreaterThan(0);
});
it('omits the Tags filter when the palette is empty', () => {
render(<OverviewToolbar {...props()} />);
fireEvent.click(screen.getByRole('button', { name: /Filters/ }));
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,38 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { ReconnectingOverlay } from '../ReconnectingOverlay';
beforeEach(() => {
vi.useFakeTimers();
// Health poll resolves with a startedAt equal to the captured one so the
// overlay never triggers a reload during these timing assertions.
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(
new Response(JSON.stringify({ startedAt: 1000 }), { status: 200, headers: { 'Content-Type': 'application/json' } }),
)));
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('ReconnectingOverlay', () => {
it('shows the in-progress state before the timeout', () => {
render(<ReconnectingOverlay preUpdateStartedAt={1000} />);
expect(screen.getByText('Updating Sencho...')).toBeInTheDocument();
expect(screen.queryByText('Taking longer than expected')).not.toBeInTheDocument();
});
it('switches to a non-failure "taking longer" state at the 5 minute mark', async () => {
render(<ReconnectingOverlay preUpdateStartedAt={1000} />);
// Advance to the 5-minute reconnect budget.
await act(async () => { await vi.advanceTimersByTimeAsync(5 * 60 * 1000); });
expect(screen.getByText('Taking longer than expected')).toBeInTheDocument();
// The copy must not assert failure; it offers a reload affordance.
expect(screen.getByRole('button', { name: 'Reload to check' })).toBeInTheDocument();
expect(screen.queryByText('Update timed out')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,36 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { UpdateStatusBadge } from '../UpdateStatusBadge';
describe('UpdateStatusBadge', () => {
it('renders nothing for a null status', () => {
const { container } = render(<UpdateStatusBadge status={null} />);
expect(container).toBeEmptyDOMElement();
});
it('renders the updating and completed states', () => {
const { rerender } = render(<UpdateStatusBadge status="updating" />);
expect(screen.getByText('Updating')).toBeInTheDocument();
rerender(<UpdateStatusBadge status="completed" />);
expect(screen.getByText('Updated')).toBeInTheDocument();
});
it('labels timeout and failed distinctly', () => {
const { rerender } = render(<UpdateStatusBadge status="timeout" />);
expect(screen.getByText('Timed out')).toBeInTheDocument();
rerender(<UpdateStatusBadge status="failed" />);
expect(screen.getByText('Failed')).toBeInTheDocument();
});
it('fires retry and dismiss handlers without bubbling the card click', () => {
const onRetry = vi.fn();
const onDismiss = vi.fn();
render(<UpdateStatusBadge status="failed" error="pull error" onRetry={onRetry} onDismiss={onDismiss} />);
fireEvent.click(screen.getByRole('button', { name: 'Retry update' }));
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onDismiss).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
const fetchForNodeMock = vi.fn();
vi.mock('@/lib/api', () => ({
fetchForNode: (...args: unknown[]) => fetchForNodeMock(...args),
}));
import { useFleetLabels, labelPaletteKey } from '../useFleetLabels';
import type { FleetNode } from '../../types';
function okJson(payload: unknown): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
function node(id: number, status: FleetNode['status'] = 'online'): FleetNode {
return {
id, name: `node-${id}`, type: id === 1 ? 'local' : 'remote', status,
stats: null, systemStats: null, stacks: ['web'],
cordoned: false, cordoned_at: null, cordoned_reason: null,
};
}
// Route the mock by the requested path so a node returns both its labels and
// its assignments.
function routeByPath(labels: unknown, assignments: unknown) {
return (path: string) => {
if (path === '/labels') return Promise.resolve(okJson(labels));
if (path === '/labels/assignments') return Promise.resolve(okJson(assignments));
return Promise.resolve(new Response('{}', { status: 404 }));
};
}
beforeEach(() => {
fetchForNodeMock.mockReset();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('useFleetLabels (stack labels are a Community feature)', () => {
it('fetches labels for online nodes without any tier gate', async () => {
fetchForNodeMock.mockImplementation(
routeByPath(
[{ id: 1, name: 'prod', color: 'rose' }],
{ web: [{ id: 1, name: 'prod', color: 'rose' }] },
),
);
const { result } = renderHook(() => useFleetLabels({ nodes: [node(2)] }));
await waitFor(() => expect(result.current.fleetPalette.length).toBe(1));
expect(result.current.fleetPalette[0]).toMatchObject({ name: 'prod', color: 'rose' });
expect(result.current.fleetStackLabelMap[2]).toEqual({ web: [{ id: 1, name: 'prod', color: 'rose' }] });
// Both endpoints hit for the one online node.
const paths = fetchForNodeMock.mock.calls.map(c => c[0]);
expect(paths).toContain('/labels');
expect(paths).toContain('/labels/assignments');
});
it('skips offline nodes but still aggregates online ones', async () => {
fetchForNodeMock.mockImplementation(
routeByPath([{ id: 1, name: 'edge', color: 'blue' }], { web: [{ id: 1, name: 'edge', color: 'blue' }] }),
);
const { result } = renderHook(() =>
useFleetLabels({ nodes: [node(2, 'offline'), node(3, 'online')] }),
);
await waitFor(() => expect(result.current.fleetPalette.length).toBe(1));
// Only node 3 (online) was queried.
const queriedNodeIds = new Set(fetchForNodeMock.mock.calls.map(c => c[1]));
expect(queriedNodeIds.has(3)).toBe(true);
expect(queriedNodeIds.has(2)).toBe(false);
expect(result.current.fleetStackLabelMap[2]).toBeUndefined();
});
it('dedupes the palette across nodes that share a label name+color', async () => {
fetchForNodeMock.mockImplementation(
routeByPath([{ id: 9, name: 'prod', color: 'rose' }], { web: [{ id: 9, name: 'prod', color: 'rose' }] }),
);
const { result } = renderHook(() => useFleetLabels({ nodes: [node(2), node(3)] }));
await waitFor(() => expect(Object.keys(result.current.fleetStackLabelMap).length).toBe(2));
// Same name+color from two nodes collapses to a single palette entry.
expect(result.current.fleetPalette).toHaveLength(1);
expect(result.current.fleetPalette[0].key).toBe(labelPaletteKey('prod', 'rose'));
});
it('does not fetch when there are no nodes', () => {
renderHook(() => useFleetLabels({ nodes: [] }));
expect(fetchForNodeMock).not.toHaveBeenCalled();
});
it('tolerates an unreachable node without losing the others', async () => {
fetchForNodeMock.mockImplementation((path: string, nodeId: number) => {
if (nodeId === 2) return Promise.reject(new Error('timeout'));
if (path === '/labels') return Promise.resolve(okJson([{ id: 1, name: 'ok', color: 'green' }]));
return Promise.resolve(okJson({ web: [{ id: 1, name: 'ok', color: 'green' }] }));
});
const { result } = renderHook(() => useFleetLabels({ nodes: [node(2), node(3)] }));
await waitFor(() => expect(result.current.fleetPalette.length).toBe(1));
expect(result.current.fleetStackLabelMap[3]).toBeDefined();
expect(result.current.fleetStackLabelMap[2]).toBeUndefined();
});
});
@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
const apiFetchMock = vi.fn();
const fetchForNodeMock = vi.fn();
vi.mock('@/lib/api', () => ({
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
fetchForNode: (...args: unknown[]) => fetchForNodeMock(...args),
}));
import { useFleetOverview } from '../useFleetOverview';
import type { FleetNode, FleetPreferences } from '../../types';
function okJson(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
function sys(cpu: string, mem = '40.0', disk = '30.0') {
return {
cpu: { usage: cpu, cores: 4 },
memory: { total: 100, used: 40, free: 60, usagePercent: mem },
disk: { total: 100, used: 30, free: 70, usagePercent: disk },
};
}
const NODES: FleetNode[] = [
{ id: 1, name: 'Alpha', type: 'local', status: 'online', stats: { active: 2, managed: 2, unmanaged: 0, exited: 0, total: 2 }, systemStats: sys('10.0'), stacks: ['web'], cordoned: false, cordoned_at: null, cordoned_reason: null },
{ id: 2, name: 'Bravo', type: 'remote', status: 'online', stats: { active: 5, managed: 5, unmanaged: 0, exited: 0, total: 5 }, systemStats: sys('95.0'), stacks: ['db'], cordoned: false, cordoned_at: null, cordoned_reason: null },
{ id: 3, name: 'Charlie', type: 'remote', status: 'offline', stats: null, systemStats: null, stacks: null, cordoned: false, cordoned_at: null, cordoned_reason: null },
];
const DEFAULT_PREFS: FleetPreferences = { sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false };
function setup(prefs: Partial<FleetPreferences> = {}) {
const updatePrefs = vi.fn();
const merged = { ...DEFAULT_PREFS, ...prefs };
const hook = renderHook(
(p: { prefs: FleetPreferences }) => useFleetOverview({ isPaid: false, prefs: p.prefs, updatePrefs, updateStatuses: [] }),
{ initialProps: { prefs: merged } },
);
return { ...hook, updatePrefs };
}
beforeEach(() => {
apiFetchMock.mockReset();
fetchForNodeMock.mockReset();
apiFetchMock.mockImplementation((path: string) => {
if (path === '/fleet/overview') return Promise.resolve(okJson(NODES));
if (path === '/node-labels') return Promise.resolve(okJson({}));
return Promise.resolve(okJson({}));
});
fetchForNodeMock.mockResolvedValue(okJson([]));
});
afterEach(() => vi.clearAllMocks());
describe('useFleetOverview', () => {
it('loads nodes and computes masthead stats', async () => {
const { result } = setup();
await act(async () => { await result.current.fetchOverview(); });
expect(result.current.nodes).toHaveLength(3);
expect(result.current.loading).toBe(false);
expect(result.current.mastheadStats.nodeCount).toBe(3);
expect(result.current.mastheadStats.onlineCount).toBe(2);
// Bravo at 95% CPU is critical.
expect(result.current.mastheadStats.criticalCount).toBe(1);
expect(result.current.lastSyncAt).toBeTypeOf('number');
});
it('filters by search query across node name and stack names', async () => {
const { result } = setup();
await act(async () => { await result.current.fetchOverview(); });
act(() => result.current.setSearchQuery('db'));
await waitFor(() => expect(result.current.processedNodes).toHaveLength(1));
expect(result.current.processedNodes[0].name).toBe('Bravo');
});
it('filters by status=offline', async () => {
const { result } = setup({ filterStatus: 'offline' });
await act(async () => { await result.current.fetchOverview(); });
expect(result.current.processedNodes.map(n => n.name)).toEqual(['Charlie']);
});
it('filters critical-only to the high-CPU node', async () => {
const { result } = setup({ filterCritical: true });
await act(async () => { await result.current.fetchOverview(); });
expect(result.current.processedNodes.map(n => n.name)).toEqual(['Bravo']);
});
it('sorts by cpu descending', async () => {
const { result } = setup({ sortBy: 'cpu', sortDir: 'asc' });
await act(async () => { await result.current.fetchOverview(); });
// cpu sort is inherently descending (b - a); offline node reads 0.
expect(result.current.processedNodes.map(n => n.name)).toEqual(['Bravo', 'Alpha', 'Charlie']);
});
it('ignores an aborted fetch without surfacing an error', async () => {
const { result } = setup();
apiFetchMock.mockImplementationOnce(() => Promise.reject(new DOMException('aborted', 'AbortError')));
await act(async () => { await result.current.fetchOverview(); });
// No throw; nodes stay empty, loading cleared.
expect(result.current.nodes).toHaveLength(0);
expect(result.current.loading).toBe(false);
});
it('clearFilters resets prefs and label filters', async () => {
const { result, updatePrefs } = setup({ filterStatus: 'online' });
await act(async () => { await result.current.fetchOverview(); });
act(() => result.current.clearFilters());
expect(updatePrefs).toHaveBeenCalledWith({ filterStatus: 'all', filterType: 'all', filterCritical: false });
});
});
@@ -0,0 +1,72 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useFleetPolling } from '../useFleetPolling';
import type { NodeUpdateStatus } from '../../types';
function updating(status: NodeUpdateStatus['updateStatus']): NodeUpdateStatus[] {
return [{ nodeId: 1, name: 'n', type: 'remote', version: '1', latestVersion: '1', updateAvailable: false, updateStatus: status }];
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); });
describe('useFleetPolling', () => {
it('fetches both endpoints once on mount', () => {
const fetchOverview = vi.fn();
const fetchUpdateStatus = vi.fn();
renderHook(() => useFleetPolling({ fetchOverview, fetchUpdateStatus, updateStatuses: [] }));
expect(fetchOverview).toHaveBeenCalledTimes(1);
expect(fetchUpdateStatus).toHaveBeenCalledTimes(1);
});
it('polls overview every 30s and update-status every 2m at baseline', () => {
const fetchOverview = vi.fn();
const fetchUpdateStatus = vi.fn();
renderHook(() => useFleetPolling({ fetchOverview, fetchUpdateStatus, updateStatuses: [] }));
fetchOverview.mockClear();
fetchUpdateStatus.mockClear();
// 90s -> overview fires 3 times, update-status 0 times (120s interval, no fast poll since not updating).
act(() => { vi.advanceTimersByTime(90_000); });
expect(fetchOverview).toHaveBeenCalledTimes(3);
expect(fetchUpdateStatus).toHaveBeenCalledTimes(0);
// Reaching 120s total fires the update-status baseline.
act(() => { vi.advanceTimersByTime(30_000); });
expect(fetchUpdateStatus).toHaveBeenCalledTimes(1);
});
it('fast-polls both endpoints every 5s while a node is updating', () => {
const fetchOverview = vi.fn();
const fetchUpdateStatus = vi.fn();
const { rerender } = renderHook(
({ statuses }) => useFleetPolling({ fetchOverview, fetchUpdateStatus, updateStatuses: statuses }),
{ initialProps: { statuses: updating(null) } },
);
// No fast poll while nothing is updating.
act(() => { vi.advanceTimersByTime(5_000); });
// Flip a node to 'updating' -> the 5s tick now drives both fetchers.
rerender({ statuses: updating('updating') });
fetchOverview.mockClear();
fetchUpdateStatus.mockClear();
act(() => { vi.advanceTimersByTime(5_000); });
expect(fetchOverview).toHaveBeenCalled();
expect(fetchUpdateStatus).toHaveBeenCalled();
});
it('clears all intervals on unmount', () => {
const fetchOverview = vi.fn();
const fetchUpdateStatus = vi.fn();
const { unmount } = renderHook(() => useFleetPolling({ fetchOverview, fetchUpdateStatus, updateStatuses: updating('updating') }));
fetchOverview.mockClear();
fetchUpdateStatus.mockClear();
unmount();
act(() => { vi.advanceTimersByTime(120_000); });
expect(fetchOverview).not.toHaveBeenCalled();
expect(fetchUpdateStatus).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useFleetPreferences } from '../useFleetPreferences';
const PREFS_KEY = 'sencho-fleet-preferences';
beforeEach(() => localStorage.clear());
afterEach(() => { localStorage.clear(); vi.restoreAllMocks(); });
describe('useFleetPreferences', () => {
it('starts from defaults when nothing is stored', () => {
const { result } = renderHook(() => useFleetPreferences());
expect(result.current.prefs).toEqual({
sortBy: 'name', sortDir: 'asc', filterStatus: 'all', filterType: 'all', filterCritical: false,
});
});
it('persists updates to localStorage', () => {
const { result } = renderHook(() => useFleetPreferences());
act(() => result.current.updatePrefs({ sortBy: 'cpu', sortDir: 'desc' }));
expect(result.current.prefs.sortBy).toBe('cpu');
expect(result.current.prefs.sortDir).toBe('desc');
const stored = JSON.parse(localStorage.getItem(PREFS_KEY) ?? '{}');
expect(stored.sortBy).toBe('cpu');
expect(stored.sortDir).toBe('desc');
});
it('merges stored prefs over defaults on mount', () => {
localStorage.setItem(PREFS_KEY, JSON.stringify({ filterStatus: 'online' }));
const { result } = renderHook(() => useFleetPreferences());
expect(result.current.prefs.filterStatus).toBe('online');
// Unspecified fields fall back to defaults.
expect(result.current.prefs.sortBy).toBe('name');
});
it('falls back to defaults when stored JSON is corrupt', () => {
localStorage.setItem(PREFS_KEY, '{ not json');
const { result } = renderHook(() => useFleetPreferences());
expect(result.current.prefs.sortBy).toBe('name');
});
});
@@ -0,0 +1,155 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
const apiFetchMock = vi.fn();
const toastSuccess = vi.fn();
const toastError = vi.fn();
vi.mock('@/lib/api', () => ({
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: { success: (...a: unknown[]) => toastSuccess(...a), error: (...a: unknown[]) => toastError(...a) },
}));
import { useFleetUpdateStatus } from '../useFleetUpdateStatus';
import type { NodeUpdateStatus } from '../../types';
function okJson(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
const STATUSES: NodeUpdateStatus[] = [
{ nodeId: 1, name: 'Local', type: 'local', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: true, updateStatus: null },
{ nodeId: 2, name: 'Edge', type: 'remote', version: '1.0.0', latestVersion: '1.1.0', updateAvailable: true, updateStatus: null },
];
beforeEach(() => {
apiFetchMock.mockReset();
toastSuccess.mockReset();
toastError.mockReset();
});
afterEach(() => vi.clearAllMocks());
describe('useFleetUpdateStatus', () => {
it('fetchUpdateStatus populates updateStatuses from the response', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.fetchUpdateStatus(); });
expect(result.current.updateStatuses).toHaveLength(2);
expect(result.current.updateStatuses[1].name).toBe('Edge');
});
it('logs (does not swallow) a failed update-status poll without toasting', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
apiFetchMock.mockRejectedValue(new Error('network down'));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.fetchUpdateStatus(); });
expect(warnSpy).toHaveBeenCalledWith('[Fleet] Failed to fetch update status:', expect.any(Error));
// Polled call must not toast on failure.
expect(toastError).not.toHaveBeenCalled();
expect(result.current.updateStatuses).toEqual([]);
warnSpy.mockRestore();
});
it('logs a non-ok update-status response (HTTP error) without toasting', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
apiFetchMock.mockResolvedValue(new Response('boom', { status: 500 }));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.fetchUpdateStatus(); });
expect(warnSpy).toHaveBeenCalledWith('[Fleet] update-status returned HTTP', 500);
expect(toastError).not.toHaveBeenCalled();
expect(result.current.updateStatuses).toEqual([]);
warnSpy.mockRestore();
});
it('preserves the last-known statuses when a later poll fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// First poll succeeds and seeds two statuses.
apiFetchMock.mockResolvedValueOnce(okJson({ nodes: STATUSES }));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.fetchUpdateStatus(); });
expect(result.current.updateStatuses).toHaveLength(2);
// A subsequent poll fails; the table must keep the seeded statuses.
apiFetchMock.mockRejectedValueOnce(new Error('network down'));
await act(async () => { await result.current.fetchUpdateStatus(); });
expect(result.current.updateStatuses).toHaveLength(2);
expect(result.current.updateStatuses[0].name).toBe('Local');
expect(warnSpy).toHaveBeenCalled();
expect(toastError).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
it('triggerNodeUpdate on a local node opens the confirm dialog instead of POSTing', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.fetchUpdateStatus(); });
apiFetchMock.mockClear();
await act(async () => { await result.current.triggerNodeUpdate(1); });
expect(result.current.localUpdateConfirm).toBe(1);
// No update POST should have fired for the local node.
expect(apiFetchMock).not.toHaveBeenCalled();
});
it('triggerNodeUpdate on a remote node POSTs and toasts success', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.fetchUpdateStatus(); });
apiFetchMock.mockResolvedValue(okJson({ message: 'ok' }));
await act(async () => { await result.current.triggerNodeUpdate(2); });
expect(apiFetchMock).toHaveBeenCalledWith('/fleet/nodes/2/update', expect.objectContaining({ method: 'POST', localOnly: true }));
expect(toastSuccess).toHaveBeenCalledWith(expect.stringContaining('Edge'));
});
it('triggerNodeUpdate surfaces a server error message via toast', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.fetchUpdateStatus(); });
apiFetchMock.mockResolvedValue(new Response(JSON.stringify({ error: 'node busy' }), { status: 409 }));
await act(async () => { await result.current.triggerNodeUpdate(2); });
expect(toastError).toHaveBeenCalledWith('node busy');
});
it('dismissNodeUpdate issues a DELETE then refetches', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: [] }));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.dismissNodeUpdate(2); });
expect(apiFetchMock).toHaveBeenCalledWith('/fleet/nodes/2/update-status', expect.objectContaining({ method: 'DELETE' }));
});
it('triggerUpdateAll reports the number of nodes updating', async () => {
apiFetchMock.mockResolvedValue(okJson({ updating: ['a', 'b'], skipped: [] }));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.triggerUpdateAll(); });
expect(apiFetchMock).toHaveBeenCalledWith('/fleet/update-all', expect.objectContaining({ method: 'POST' }));
expect(toastSuccess).toHaveBeenCalledWith(expect.stringContaining('2 nodes'));
});
it('checkUpdates opens the modal and toggles the checking flag', async () => {
apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES }));
const { result } = renderHook(() => useFleetUpdateStatus());
await act(async () => { await result.current.checkUpdates(); });
expect(result.current.showUpdateModal).toBe(true);
expect(result.current.checkingUpdates).toBe(false);
await waitFor(() => expect(result.current.updateStatuses).toHaveLength(2));
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
const apiFetchMock = vi.fn();
vi.mock('@/lib/api', () => ({
apiFetch: (...args: unknown[]) => apiFetchMock(...args),
}));
import { useNodeLabels } from '../useNodeLabels';
import type { FleetNode } from '../../types';
function okJson(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
function node(id: number): FleetNode {
return {
id, name: `node-${id}`, type: 'remote', status: 'online',
stats: null, systemStats: null, stacks: null,
cordoned: false, cordoned_at: null, cordoned_reason: null,
};
}
beforeEach(() => apiFetchMock.mockReset());
afterEach(() => vi.clearAllMocks());
describe('useNodeLabels (node-tag aggregation stays paid)', () => {
it('does not fetch and reports unavailable when not paid', async () => {
const { result } = renderHook(() => useNodeLabels({ isPaid: false, nodes: [node(2)] }));
expect(result.current.isAvailable).toBe(false);
expect(result.current.labelsByNodeId).toEqual({});
// Allow any effect to flush; still no call.
await Promise.resolve();
expect(apiFetchMock).not.toHaveBeenCalled();
});
it('fetches and normalizes string keys to numbers when paid', async () => {
apiFetchMock.mockResolvedValue(okJson({ '2': ['prod', 'edge'], '3': ['db'] }));
const { result } = renderHook(() => useNodeLabels({ isPaid: true, nodes: [node(2), node(3)] }));
await waitFor(() => expect(Object.keys(result.current.labelsByNodeId).length).toBe(2));
expect(result.current.isAvailable).toBe(true);
expect(result.current.labelsByNodeId[2]).toEqual(['prod', 'edge']);
expect(result.current.labelsByNodeId[3]).toEqual(['db']);
// distinctLabels is the sorted union.
expect(result.current.distinctLabels).toEqual(['db', 'edge', 'prod']);
expect(apiFetchMock).toHaveBeenCalledWith('/node-labels', expect.objectContaining({ localOnly: true }));
});
it('clears the map on a non-ok response', async () => {
apiFetchMock.mockResolvedValue(new Response('nope', { status: 403 }));
const { result } = renderHook(() => useNodeLabels({ isPaid: true, nodes: [node(2)] }));
await waitFor(() => expect(apiFetchMock).toHaveBeenCalled());
expect(result.current.labelsByNodeId).toEqual({});
});
});
@@ -8,16 +8,19 @@ export function labelPaletteKey(name: string, color: LabelColor): string {
}
interface UseFleetLabelsOptions {
isPaid: boolean;
nodes: FleetNode[];
}
export function useFleetLabels({ isPaid, nodes }: UseFleetLabelsOptions) {
// Stack labels are a Community feature (the per-node `/labels` and
// `/labels/assignments` reads are open to any authenticated user), so the
// fleet-wide palette and per-stack chips render on every tier. Node-level tag
// aggregation stays paid and lives in useNodeLabels.
export function useFleetLabels({ nodes }: UseFleetLabelsOptions) {
const [fleetPalette, setFleetPalette] = useState<FleetPaletteEntry[]>([]);
const [fleetStackLabelMap, setFleetStackLabelMap] = useState<Record<number, Record<string, StackLabel[]>>>({});
const fetchLabelsForNodes = useCallback(async (fleetNodes: FleetNode[]) => {
if (!isPaid || fleetNodes.length === 0) return;
if (fleetNodes.length === 0) return;
const paletteMap = new Map<string, FleetPaletteEntry>();
const stackLabelMap: Record<number, Record<string, StackLabel[]>> = {};
@@ -48,7 +51,7 @@ export function useFleetLabels({ isPaid, nodes }: UseFleetLabelsOptions) {
setFleetPalette(Array.from(paletteMap.values()).sort((a, b) => a.name.localeCompare(b.name)));
setFleetStackLabelMap(stackLabelMap);
}, [isPaid]);
}, []);
// Refetch labels only when the set of online nodes actually changes,
// not on every fetchOverview tick (which mints a new nodes ref).
@@ -62,10 +65,10 @@ export function useFleetLabels({ isPaid, nodes }: UseFleetLabelsOptions) {
);
useEffect(() => {
if (!isPaid || nodes.length === 0) return;
if (nodes.length === 0) return;
fetchLabelsForNodes(nodes);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPaid, onlineNodeKey, fetchLabelsForNodes]);
}, [onlineNodeKey, fetchLabelsForNodes]);
return { fleetPalette, fleetStackLabelMap };
}
@@ -34,7 +34,7 @@ export function useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses }:
const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set());
const abortRef = useRef<AbortController | null>(null);
const { fleetPalette, fleetStackLabelMap } = useFleetLabels({ isPaid, nodes });
const { fleetPalette, fleetStackLabelMap } = useFleetLabels({ nodes });
const { labelsByNodeId, distinctLabels, isAvailable: nodeLabelsAvailable } = useNodeLabels({ isPaid, nodes });
const fetchOverview = useCallback(async (showRefresh = false) => {
@@ -26,8 +26,18 @@ export function useFleetUpdateStatus() {
setUpdateStatuses(prev =>
JSON.stringify(prev) === JSON.stringify(next) ? prev : next
);
} else {
// apiFetch only throws on 401/network, so an HTTP error (500/403/
// 502) lands here, not in the catch. Log it so the breadcrumb
// covers backend failures too; keep last-known statuses.
console.warn('[Fleet] update-status returned HTTP', res.status);
}
} catch { /* non-critical */ }
} catch (error) {
// Polled call (every 5s while updating, 2m otherwise): log for
// diagnosis but stay silent in the UI so a transient failure does
// not toast on every tick. The view keeps its last-known statuses.
console.warn('[Fleet] Failed to fetch update status:', error);
}
}, []);
const triggerNodeUpdate = useCallback(async (nodeId: number) => {