From d8f73f820315d4d43a89e49882a59f425213c048 Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 1 Jun 2026 13:20:36 -0400 Subject: [PATCH] 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. --- .../fleet-update-tracker-service.test.ts | 95 +++++++++++ backend/src/routes/fleet.ts | 4 + docs/features/fleet-view.mdx | 5 +- .../FleetView/ReconnectingOverlay.tsx | 14 +- .../FleetView/__tests__/NodeCard.test.tsx | 69 ++++++++ .../__tests__/NodeUpdatesSheet.test.tsx | 66 ++++++++ .../FleetView/__tests__/OverviewTab.test.tsx | 68 ++++++++ .../__tests__/OverviewToolbar.test.tsx | 56 +++++++ .../__tests__/ReconnectingOverlay.test.tsx | 38 +++++ .../__tests__/UpdateStatusBadge.test.tsx | 36 ++++ .../hooks/__tests__/useFleetLabels.test.tsx | 114 +++++++++++++ .../hooks/__tests__/useFleetOverview.test.tsx | 113 +++++++++++++ .../hooks/__tests__/useFleetPolling.test.tsx | 72 ++++++++ .../__tests__/useFleetPreferences.test.tsx | 43 +++++ .../__tests__/useFleetUpdateStatus.test.tsx | 155 ++++++++++++++++++ .../hooks/__tests__/useNodeLabels.test.tsx | 58 +++++++ .../FleetView/hooks/useFleetLabels.ts | 15 +- .../FleetView/hooks/useFleetOverview.ts | 2 +- .../FleetView/hooks/useFleetUpdateStatus.ts | 12 +- 19 files changed, 1022 insertions(+), 13 deletions(-) create mode 100644 backend/src/__tests__/fleet-update-tracker-service.test.ts create mode 100644 frontend/src/components/FleetView/__tests__/NodeCard.test.tsx create mode 100644 frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx create mode 100644 frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx create mode 100644 frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx create mode 100644 frontend/src/components/FleetView/__tests__/ReconnectingOverlay.test.tsx create mode 100644 frontend/src/components/FleetView/__tests__/UpdateStatusBadge.test.tsx create mode 100644 frontend/src/components/FleetView/hooks/__tests__/useFleetLabels.test.tsx create mode 100644 frontend/src/components/FleetView/hooks/__tests__/useFleetOverview.test.tsx create mode 100644 frontend/src/components/FleetView/hooks/__tests__/useFleetPolling.test.tsx create mode 100644 frontend/src/components/FleetView/hooks/__tests__/useFleetPreferences.test.tsx create mode 100644 frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx create mode 100644 frontend/src/components/FleetView/hooks/__tests__/useNodeLabels.test.tsx diff --git a/backend/src/__tests__/fleet-update-tracker-service.test.ts b/backend/src/__tests__/fleet-update-tracker-service.test.ts new file mode 100644 index 00000000..61499d92 --- /dev/null +++ b/backend/src/__tests__/fleet-update-tracker-service.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { FleetUpdateTrackerService } from '../services/FleetUpdateTrackerService'; + +// The service is a process-local singleton. Each test clears any entries left +// by a prior test so assertions on size() are deterministic. +function clearAll(svc: FleetUpdateTrackerService) { + for (const [nodeId] of [...svc.entries()]) svc.delete(nodeId); +} + +describe('FleetUpdateTrackerService', () => { + let svc: FleetUpdateTrackerService; + + beforeEach(() => { + svc = FleetUpdateTrackerService.getInstance(); + clearAll(svc); + }); + + it('returns the same singleton instance', () => { + expect(FleetUpdateTrackerService.getInstance()).toBe(svc); + }); + + it('create() starts an updating tracker with startedAt and no resolvedAt', () => { + const before = Date.now(); + const tracker = svc.create('updating', '1.0.0', 1234); + expect(tracker.status).toBe('updating'); + expect(tracker.previousVersion).toBe('1.0.0'); + expect(tracker.previousProcessStart).toBe(1234); + expect(tracker.wasOffline).toBe(false); + expect(tracker.startedAt).toBeGreaterThanOrEqual(before); + expect(tracker.resolvedAt).toBeUndefined(); + }); + + it('create() with a terminal status sets resolvedAt immediately', () => { + const tracker = svc.create('failed', '1.0.0', null, 'pull failed'); + expect(tracker.status).toBe('failed'); + expect(tracker.error).toBe('pull failed'); + expect(tracker.resolvedAt).toBeTypeOf('number'); + }); + + it('set()/get() round-trips a tracker by node id', () => { + const tracker = svc.create('updating', '1.0.0', null); + svc.set(7, tracker); + expect(svc.get(7)).toEqual(tracker); + expect(svc.get(999)).toBeUndefined(); + }); + + it('resolve() returns a terminal copy without mutating the original', () => { + const original = svc.create('updating', '1.0.0', null); + const resolved = svc.resolve(original, 'completed'); + expect(resolved).not.toBe(original); + expect(original.status).toBe('updating'); + expect(original.resolvedAt).toBeUndefined(); + expect(resolved.status).toBe('completed'); + expect(resolved.resolvedAt).toBeTypeOf('number'); + // Carries forward the immutable fields. + expect(resolved.startedAt).toBe(original.startedAt); + expect(resolved.previousVersion).toBe(original.previousVersion); + }); + + it('resolve() to timeout/failed carries the error message', () => { + const original = svc.create('updating', '1.0.0', null); + const timedOut = svc.resolve(original, 'timeout', 'did not come back'); + expect(timedOut.status).toBe('timeout'); + expect(timedOut.error).toBe('did not come back'); + }); + + it('delete() removes a tracker and reports whether one existed', () => { + svc.set(3, svc.create('completed', '1.0.0', null)); + expect(svc.size()).toBe(1); + expect(svc.delete(3)).toBe(true); + expect(svc.delete(3)).toBe(false); + expect(svc.get(3)).toBeUndefined(); + expect(svc.size()).toBe(0); + }); + + it('entries() iterates every stored tracker keyed by node id', () => { + svc.set(1, svc.create('updating', '1.0.0', null)); + svc.set(2, svc.create('completed', '1.0.0', null)); + const ids = [...svc.entries()].map(([id]) => id).sort(); + expect(ids).toEqual([1, 2]); + expect(svc.size()).toBe(2); + }); + + it('models the full updating -> completed transition the way the poll loop uses it', () => { + // create updating, store, then resolve and store the terminal copy. + const updating = svc.create('updating', '1.0.0', 100); + svc.set(5, updating); + expect(svc.get(5)?.status).toBe('updating'); + + const completed = svc.resolve(svc.get(5)!, 'completed'); + svc.set(5, completed); + expect(svc.get(5)?.status).toBe('completed'); + expect(svc.get(5)?.resolvedAt).toBeTypeOf('number'); + }); +}); diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 9745eeed..f7b17fcf 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -826,6 +826,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp const nodeStatuses = results.map((r, i) => { if (r.status === 'fulfilled') return r.value; + console.warn(`[Fleet] Update-status poll failed for node ${nodes[i].name}:`, r.reason); return { nodeId: nodes[i].id, name: nodes[i].name, @@ -995,6 +996,9 @@ fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Respon const skipped = nodes.filter(n => !candidates.includes(n)).map(n => n.name); for (let i = 0; i < results.length; i++) { const r = results[i]; + if (r.status === 'rejected') { + console.warn(`[Fleet] Update-all failed for node ${candidates[i].name}:`, r.reason); + } const val = r.status === 'fulfilled' ? r.value : { name: candidates[i].name, triggered: false }; (val.triggered ? updating : skipped).push(val.name); } diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index ab9737b8..5d79d171 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -246,7 +246,7 @@ The latest-version label is resolved from the GitHub Releases API (with a Docker When you click **Update** on a remote row, Sencho dispatches the update command to that remote's API. The remote pulls the latest Docker image directly, then spawns a short-lived helper container that bind-mounts the compose working directory from the host and runs `docker compose up -d --force-recreate `. The remote restarts with the new image; the table cell flips from **Updating** to a green **Updated** badge once the gateway detects the version change. The **Updated** state stays visible for a few seconds before the row settles back to **Up to date**. -When you click **Update** on the local row, a confirmation dialog appears first ("Update local node"). Confirming kicks off the same pull-and-recreate flow on the local Sencho instance. Because the gateway is restarting itself, a full-screen reconnecting overlay takes over the browser tab. The overlay polls `/api/health` every 3 seconds and dismisses itself once the new gateway answers; if the overlay does not clear within 5 minutes it surfaces a *Try Reloading* button instead of waiting indefinitely. +When you click **Update** on the local row, a confirmation dialog appears first ("Update local node"). Confirming kicks off the same pull-and-recreate flow on the local Sencho instance. Because the gateway is restarting itself, a full-screen reconnecting overlay takes over the browser tab. The overlay polls `/api/health` every 3 seconds and dismisses itself once the new gateway answers. If the gateway has not returned within 5 minutes the overlay switches to a *Taking longer than expected* state with a **Reload to check** button rather than waiting indefinitely. A large image pull can legitimately run past that window, so this state is not a failure on its own. Triggering updates (per-row and **Update all**) requires the resolved user to have the admin role. Operator and viewer roles can read update status but cannot initiate updates. @@ -275,6 +275,9 @@ Fleet View queries every registered node in parallel. Each node responds indepen Fleet update operations (per-row and **Update all**) are admin-only. Viewer and operator roles can see the table and the badges but the button returns a 403 when clicked. Ask a Sencho administrator either to run the update for you or to grant your account the admin role. + + When you update the local (control) node, Sencho restarts itself and a full-screen overlay waits for the new gateway to answer. If it has not come back within five minutes the overlay stops waiting and offers a **Reload to check** button. On its own this is not a failure: a large image pull can take longer than the reconnect window. Give the pull a little more time and click **Reload to check**. If the page still does not load after the Sencho container has restarted, inspect it on the Docker host with `docker ps` and `docker logs`. + Free mode stores node positions in your browser's local storage. The arrangement is per-browser; it does not sync across devices or users. If positions reset on reload, the most common causes are a browser session that has local storage disabled (private or incognito windows, strict tracking-prevention modes, which fall back to in-memory state for the session only), storage cleared by browser cleanup tools or a profile reset, or simply a different browser or profile. To verify storage is writable, open DevTools → Application → Local Storage on the Sencho origin and confirm the `sencho-topology-preferences` key exists after switching modes or dragging a node. diff --git a/frontend/src/components/FleetView/ReconnectingOverlay.tsx b/frontend/src/components/FleetView/ReconnectingOverlay.tsx index eb35102e..5dade6b5 100644 --- a/frontend/src/components/FleetView/ReconnectingOverlay.tsx +++ b/frontend/src/components/FleetView/ReconnectingOverlay.tsx @@ -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 ? ( <> -

Update timed out

+

Taking longer than expected

- 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.

) : ( diff --git a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx new file mode 100644 index 00000000..3c9a9b3b --- /dev/null +++ b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx @@ -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(); + 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(); + 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(); + 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(); + // The actions menu only renders when cordon (admiral-only here) is available. + expect(screen.getByRole('button', { name: 'Node actions' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx new file mode 100644 index 00000000..b6df309b --- /dev/null +++ b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx @@ -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> = {}) { + 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(); + expect(screen.getByText('Local')).toBeInTheDocument(); + expect(screen.getByText('Edge')).toBeInTheDocument(); + expect(screen.getByText('Db')).toBeInTheDocument(); + }); + + it('shows a checking spinner state', () => { + render(); + expect(screen.getByText('Checking for updates...')).toBeInTheDocument(); + }); + + it('shows the empty state with no nodes', () => { + render(); + expect(screen.getByText('No nodes found.')).toBeInTheDocument(); + }); + + it('triggers a per-node update from the Update button', () => { + const triggerNodeUpdate = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: /Update$/ })); + expect(triggerNodeUpdate).toHaveBeenCalledWith(2); + }); + + it('filters the node table by the search box', () => { + render(); + fireEvent.change(screen.getByPlaceholderText('Filter nodes...'), { target: { value: 'edge' } }); + expect(screen.getByText('Edge')).toBeInTheDocument(); + expect(screen.queryByText('Local')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx b/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx new file mode 100644 index 00000000..c8b095d1 --- /dev/null +++ b/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx @@ -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 } }) =>
{node.name}
})); +vi.mock('../../fleet/FleetTopology', () => ({ FleetTopology: () =>
})); + +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> = {}) { + 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(), + 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(); + expect(screen.getByTestId('node-card')).toHaveTextContent('Alpha'); + }); + + it('shows the empty state when no nodes are configured', () => { + render(); + expect(screen.getByText('No nodes configured')).toBeInTheDocument(); + }); + + it('shows the no-match state when filters exclude every node', () => { + render(); + expect(screen.getByText('No nodes match your filters')).toBeInTheDocument(); + }); + + it('renders the topology view in topology mode', () => { + render(); + expect(screen.getByTestId('topology')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx b/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx new file mode 100644 index 00000000..c8c84389 --- /dev/null +++ b/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx @@ -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> = {}) { + return { + viewMode: 'grid' as const, + onViewModeChange: vi.fn(), + searchQuery: '', + onSearchQueryChange: vi.fn(), + prefs: PREFS, + onPrefsChange: vi.fn(), + fleetPalette: [] as FleetPaletteEntry[], + labelFilters: new Set(), + onLabelFiltersChange: vi.fn(), + onClearFilters: vi.fn(), + ...overrides, + }; +} + +describe('OverviewToolbar', () => { + it('shows search and sort controls in grid mode', () => { + render(); + expect(screen.getByPlaceholderText('Search nodes or stacks...')).toBeInTheDocument(); + }); + + it('hides grid controls in topology mode', () => { + render(); + expect(screen.queryByPlaceholderText('Search nodes or stacks...')).not.toBeInTheDocument(); + }); + + it('forwards search input changes', () => { + const onSearchQueryChange = vi.fn(); + render(); + 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(); + 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(); + fireEvent.click(screen.getByRole('button', { name: /Filters/ })); + expect(screen.queryByText('Tags')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/FleetView/__tests__/ReconnectingOverlay.test.tsx b/frontend/src/components/FleetView/__tests__/ReconnectingOverlay.test.tsx new file mode 100644 index 00000000..695f9270 --- /dev/null +++ b/frontend/src/components/FleetView/__tests__/ReconnectingOverlay.test.tsx @@ -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(); + 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(); + + // 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(); + }); +}); diff --git a/frontend/src/components/FleetView/__tests__/UpdateStatusBadge.test.tsx b/frontend/src/components/FleetView/__tests__/UpdateStatusBadge.test.tsx new file mode 100644 index 00000000..e2616a41 --- /dev/null +++ b/frontend/src/components/FleetView/__tests__/UpdateStatusBadge.test.tsx @@ -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(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the updating and completed states', () => { + const { rerender } = render(); + expect(screen.getByText('Updating')).toBeInTheDocument(); + rerender(); + expect(screen.getByText('Updated')).toBeInTheDocument(); + }); + + it('labels timeout and failed distinctly', () => { + const { rerender } = render(); + expect(screen.getByText('Timed out')).toBeInTheDocument(); + rerender(); + 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(); + + fireEvent.click(screen.getByRole('button', { name: 'Retry update' })); + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(onRetry).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/FleetView/hooks/__tests__/useFleetLabels.test.tsx b/frontend/src/components/FleetView/hooks/__tests__/useFleetLabels.test.tsx new file mode 100644 index 00000000..c7be2de4 --- /dev/null +++ b/frontend/src/components/FleetView/hooks/__tests__/useFleetLabels.test.tsx @@ -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(); + }); +}); diff --git a/frontend/src/components/FleetView/hooks/__tests__/useFleetOverview.test.tsx b/frontend/src/components/FleetView/hooks/__tests__/useFleetOverview.test.tsx new file mode 100644 index 00000000..99c9f525 --- /dev/null +++ b/frontend/src/components/FleetView/hooks/__tests__/useFleetOverview.test.tsx @@ -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 = {}) { + 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 }); + }); +}); diff --git a/frontend/src/components/FleetView/hooks/__tests__/useFleetPolling.test.tsx b/frontend/src/components/FleetView/hooks/__tests__/useFleetPolling.test.tsx new file mode 100644 index 00000000..f015ffac --- /dev/null +++ b/frontend/src/components/FleetView/hooks/__tests__/useFleetPolling.test.tsx @@ -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(); + }); +}); diff --git a/frontend/src/components/FleetView/hooks/__tests__/useFleetPreferences.test.tsx b/frontend/src/components/FleetView/hooks/__tests__/useFleetPreferences.test.tsx new file mode 100644 index 00000000..e59a12dc --- /dev/null +++ b/frontend/src/components/FleetView/hooks/__tests__/useFleetPreferences.test.tsx @@ -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'); + }); +}); diff --git a/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx b/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx new file mode 100644 index 00000000..bda67754 --- /dev/null +++ b/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx @@ -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)); + }); +}); diff --git a/frontend/src/components/FleetView/hooks/__tests__/useNodeLabels.test.tsx b/frontend/src/components/FleetView/hooks/__tests__/useNodeLabels.test.tsx new file mode 100644 index 00000000..01932227 --- /dev/null +++ b/frontend/src/components/FleetView/hooks/__tests__/useNodeLabels.test.tsx @@ -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({}); + }); +}); diff --git a/frontend/src/components/FleetView/hooks/useFleetLabels.ts b/frontend/src/components/FleetView/hooks/useFleetLabels.ts index a46cfddc..3efee25f 100644 --- a/frontend/src/components/FleetView/hooks/useFleetLabels.ts +++ b/frontend/src/components/FleetView/hooks/useFleetLabels.ts @@ -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([]); const [fleetStackLabelMap, setFleetStackLabelMap] = useState>>({}); const fetchLabelsForNodes = useCallback(async (fleetNodes: FleetNode[]) => { - if (!isPaid || fleetNodes.length === 0) return; + if (fleetNodes.length === 0) return; const paletteMap = new Map(); const stackLabelMap: Record> = {}; @@ -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 }; } diff --git a/frontend/src/components/FleetView/hooks/useFleetOverview.ts b/frontend/src/components/FleetView/hooks/useFleetOverview.ts index 10c6a0ac..434f7a0f 100644 --- a/frontend/src/components/FleetView/hooks/useFleetOverview.ts +++ b/frontend/src/components/FleetView/hooks/useFleetOverview.ts @@ -34,7 +34,7 @@ export function useFleetOverview({ isPaid, prefs, updatePrefs, updateStatuses }: const [labelFilters, setLabelFilters] = useState>(new Set()); const abortRef = useRef(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) => { diff --git a/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts b/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts index aa327588..41d05f75 100644 --- a/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts +++ b/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts @@ -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) => {