mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
98049e3b1c
* 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.
165 lines
6.7 KiB
TypeScript
165 lines
6.7 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useNodes } from '@/context/NodeContext';
|
|
import { fetchForNode } from '@/lib/api';
|
|
|
|
export type StackStatus = 'running' | 'exited' | 'unknown';
|
|
|
|
export interface StackStatusInfo {
|
|
status: StackStatus;
|
|
}
|
|
|
|
export interface StackHit {
|
|
nodeId: number;
|
|
nodeName: string;
|
|
file: string;
|
|
status: StackStatus;
|
|
}
|
|
|
|
export interface FailedNode {
|
|
nodeId: number;
|
|
nodeName: string;
|
|
reason: string;
|
|
}
|
|
|
|
interface Options {
|
|
query: string;
|
|
enabled: boolean;
|
|
excludeNodeId?: number;
|
|
}
|
|
|
|
const DEBOUNCE_MS = 250;
|
|
|
|
interface NodeOutcome {
|
|
hits: StackHit[];
|
|
failure: FailedNode | null;
|
|
}
|
|
|
|
export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Options) {
|
|
const { nodes } = useNodes();
|
|
// 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);
|
|
|
|
// Ref avoids re-running the effect on every NodeContext status tick
|
|
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(() => {
|
|
if (!active) {
|
|
setInventory([]);
|
|
setFailedNodes([]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
const targets = nodesRef.current.filter(
|
|
n => n.status !== 'offline' && n.id !== excludeNodeId,
|
|
);
|
|
if (targets.length === 0) {
|
|
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 () => {
|
|
try {
|
|
const perNode = await Promise.all(targets.map(async (node): Promise<NodeOutcome> => {
|
|
try {
|
|
const [listRes, statusRes] = await Promise.all([
|
|
fetchForNode('/stacks', node.id, { signal: controller.signal }),
|
|
fetchForNode('/stacks/statuses', node.id, { signal: controller.signal }),
|
|
]);
|
|
if (!listRes.ok) {
|
|
return {
|
|
hits: [],
|
|
failure: {
|
|
nodeId: node.id,
|
|
nodeName: node.name,
|
|
reason: `list returned HTTP ${listRes.status}`,
|
|
},
|
|
};
|
|
}
|
|
const rawList = await listRes.json();
|
|
const files: string[] = Array.isArray(rawList)
|
|
? rawList.filter((f): f is string => typeof f === 'string')
|
|
: [];
|
|
const statuses: Record<string, StackStatus> = {};
|
|
if (statusRes.ok) {
|
|
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.
|
|
}
|
|
}
|
|
// 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
|
|
// surface it as a node failure to the user.
|
|
if ((err as Error)?.name === 'AbortError') {
|
|
return { hits: [], failure: null };
|
|
}
|
|
return {
|
|
hits: [],
|
|
failure: {
|
|
nodeId: node.id,
|
|
nodeName: node.name,
|
|
reason: (err as Error)?.message ?? 'unreachable',
|
|
},
|
|
};
|
|
}
|
|
}));
|
|
if (controller.signal.aborted) return;
|
|
setInventory(perNode.flatMap(o => o.hits));
|
|
setFailedNodes(perNode.flatMap(o => (o.failure ? [o.failure] : [])));
|
|
} finally {
|
|
if (!controller.signal.aborted) setLoading(false);
|
|
}
|
|
}, DEBOUNCE_MS);
|
|
return () => {
|
|
clearTimeout(timer);
|
|
controller.abort();
|
|
};
|
|
}, [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 };
|
|
}
|