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) => {