diff --git a/docs/features/global-search.mdx b/docs/features/global-search.mdx index cbbda164..2cef5df9 100644 --- a/docs/features/global-search.mdx +++ b/docs/features/global-search.mdx @@ -37,7 +37,7 @@ Each stack row shows a status dot on the left (green for running, grey for exite ## Typing to filter -Start typing and the results narrow in real time. Pages and nodes filter instantly; stack search debounces for about 250 ms before fanning out across the fleet, so a fast typist sees the Stacks group fill in a beat after the rest. Matching is substring-based, so you can type a fragment from anywhere in a stack's filename or a node's name. Examples: +Start typing and the results narrow in real time. Pages and nodes filter instantly. Stack search debounces for about 250 ms before fanning out across the fleet when you begin a search; once the results are in, refining the query filters them instantly without querying the fleet again. Matching is substring-based, so you can type a fragment from anywhere in a stack's filename or a node's name. Examples: - `fleet` jumps to the Fleet page. - `local` selects the Local node and switches the active context to it. @@ -57,6 +57,8 @@ The Stacks group caps at 50 rows. When a query matches more than that, a `Showin While the fleet is responding, the empty area reads `Searching...` until the first batch of stack hits arrives. If no group has a hit at all, the empty area reads `No results.` +If a node cannot be reached while the search is running, the palette still shows the stacks it could gather from the rest of the fleet and adds a `N nodes unreachable, stack results may be incomplete` line beneath the Stacks group. Hover the line to see which nodes were skipped and why. This tells you a match might exist on a host that did not answer, rather than leaving you to assume the stack does not exist. + ## Keyboard navigation Once the palette is open: @@ -66,3 +68,23 @@ Once the palette is open: - Esc closes the palette. Offline nodes show as greyed-out and are not selectable. + +## Troubleshooting + + + + Stack search needs permission to read stacks on each node. If your account lacks that access on a node, that node's stacks are skipped and it is reported in the `nodes unreachable` line beneath the group. Search also only covers online nodes; a node that is offline in your fleet list is never queried. Confirm the node is online and that your account can read its stacks, then run the search again. + + + The palette fans out to every online node when you start a search. If a node does not answer in time, returns an error, or drops mid-search, its stacks are left out and the count is added to the `N nodes unreachable, stack results may be incomplete` line. Hover that line to see which nodes were skipped and the reason. The stacks from every node that did answer still appear, so you are never left with a silently short list. Re-run the search once the node is reachable to fold its stacks back in. + + + The fleet-wide stack list is gathered once when you begin a search and reused while you keep typing, so a stack created on a remote node after you started typing will not appear yet. Clear the query and search again to pull a fresh list from the fleet. + + + The dot reflects the stack's run state: green for running, grey for exited, faded grey for unknown. Unknown means the node listed the stack but its status could not be read at that moment, usually a transient hiccup talking to that host's Docker engine. The stack is still selectable; open it to see its real state in the editor. + + + The shortcut is handled at the window level, so it works from any screen, but a focused element that captures the keystroke first (for example a terminal in the host console) can intercept it. Click outside that element, or use the search icon in the top bar to open the palette instead. + + diff --git a/e2e/command-palette.spec.ts b/e2e/command-palette.spec.ts new file mode 100644 index 00000000..9c559213 --- /dev/null +++ b/e2e/command-palette.spec.ts @@ -0,0 +1,93 @@ +/** + * Global search command palette E2E - the keyboard-driven navigation journey. + * + * The cross-node failure modes (a remote node returning 502/500 or throwing, + * which drives the "N nodes unreachable" affordance) are covered + * deterministically by the hook unit tests in + * frontend/src/hooks/__tests__/useCrossNodeStackSearch.test.tsx, since they + * cannot be triggered reliably from a single-node E2E environment. + */ +import { test, expect, type Page } from '@playwright/test'; +import { loginAs, waitForStacksLoaded } from './helpers'; + +const PALETTE_INPUT = 'input[placeholder="Search the app..."]'; +const PALETTE_STACK = 'e2e-palette-stack'; + +async function createStackViaUi(page: Page, name: string) { + await page.evaluate(async (n) => { + await fetch(`/api/stacks/${n}`, { method: 'DELETE', credentials: 'include' }).catch(() => {}); + }, name); + await page.reload(); + await loginAs(page); + await waitForStacksLoaded(page); + + await page.getByRole('button', { name: 'Create Stack' }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 }); + await page.locator('#create-stack-name').fill(name); + await page.locator('[role="dialog"]').getByRole('button', { name: 'Create' }).click(); + await expect(page.getByRole('dialog')).toBeHidden({ timeout: 8_000 }); +} + +test.describe('Global search command palette', () => { + test.beforeEach(async ({ page }) => { + await loginAs(page); + await waitForStacksLoaded(page); + }); + + test('opens with Ctrl+K and closes with Esc', async ({ page }) => { + await page.keyboard.press('Control+k'); + await expect(page.locator(PALETTE_INPUT)).toBeVisible({ timeout: 5_000 }); + await expect(page.getByRole('dialog').getByText('Pages', { exact: true })).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(page.locator(PALETTE_INPUT)).toBeHidden({ timeout: 5_000 }); + }); + + test('opens from the top-bar search trigger', async ({ page }) => { + await page.getByRole('button', { name: 'Open search (Ctrl+K)' }).click(); + await expect(page.locator(PALETTE_INPUT)).toBeVisible({ timeout: 5_000 }); + await page.keyboard.press('Escape'); + await expect(page.locator(PALETTE_INPUT)).toBeHidden({ timeout: 5_000 }); + }); + + test('filters pages by substring and navigates on select', async ({ page }) => { + await page.keyboard.press('Control+k'); + const input = page.locator(PALETTE_INPUT); + await expect(input).toBeVisible({ timeout: 5_000 }); + + await input.fill('fleet'); + const dialog = page.getByRole('dialog'); + await expect(dialog.getByText('Fleet', { exact: true })).toBeVisible(); + // A non-matching page is hidden because the palette owns matching. + await expect(dialog.getByText('Home', { exact: true })).toBeHidden(); + + await dialog.getByText('Fleet', { exact: true }).click(); + await expect(input).toBeHidden({ timeout: 5_000 }); + }); + + test('shows "No results." for a query that matches nothing', async ({ page }) => { + await page.keyboard.press('Control+k'); + const input = page.locator(PALETTE_INPUT); + await expect(input).toBeVisible({ timeout: 5_000 }); + + await input.fill('zzzznomatchzzzz'); + await expect(page.getByRole('dialog').getByText('No results.')).toBeVisible({ timeout: 8_000 }); + }); + + test('finds a stack by filename and surfaces it in the Stacks group', async ({ page }) => { + await createStackViaUi(page, PALETTE_STACK); + + await page.keyboard.press('Control+k'); + const input = page.locator(PALETTE_INPUT); + await expect(input).toBeVisible({ timeout: 5_000 }); + + await input.fill('e2e-palette'); + const dialog = page.getByRole('dialog'); + await expect(dialog.getByText('Stacks', { exact: true })).toBeVisible({ timeout: 8_000 }); + await expect(dialog.getByText(new RegExp(PALETTE_STACK))).toBeVisible({ timeout: 8_000 }); + + // Selecting the stack closes the palette and opens it in the editor. + await dialog.getByText(new RegExp(PALETTE_STACK)).first().click(); + await expect(input).toBeHidden({ timeout: 8_000 }); + }); +}); diff --git a/frontend/src/components/GlobalCommandPalette.tsx b/frontend/src/components/GlobalCommandPalette.tsx index a3ce7c70..8bda9cfc 100644 --- a/frontend/src/components/GlobalCommandPalette.tsx +++ b/frontend/src/components/GlobalCommandPalette.tsx @@ -7,11 +7,10 @@ import { useState, type ReactNode, } from 'react'; -import { Search } from 'lucide-react'; +import { Search, AlertCircle } from 'lucide-react'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { CommandDialog, - CommandEmpty, CommandGroup, CommandInput, CommandItem, @@ -103,7 +102,7 @@ export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: Gl const { nodes, activeNode, setActiveNode } = useNodes(); const [query, setQuery] = useState(''); - const { hits: remoteHits, loading: stacksLoading } = useCrossNodeStackSearch({ + const { hits: remoteHits, failedNodes, loading: stacksLoading } = useCrossNodeStackSearch({ query, enabled: open, }); @@ -131,14 +130,29 @@ export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: Gl onSelectStack(node, hit.file); }, [handleOpenChange, nodes, onSelectStack]); - const visibleNodes = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return nodes; - return nodes.filter(n => n.name.toLowerCase().includes(q)); - }, [nodes, query]); + // cmdk's built-in filter is disabled (shouldFilter={false}) so the palette + // owns matching for every group. This keeps stack order deterministic (node + // order, then the 50-row cap) instead of being re-sorted by cmdk's scorer, + // and makes matching a plain case-insensitive substring across all groups. + const q = query.trim().toLowerCase(); + + const visiblePages = useMemo( + () => (q ? navItems.filter(i => i.label.toLowerCase().includes(q) || i.value.toLowerCase().includes(q)) : navItems), + [navItems, q], + ); + + const visibleNodes = useMemo( + () => (q ? nodes.filter(n => n.name.toLowerCase().includes(q)) : nodes), + [nodes, q], + ); + + const hasResults = visiblePages.length > 0 || visibleNodes.length > 0 || stackHits.length > 0; + // Suppress the empty state when a node failed (unless still loading): the + // "N nodes unreachable" line below already explains why results are missing. + const showEmptyState = !hasResults && (stacksLoading || failedNodes.length === 0); return ( - + Search Jump to a page, node, or stack @@ -149,24 +163,28 @@ export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: Gl onValueChange={setQuery} /> - - - {stacksLoading ? 'Searching...' : 'No results.'} - - + {showEmptyState && ( +
+ + {stacksLoading ? 'Searching...' : 'No results.'} + +
+ )} - - {navItems.map(({ value, label, icon: Icon }) => ( - handleSelectNav(value)} - > - - {label} - - ))} - + {visiblePages.length > 0 && ( + + {visiblePages.map(({ value, label, icon: Icon }) => ( + handleSelectNav(value)} + > + + {label} + + ))} + + )} {visibleNodes.length > 0 && ( @@ -221,6 +239,18 @@ export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: Gl )} )} + + {failedNodes.length > 0 && ( +
`${f.nodeName}: ${f.reason}`).join('\n')} + > + + + {failedNodes.length} {failedNodes.length === 1 ? 'node' : 'nodes'} unreachable, stack results may be incomplete + +
+ )}
); diff --git a/frontend/src/components/__tests__/GlobalCommandPalette.test.tsx b/frontend/src/components/__tests__/GlobalCommandPalette.test.tsx new file mode 100644 index 00000000..3c4ee433 --- /dev/null +++ b/frontend/src/components/__tests__/GlobalCommandPalette.test.tsx @@ -0,0 +1,195 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { Home, Radar } from 'lucide-react'; +import type { Node } from '@/context/NodeContext'; +import type { StackHit, FailedNode } from '@/hooks/useCrossNodeStackSearch'; +import type { TopBarNavItem } from '../TopBar'; + +let hookReturn: { hits: StackHit[]; failedNodes: FailedNode[]; loading: boolean }; +vi.mock('@/hooks/useCrossNodeStackSearch', () => ({ + useCrossNodeStackSearch: () => hookReturn, +})); + +const setActiveNodeMock = vi.fn(); +let nodesValue: { nodes: Node[]; activeNode: Node | null; setActiveNode: (n: Node) => void }; +vi.mock('@/context/NodeContext', () => ({ + useNodes: () => nodesValue, +})); + +import { + GlobalCommandPaletteProvider, + GlobalCommandPaletteTrigger, + GlobalCommandPalette, +} from '../GlobalCommandPalette'; + +// cmdk scrolls the active item into view; jsdom has no scrollIntoView. +Element.prototype.scrollIntoView = () => {}; + +function node(id: number, name: string, status: Node['status'] = 'online'): Node { + return { + id, + name, + type: id === 1 ? 'local' : 'remote', + compose_dir: '/compose', + is_default: id === 1, + status, + created_at: 0, + }; +} + +const navItems: TopBarNavItem[] = [ + { value: 'dashboard', label: 'Home', icon: Home }, + { value: 'fleet', label: 'Fleet', icon: Radar }, +]; + +const onNavigate = vi.fn(); +const onSelectStack = vi.fn(); + +function renderPalette() { + return render( + + + + , + ); +} + +function open() { + fireEvent.click(screen.getByLabelText('Open search (Ctrl+K)')); +} + +function type(value: string) { + fireEvent.change(screen.getByPlaceholderText('Search the app...'), { target: { value } }); +} + +beforeEach(() => { + hookReturn = { hits: [], failedNodes: [], loading: false }; + nodesValue = { + nodes: [node(1, 'local'), node(2, 'opsix')], + activeNode: node(1, 'local'), + setActiveNode: setActiveNodeMock, + }; + onNavigate.mockReset(); + onSelectStack.mockReset(); + setActiveNodeMock.mockReset(); +}); + +describe('GlobalCommandPalette', () => { + it('opens on trigger click and lists Pages and Nodes', () => { + renderPalette(); + open(); + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('Fleet')).toBeInTheDocument(); + expect(screen.getByText('local')).toBeInTheDocument(); + expect(screen.getByText('opsix')).toBeInTheDocument(); + }); + + it('filters pages and nodes by substring (palette owns matching)', () => { + renderPalette(); + open(); + type('fleet'); + expect(screen.getByText('Fleet')).toBeInTheDocument(); + expect(screen.queryByText('Home')).not.toBeInTheDocument(); + // 'fleet' matches no node name. + expect(screen.queryByText('local')).not.toBeInTheDocument(); + expect(screen.queryByText('opsix')).not.toBeInTheDocument(); + }); + + it('surfaces a single unreachable node, even with zero hits', () => { + hookReturn = { + hits: [], + failedNodes: [{ nodeId: 2, nodeName: 'opsix', reason: 'list returned HTTP 502' }], + loading: false, + }; + renderPalette(); + open(); + type('zzz'); + expect(screen.getByText(/1 node unreachable/i)).toBeInTheDocument(); + }); + + it('pluralises the unreachable-node count', () => { + hookReturn = { + hits: [], + failedNodes: [ + { nodeId: 2, nodeName: 'opsix', reason: 'down' }, + { nodeId: 3, nodeName: 'edge', reason: 'down' }, + ], + loading: false, + }; + renderPalette(); + open(); + type('zzz'); + expect(screen.getByText(/2 nodes unreachable/i)).toBeInTheDocument(); + }); + + it('shows stack hits and the unreachable line together (partial results)', () => { + hookReturn = { + hits: [{ nodeId: 2, nodeName: 'opsix', file: 'db.yml', status: 'running' }], + failedNodes: [{ nodeId: 3, nodeName: 'edge', reason: 'down' }], + loading: false, + }; + renderPalette(); + open(); + type('db'); + expect(screen.getByText('db.yml')).toBeInTheDocument(); + expect(screen.getByText(/1 node unreachable/i)).toBeInTheDocument(); + // "No results." is suppressed: there are hits, and a node failed. + expect(screen.queryByText('No results.')).not.toBeInTheDocument(); + }); + + it('renders stack hits and caps the list with an overflow line', () => { + const hits: StackHit[] = Array.from({ length: 55 }, (_, i) => ({ + nodeId: 2, + nodeName: 'opsix', + file: `s${i}.yml`, + status: 'running', + })); + hookReturn = { hits, failedNodes: [], loading: false }; + renderPalette(); + open(); + type('s'); + expect(screen.getByText('Showing first 50 of 55')).toBeInTheDocument(); + expect(screen.getByText('s0.yml')).toBeInTheDocument(); + expect(screen.queryByText('s54.yml')).not.toBeInTheDocument(); + }); + + it('navigates and closes when a page is selected', async () => { + renderPalette(); + open(); + fireEvent.click(screen.getByText('Fleet')); + expect(onNavigate).toHaveBeenCalledWith('fleet'); + // The animated dialog stays mounted briefly in jsdom; assert it left the + // open state rather than depending on unmount timing. + await waitFor(() => { + const dialog = screen.queryByRole('dialog'); + expect(dialog?.getAttribute('data-state') ?? 'unmounted').not.toBe('open'); + }); + }); + + it('disables an offline node row', () => { + nodesValue = { + nodes: [node(1, 'local'), node(2, 'opsix', 'offline')], + activeNode: node(1, 'local'), + setActiveNode: setActiveNodeMock, + }; + renderPalette(); + open(); + const row = screen.getByText('opsix').closest('[cmdk-item]'); + expect(row).toHaveAttribute('data-disabled', 'true'); + }); + + it('shows "No results." when nothing matches and nothing is loading', () => { + renderPalette(); + open(); + type('zzzz'); + expect(screen.getByText('No results.')).toBeInTheDocument(); + }); + + it('shows "Searching..." while stacks load and nothing else matches', () => { + hookReturn = { hits: [], failedNodes: [], loading: true }; + renderPalette(); + open(); + type('zzzz'); + expect(screen.getByText('Searching...')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/ui/command.tsx b/frontend/src/components/ui/command.tsx index 3acc5658..8fe9af3b 100644 --- a/frontend/src/components/ui/command.tsx +++ b/frontend/src/components/ui/command.tsx @@ -21,11 +21,15 @@ const Command = React.forwardRef< )) Command.displayName = CommandPrimitive.displayName -const CommandDialog = ({ children, ...props }: DialogProps) => { +// `shouldFilter` is forwarded to the inner cmdk Command. Callers that omit it +// keep cmdk's built-in fuzzy filtering; passing `false` lets the caller own +// filtering so result order and any result cap stay deterministic instead of +// being re-sorted by cmdk's scorer. +const CommandDialog = ({ children, shouldFilter, ...props }: DialogProps & { shouldFilter?: boolean }) => { return ( - + {children} diff --git a/frontend/src/hooks/__tests__/useCrossNodeStackSearch.test.tsx b/frontend/src/hooks/__tests__/useCrossNodeStackSearch.test.tsx new file mode 100644 index 00000000..176b3640 --- /dev/null +++ b/frontend/src/hooks/__tests__/useCrossNodeStackSearch.test.tsx @@ -0,0 +1,305 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import type { Node } from '@/context/NodeContext'; + +const fetchForNodeMock = vi.fn(); +vi.mock('@/lib/api', () => ({ + fetchForNode: (...args: unknown[]) => fetchForNodeMock(...args), +})); + +const useNodesMock = vi.fn(); +vi.mock('@/context/NodeContext', () => ({ + useNodes: () => useNodesMock(), +})); + +import { useCrossNodeStackSearch } from '../useCrossNodeStackSearch'; + +function node(id: number, name: string, status: Node['status'] = 'online'): Node { + return { + id, + name, + type: id === 1 ? 'local' : 'remote', + compose_dir: '/compose', + is_default: id === 1, + status, + created_at: 0, + }; +} + +function res(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +interface NodeFixture { + files?: string[]; + statuses?: Record; + listStatus?: number; + statusStatus?: number; + throwOn?: 'list' | 'both'; +} + +function mockFleet(map: Record) { + fetchForNodeMock.mockImplementation((endpoint: string, nodeId: number) => { + const cfg = map[nodeId] ?? {}; + if (cfg.throwOn === 'both' || (cfg.throwOn === 'list' && endpoint === '/stacks')) { + return Promise.reject(new Error('boom')); + } + if (endpoint === '/stacks') { + return Promise.resolve(res(cfg.listStatus ?? 200, cfg.files ?? [])); + } + if (endpoint === '/stacks/statuses') { + return Promise.resolve(res(cfg.statusStatus ?? 200, cfg.statuses ?? {})); + } + return Promise.resolve(res(404, {})); + }); +} + +async function flushDebounce(ms = 260) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +beforeEach(() => { + fetchForNodeMock.mockReset(); + useNodesMock.mockReset(); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe('useCrossNodeStackSearch', () => { + it('does not fetch while the query is empty', async () => { + useNodesMock.mockReturnValue({ nodes: [node(1, 'local'), node(2, 'opsix')] }); + const { result } = renderHook(() => useCrossNodeStackSearch({ query: '', enabled: true })); + + await flushDebounce(300); + + expect(fetchForNodeMock).not.toHaveBeenCalled(); + expect(result.current.hits).toEqual([]); + expect(result.current.loading).toBe(false); + }); + + it('does not fetch when disabled even with a query', async () => { + useNodesMock.mockReturnValue({ nodes: [node(1, 'local')] }); + const { result } = renderHook(() => useCrossNodeStackSearch({ query: 'db', enabled: false })); + + await flushDebounce(); + + expect(fetchForNodeMock).not.toHaveBeenCalled(); + expect(result.current.hits).toEqual([]); + }); + + it('fans out once per search session and filters cached inventory on refine (no refetch)', async () => { + useNodesMock.mockReturnValue({ nodes: [node(1, 'local'), node(2, 'opsix')] }); + mockFleet({ + 1: { files: ['db.yml', 'web.yml'], statuses: { 'db.yml': { status: 'running' }, 'web.yml': { status: 'exited' } } }, + 2: { files: ['mariadb.yml', 'cache.yml'], statuses: { 'mariadb.yml': 'running' } }, + }); + + const { result, rerender } = renderHook( + ({ query }) => useCrossNodeStackSearch({ query, enabled: true }), + { initialProps: { query: 'd' } }, + ); + + await flushDebounce(); + + // /stacks + /stacks/statuses for each of 2 nodes = 4 calls. + expect(fetchForNodeMock).toHaveBeenCalledTimes(4); + expect(result.current.hits.map(h => h.file).sort()).toEqual(['db.yml', 'mariadb.yml']); + expect(result.current.hits.find(h => h.file === 'db.yml')?.status).toBe('running'); + expect(result.current.hits.find(h => h.file === 'mariadb.yml')?.status).toBe('running'); + + // Refining to a term that matches a different file must be served from the + // cached inventory without any new network calls. + rerender({ query: 'web' }); + await flushDebounce(); + + expect(fetchForNodeMock).toHaveBeenCalledTimes(4); + expect(result.current.hits.map(h => h.file)).toEqual(['web.yml']); + expect(result.current.hits[0]?.status).toBe('exited'); + }); + + it('skips offline nodes and the excluded node', async () => { + useNodesMock.mockReturnValue({ + nodes: [node(1, 'local'), node(2, 'opsix', 'offline'), node(3, 'edge')], + }); + mockFleet({ 1: { files: ['a.yml'] }, 3: { files: ['a.yml'] } }); + + const { result } = renderHook(() => + useCrossNodeStackSearch({ query: 'a', enabled: true, excludeNodeId: 1 }), + ); + + await flushDebounce(); + + // node 1 excluded, node 2 offline, only node 3 queried = 2 calls. + expect(fetchForNodeMock).toHaveBeenCalledTimes(2); + const queriedNodeIds = new Set(fetchForNodeMock.mock.calls.map(c => c[1])); + expect(queriedNodeIds).toEqual(new Set([3])); + expect(result.current.hits.every(h => h.nodeId === 3)).toBe(true); + }); + + it('does not fetch when every node is offline', async () => { + useNodesMock.mockReturnValue({ nodes: [node(1, 'local', 'offline')] }); + const { result } = renderHook(() => useCrossNodeStackSearch({ query: 'db', enabled: true })); + + await flushDebounce(); + + expect(fetchForNodeMock).not.toHaveBeenCalled(); + expect(result.current.hits).toEqual([]); + expect(result.current.loading).toBe(false); + }); + + it('records a failed node when the stack list response is not ok, keeping other nodes', async () => { + useNodesMock.mockReturnValue({ nodes: [node(1, 'local'), node(2, 'opsix')] }); + mockFleet({ + 1: { files: ['db.yml'], statuses: { 'db.yml': 'running' } }, + 2: { listStatus: 502 }, + }); + + const { result } = renderHook(() => useCrossNodeStackSearch({ query: 'db', enabled: true })); + + await flushDebounce(); + + expect(result.current.failedNodes).toEqual([ + { nodeId: 2, nodeName: 'opsix', reason: 'list returned HTTP 502' }, + ]); + expect(result.current.hits.map(h => h.file)).toEqual(['db.yml']); + }); + + it('records a failed node with the error message when the request throws', async () => { + useNodesMock.mockReturnValue({ nodes: [node(2, 'opsix')] }); + mockFleet({ 2: { throwOn: 'both' } }); + + const { result } = renderHook(() => useCrossNodeStackSearch({ query: 'x', enabled: true })); + + await flushDebounce(); + + expect(result.current.failedNodes).toEqual([ + { nodeId: 2, nodeName: 'opsix', reason: 'boom' }, + ]); + expect(result.current.hits).toEqual([]); + }); + + it('still lists stacks as unknown when only the status endpoint fails', async () => { + useNodesMock.mockReturnValue({ nodes: [node(1, 'local')] }); + mockFleet({ 1: { files: ['db.yml'], statusStatus: 500 } }); + + const { result } = renderHook(() => useCrossNodeStackSearch({ query: 'db', enabled: true })); + + await flushDebounce(); + + // A status-endpoint failure is graceful degradation, not a node failure. + expect(result.current.failedNodes).toEqual([]); + expect(result.current.hits).toEqual([ + { nodeId: 1, nodeName: 'local', file: 'db.yml', status: 'unknown' }, + ]); + }); + + it('clears results when the search is disabled', async () => { + useNodesMock.mockReturnValue({ nodes: [node(1, 'local')] }); + mockFleet({ 1: { files: ['db.yml'], statuses: { 'db.yml': 'running' } } }); + + const { result, rerender } = renderHook( + ({ enabled }) => useCrossNodeStackSearch({ query: 'db', enabled }), + { initialProps: { enabled: true } }, + ); + + await flushDebounce(); + expect(result.current.hits.length).toBe(1); + + rerender({ enabled: false }); + await flushDebounce(10); + + expect(result.current.hits).toEqual([]); + }); + + it('starts a fresh fanout after the query is cleared and re-entered', async () => { + useNodesMock.mockReturnValue({ nodes: [node(1, 'local')] }); + mockFleet({ 1: { files: ['db.yml', 'web.yml'], statuses: { 'db.yml': 'running' } } }); + + const { result, rerender } = renderHook( + ({ query }) => useCrossNodeStackSearch({ query, enabled: true }), + { initialProps: { query: 'db' } }, + ); + await flushDebounce(); + expect(fetchForNodeMock).toHaveBeenCalledTimes(2); + expect(result.current.hits.map(h => h.file)).toEqual(['db.yml']); + + // Clearing the query ends the session and clears results. + rerender({ query: '' }); + await flushDebounce(10); + expect(result.current.hits).toEqual([]); + + // Re-entering a query starts a new session, so the fleet is queried again. + rerender({ query: 'web' }); + await flushDebounce(); + expect(fetchForNodeMock).toHaveBeenCalledTimes(4); + expect(result.current.hits.map(h => h.file)).toEqual(['web.yml']); + }); + + it('re-fans-out when excludeNodeId changes (active node switch)', async () => { + useNodesMock.mockReturnValue({ nodes: [node(1, 'local'), node(2, 'opsix')] }); + mockFleet({ 1: { files: ['a.yml'] }, 2: { files: ['a.yml'] } }); + + const { rerender } = renderHook( + ({ excludeNodeId }) => useCrossNodeStackSearch({ query: 'a', enabled: true, excludeNodeId }), + { initialProps: { excludeNodeId: 1 } }, + ); + await flushDebounce(); + expect(new Set(fetchForNodeMock.mock.calls.map(c => c[1]))).toEqual(new Set([2])); + + fetchForNodeMock.mockClear(); + rerender({ excludeNodeId: 2 }); + await flushDebounce(); + expect(new Set(fetchForNodeMock.mock.calls.map(c => c[1]))).toEqual(new Set([1])); + }); + + it('clears stale results immediately when excludeNodeId changes, before the refetch resolves', async () => { + useNodesMock.mockReturnValue({ nodes: [node(1, 'local'), node(2, 'opsix'), node(3, 'edge')] }); + mockFleet({ 1: { files: ['a.yml'] }, 2: { files: ['a.yml'] }, 3: { files: ['a.yml'] } }); + + const { result, rerender } = renderHook( + ({ excludeNodeId }) => useCrossNodeStackSearch({ query: 'a', enabled: true, excludeNodeId }), + { initialProps: { excludeNodeId: 1 } }, + ); + await flushDebounce(); + expect(result.current.hits.map(h => h.nodeId).sort()).toEqual([2, 3]); + + // Switching active node (excludeNodeId 1 -> 2) must drop the stale inventory + // at once, before the new fanout resolves, so node 2 does not linger. + rerender({ excludeNodeId: 2 }); + expect(result.current.hits).toEqual([]); + expect(result.current.loading).toBe(true); + + await flushDebounce(); + expect(result.current.hits.map(h => h.nodeId).sort()).toEqual([1, 3]); + }); + + it('degrades to unknown when the status endpoint is 200 with an unparseable body', async () => { + useNodesMock.mockReturnValue({ nodes: [node(1, 'local')] }); + fetchForNodeMock.mockImplementation((endpoint: string) => { + if (endpoint === '/stacks') return Promise.resolve(res(200, ['db.yml'])); + // 200 OK but a non-JSON body, so statusRes.json() throws. + return Promise.resolve(new Response('not json', { + status: 200, + headers: { 'Content-Type': 'text/html' }, + })); + }); + + const { result } = renderHook(() => useCrossNodeStackSearch({ query: 'db', enabled: true })); + await flushDebounce(); + + // The successful stack list must survive a bad status body. + expect(result.current.failedNodes).toEqual([]); + expect(result.current.hits).toEqual([ + { nodeId: 1, nodeName: 'local', file: 'db.yml', status: 'unknown' }, + ]); + }); +}); diff --git a/frontend/src/hooks/useCrossNodeStackSearch.ts b/frontend/src/hooks/useCrossNodeStackSearch.ts index 6b41288c..705b80cd 100644 --- a/frontend/src/hooks/useCrossNodeStackSearch.ts +++ b/frontend/src/hooks/useCrossNodeStackSearch.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useNodes } from '@/context/NodeContext'; import { fetchForNode } from '@/lib/api'; @@ -36,7 +36,10 @@ interface NodeOutcome { export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Options) { const { nodes } = useNodes(); - const [hits, setHits] = useState([]); + // Full per-node inventory captured for the active search session. The query + // filter is applied client-side (see `hits` below) so refining the search + // never re-fans-out to the fleet. + const [inventory, setInventory] = useState([]); const [failedNodes, setFailedNodes] = useState([]); const [loading, setLoading] = useState(false); @@ -44,10 +47,15 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio const nodesRef = useRef(nodes); nodesRef.current = nodes; + const q = query.trim().toLowerCase(); + // A search "session" is active while enabled and the query is non-empty. The + // fanout fetch runs once per session start, not per keystroke; clearing the + // query and typing again starts a fresh session (and a fresh fetch). + const active = enabled && q.length > 0; + useEffect(() => { - const q = query.trim().toLowerCase(); - if (!enabled || !q) { - setHits([]); + if (!active) { + setInventory([]); setFailedNodes([]); setLoading(false); return; @@ -56,13 +64,21 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio n => n.status !== 'offline' && n.id !== excludeNodeId, ); if (targets.length === 0) { - setHits([]); + setInventory([]); setFailedNodes([]); + setLoading(false); return; } + // Starting a new fetch session (a search became active, or the active + // node changed via excludeNodeId): drop the previous session's results + // so a stale inventory or unreachable warning never lingers during the + // refetch window. Refining the query keeps `active` true and does not + // re-run this effect, so the cached inventory survives keystrokes. + setLoading(true); + setInventory([]); + setFailedNodes([]); const controller = new AbortController(); const timer = setTimeout(async () => { - setLoading(true); try { const perNode = await Promise.all(targets.map(async (node): Promise => { try { @@ -81,26 +97,33 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio }; } const rawList = await listRes.json(); - const files: string[] = Array.isArray(rawList) ? rawList : []; + const files: string[] = Array.isArray(rawList) + ? rawList.filter((f): f is string => typeof f === 'string') + : []; const statuses: Record = {}; if (statusRes.ok) { - const raw = await statusRes.json(); - for (const [key, val] of Object.entries(raw)) { - if (typeof val === 'string') { - statuses[key] = val as StackStatus; - } else if (val && typeof val === 'object' && 'status' in val) { - statuses[key] = (val as StackStatusInfo).status; + try { + const raw = await statusRes.json(); + for (const [key, val] of Object.entries(raw)) { + if (typeof val === 'string') { + statuses[key] = val as StackStatus; + } else if (val && typeof val === 'object' && 'status' in val) { + statuses[key] = (val as StackStatusInfo).status; + } } + } catch { + // A 200 with an unparseable status body must not discard the + // stack list we already fetched; leave statuses empty so every + // stack degrades to 'unknown' rather than failing the node. } } - const nodeHits = files - .filter(f => f.toLowerCase().includes(q)) - .map(file => ({ - nodeId: node.id, - nodeName: node.name, - file, - status: statuses[file] ?? 'unknown', - })); + // Capture the whole node inventory; filtering is client-side. + const nodeHits = files.map(file => ({ + nodeId: node.id, + nodeName: node.name, + file, + status: statuses[file] ?? 'unknown', + })); return { hits: nodeHits, failure: null }; } catch (err) { // AbortError is expected when the effect cleans up; don't @@ -119,7 +142,7 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio } })); if (controller.signal.aborted) return; - setHits(perNode.flatMap(o => o.hits)); + setInventory(perNode.flatMap(o => o.hits)); setFailedNodes(perNode.flatMap(o => (o.failure ? [o.failure] : []))); } finally { if (!controller.signal.aborted) setLoading(false); @@ -129,7 +152,13 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio clearTimeout(timer); controller.abort(); }; - }, [enabled, query, excludeNodeId]); + }, [active, excludeNodeId]); + + // Client-side query filter over the session inventory. No network on keystrokes. + const hits = useMemo( + () => (active ? inventory.filter(h => h.file.toLowerCase().includes(q)) : []), + [active, inventory, q], + ); return { hits, failedNodes, loading }; }