mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-22 16:16:41 +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:
@@ -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