fix(global-search): surface unreachable nodes and harden the command palette (#1253)

* fix(global-search): surface unreachable nodes and harden the command palette

The command palette discarded the cross-node search hook's failedNodes, so a search run while a fleet node was down silently returned partial results with no sign a host was skipped. It now renders an "N nodes unreachable" line and still shows the stacks it could gather.

The shared cross-node search hook fetched every node's stack list and statuses on every keystroke. It now fans out once per search session and filters the cached inventory client-side as the query is refined, cutting per-keystroke fleet traffic. A 200 response with an unparseable status body degrades stacks to unknown instead of failing the whole node.

The palette now owns result matching (cmdk's built-in fuzzy filter is disabled), so Pages, Nodes, and Stacks match by case-insensitive substring in a deterministic order and the 50-row cap applies to the real match set rather than a re-sorted slice.

Adds unit coverage for the hook and palette, plus a Playwright journey spec.

* fix(global-search): clear stale cross-node results when the active node changes

When excludeNodeId changed mid-search (the sidebar switches it on active-node change), the hook started a fresh fanout but left the previous session's inventory and failedNodes visible until the refetch resolved, so the newly active node could briefly appear under the other-nodes results or a stale unreachable warning could persist. The new session now drops prior results synchronously before refetching.
This commit is contained in:
Anso
2026-05-29 19:04:39 -04:00
committed by GitHub
parent d41282e352
commit 98049e3b1c
7 changed files with 731 additions and 53 deletions
+23 -1
View File
@@ -37,7 +37,7 @@ Each stack row shows a status dot on the left (green for running, grey for exite
## Typing to filter ## 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. - `fleet` jumps to the Fleet page.
- `local` selects the Local node and switches the active context to it. - `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.` 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 ## Keyboard navigation
Once the palette is open: Once the palette is open:
@@ -66,3 +68,23 @@ Once the palette is open:
- <kbd>Esc</kbd> closes the palette. - <kbd>Esc</kbd> closes the palette.
Offline nodes show as greyed-out and are not selectable. Offline nodes show as greyed-out and are not selectable.
## Troubleshooting
<AccordionGroup>
<Accordion title="The Stacks group is empty even though I know a stack matches">
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.
</Accordion>
<Accordion title="A node is missing from the results and I see 'nodes unreachable'">
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.
</Accordion>
<Accordion title="A stack I just created on another node does not show up">
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.
</Accordion>
<Accordion title="A stack row shows a faded grey status dot">
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.
</Accordion>
<Accordion title="Ctrl+K does not open the palette">
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.
</Accordion>
</AccordionGroup>
+93
View File
@@ -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 });
});
});
@@ -7,11 +7,10 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from 'react'; } from 'react';
import { Search } from 'lucide-react'; import { Search, AlertCircle } from 'lucide-react';
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { import {
CommandDialog, CommandDialog,
CommandEmpty,
CommandGroup, CommandGroup,
CommandInput, CommandInput,
CommandItem, CommandItem,
@@ -103,7 +102,7 @@ export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: Gl
const { nodes, activeNode, setActiveNode } = useNodes(); const { nodes, activeNode, setActiveNode } = useNodes();
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const { hits: remoteHits, loading: stacksLoading } = useCrossNodeStackSearch({ const { hits: remoteHits, failedNodes, loading: stacksLoading } = useCrossNodeStackSearch({
query, query,
enabled: open, enabled: open,
}); });
@@ -131,14 +130,29 @@ export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: Gl
onSelectStack(node, hit.file); onSelectStack(node, hit.file);
}, [handleOpenChange, nodes, onSelectStack]); }, [handleOpenChange, nodes, onSelectStack]);
const visibleNodes = useMemo(() => { // cmdk's built-in filter is disabled (shouldFilter={false}) so the palette
const q = query.trim().toLowerCase(); // owns matching for every group. This keeps stack order deterministic (node
if (!q) return nodes; // order, then the 50-row cap) instead of being re-sorted by cmdk's scorer,
return nodes.filter(n => n.name.toLowerCase().includes(q)); // and makes matching a plain case-insensitive substring across all groups.
}, [nodes, query]); 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 ( return (
<CommandDialog open={open} onOpenChange={handleOpenChange}> <CommandDialog open={open} onOpenChange={handleOpenChange} shouldFilter={false}>
<VisuallyHidden> <VisuallyHidden>
<DialogTitle>Search</DialogTitle> <DialogTitle>Search</DialogTitle>
<DialogDescription>Jump to a page, node, or stack</DialogDescription> <DialogDescription>Jump to a page, node, or stack</DialogDescription>
@@ -149,24 +163,28 @@ export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: Gl
onValueChange={setQuery} onValueChange={setQuery}
/> />
<CommandList> <CommandList>
<CommandEmpty> {showEmptyState && (
<span aria-live="polite"> <div className="py-6 text-center text-sm">
{stacksLoading ? 'Searching...' : 'No results.'} <span aria-live="polite">
</span> {stacksLoading ? 'Searching...' : 'No results.'}
</CommandEmpty> </span>
</div>
)}
<CommandGroup heading="Pages"> {visiblePages.length > 0 && (
{navItems.map(({ value, label, icon: Icon }) => ( <CommandGroup heading="Pages">
<CommandItem {visiblePages.map(({ value, label, icon: Icon }) => (
key={`nav-${value}`} <CommandItem
value={`nav ${label} ${value}`} key={`nav-${value}`}
onSelect={() => handleSelectNav(value)} value={`nav ${label} ${value}`}
> onSelect={() => handleSelectNav(value)}
<Icon className="h-4 w-4" strokeWidth={1.5} /> >
<span>{label}</span> <Icon className="h-4 w-4" strokeWidth={1.5} />
</CommandItem> <span>{label}</span>
))} </CommandItem>
</CommandGroup> ))}
</CommandGroup>
)}
{visibleNodes.length > 0 && ( {visibleNodes.length > 0 && (
<CommandGroup heading="Nodes"> <CommandGroup heading="Nodes">
@@ -221,6 +239,18 @@ export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: Gl
)} )}
</CommandGroup> </CommandGroup>
)} )}
{failedNodes.length > 0 && (
<div
className="flex items-center gap-1.5 px-3 py-2 font-mono text-[10px] uppercase tracking-[0.06em] text-warning"
title={failedNodes.map(f => `${f.nodeName}: ${f.reason}`).join('\n')}
>
<AlertCircle className="h-3 w-3 shrink-0" strokeWidth={1.5} />
<span aria-live="polite">
{failedNodes.length} {failedNodes.length === 1 ? 'node' : 'nodes'} unreachable, stack results may be incomplete
</span>
</div>
)}
</CommandList> </CommandList>
</CommandDialog> </CommandDialog>
); );
@@ -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(
<GlobalCommandPaletteProvider>
<GlobalCommandPaletteTrigger />
<GlobalCommandPalette navItems={navItems} onNavigate={onNavigate} onSelectStack={onSelectStack} />
</GlobalCommandPaletteProvider>,
);
}
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();
});
});
+6 -2
View File
@@ -21,11 +21,15 @@ const Command = React.forwardRef<
)) ))
Command.displayName = CommandPrimitive.displayName 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 ( return (
<Dialog {...props}> <Dialog {...props}>
<DialogContent className="overflow-hidden p-0"> <DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> <Command shouldFilter={shouldFilter} className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children} {children}
</Command> </Command>
</DialogContent> </DialogContent>
@@ -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<string, unknown>;
listStatus?: number;
statusStatus?: number;
throwOn?: 'list' | 'both';
}
function mockFleet(map: Record<number, NodeFixture>) {
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('<html>not json</html>', {
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' },
]);
});
});
+53 -24
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useNodes } from '@/context/NodeContext'; import { useNodes } from '@/context/NodeContext';
import { fetchForNode } from '@/lib/api'; import { fetchForNode } from '@/lib/api';
@@ -36,7 +36,10 @@ interface NodeOutcome {
export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Options) { export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Options) {
const { nodes } = useNodes(); const { nodes } = useNodes();
const [hits, setHits] = useState<StackHit[]>([]); // 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<StackHit[]>([]);
const [failedNodes, setFailedNodes] = useState<FailedNode[]>([]); const [failedNodes, setFailedNodes] = useState<FailedNode[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -44,10 +47,15 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio
const nodesRef = useRef(nodes); const nodesRef = useRef(nodes);
nodesRef.current = 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(() => { useEffect(() => {
const q = query.trim().toLowerCase(); if (!active) {
if (!enabled || !q) { setInventory([]);
setHits([]);
setFailedNodes([]); setFailedNodes([]);
setLoading(false); setLoading(false);
return; return;
@@ -56,13 +64,21 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio
n => n.status !== 'offline' && n.id !== excludeNodeId, n => n.status !== 'offline' && n.id !== excludeNodeId,
); );
if (targets.length === 0) { if (targets.length === 0) {
setHits([]); setInventory([]);
setFailedNodes([]); setFailedNodes([]);
setLoading(false);
return; 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 controller = new AbortController();
const timer = setTimeout(async () => { const timer = setTimeout(async () => {
setLoading(true);
try { try {
const perNode = await Promise.all(targets.map(async (node): Promise<NodeOutcome> => { const perNode = await Promise.all(targets.map(async (node): Promise<NodeOutcome> => {
try { try {
@@ -81,26 +97,33 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio
}; };
} }
const rawList = await listRes.json(); 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<string, StackStatus> = {}; const statuses: Record<string, StackStatus> = {};
if (statusRes.ok) { if (statusRes.ok) {
const raw = await statusRes.json(); try {
for (const [key, val] of Object.entries(raw)) { const raw = await statusRes.json();
if (typeof val === 'string') { for (const [key, val] of Object.entries(raw)) {
statuses[key] = val as StackStatus; if (typeof val === 'string') {
} else if (val && typeof val === 'object' && 'status' in val) { statuses[key] = val as StackStatus;
statuses[key] = (val as StackStatusInfo).status; } 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 // Capture the whole node inventory; filtering is client-side.
.filter(f => f.toLowerCase().includes(q)) const nodeHits = files.map<StackHit>(file => ({
.map<StackHit>(file => ({ nodeId: node.id,
nodeId: node.id, nodeName: node.name,
nodeName: node.name, file,
file, status: statuses[file] ?? 'unknown',
status: statuses[file] ?? 'unknown', }));
}));
return { hits: nodeHits, failure: null }; return { hits: nodeHits, failure: null };
} catch (err) { } catch (err) {
// AbortError is expected when the effect cleans up; don't // 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; 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] : []))); setFailedNodes(perNode.flatMap(o => (o.failure ? [o.failure] : [])));
} finally { } finally {
if (!controller.signal.aborted) setLoading(false); if (!controller.signal.aborted) setLoading(false);
@@ -129,7 +152,13 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio
clearTimeout(timer); clearTimeout(timer);
controller.abort(); 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 }; return { hits, failedNodes, loading };
} }