mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-01 05:07:59 +00:00
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:
@@ -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 (
|
||||
<CommandDialog open={open} onOpenChange={handleOpenChange}>
|
||||
<CommandDialog open={open} onOpenChange={handleOpenChange} shouldFilter={false}>
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>Search</DialogTitle>
|
||||
<DialogDescription>Jump to a page, node, or stack</DialogDescription>
|
||||
@@ -149,24 +163,28 @@ export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: Gl
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<span aria-live="polite">
|
||||
{stacksLoading ? 'Searching...' : 'No results.'}
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
{showEmptyState && (
|
||||
<div className="py-6 text-center text-sm">
|
||||
<span aria-live="polite">
|
||||
{stacksLoading ? 'Searching...' : 'No results.'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CommandGroup heading="Pages">
|
||||
{navItems.map(({ value, label, icon: Icon }) => (
|
||||
<CommandItem
|
||||
key={`nav-${value}`}
|
||||
value={`nav ${label} ${value}`}
|
||||
onSelect={() => handleSelectNav(value)}
|
||||
>
|
||||
<Icon className="h-4 w-4" strokeWidth={1.5} />
|
||||
<span>{label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{visiblePages.length > 0 && (
|
||||
<CommandGroup heading="Pages">
|
||||
{visiblePages.map(({ value, label, icon: Icon }) => (
|
||||
<CommandItem
|
||||
key={`nav-${value}`}
|
||||
value={`nav ${label} ${value}`}
|
||||
onSelect={() => handleSelectNav(value)}
|
||||
>
|
||||
<Icon className="h-4 w-4" strokeWidth={1.5} />
|
||||
<span>{label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{visibleNodes.length > 0 && (
|
||||
<CommandGroup heading="Nodes">
|
||||
@@ -221,6 +239,18 @@ export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: Gl
|
||||
)}
|
||||
</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>
|
||||
</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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Dialog {...props}>
|
||||
<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}
|
||||
</Command>
|
||||
</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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<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 [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<NodeOutcome> => {
|
||||
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<string, StackStatus> = {};
|
||||
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<StackHit>(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<StackHit>(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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user