mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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:
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user