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
+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 { 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 };
}