mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(sidebar): surface unreachable nodes in cross-node stack search (#1195)
Per-node fetch failures in useCrossNodeStackSearch were silently
swallowed into []. The user could not tell the difference between
"this node has no matching stack" and "this node timed out or 502'd".
Adds a third return value to the hook: failedNodes: FailedNode[]
where FailedNode is { nodeId, nodeName, reason }. The reason captures
the HTTP status text (e.g. "list returned HTTP 502") or the
underlying Error message ("connect ECONNREFUSED 192.168.x.x:1852")
without leaking node URLs. AbortError from the effect cleanup path
is intentionally excluded - it's expected when the user keeps typing.
Threads the new field through useStackListState and EditorLayout into
StackList. StackList renders a warning chip below the "Other nodes"
header when the array is non-empty:
! N nodes unreachable > (expand)
Click expands the chip to a vertical list of "node: reason" lines.
Hover surfaces the full reasons in the title attribute too, so a
user inspecting at a glance can read them without clicking. The chip
is suppressed entirely when the search yields no failures (no zero
state). Color is the existing warning token, not error - the failure
is recoverable (node may come back) and not a system-wide problem.
GlobalCommandPalette also calls useCrossNodeStackSearch but ignores
the third return value; its destructuring is additive-safe.
Resolves M-2 from the stack-management audit.
This commit is contained in:
@@ -94,6 +94,7 @@ export default function EditorLayout() {
|
||||
pinned,
|
||||
isCollapsed, toggleCollapse,
|
||||
remoteSearchLoading,
|
||||
remoteSearchFailedNodes,
|
||||
} = stackListState;
|
||||
|
||||
const { nodes, activeNode, setActiveNode } = useNodes();
|
||||
@@ -390,6 +391,7 @@ export default function EditorLayout() {
|
||||
buildMenuCtx,
|
||||
remoteResults,
|
||||
remoteLoading: remoteSearchLoading,
|
||||
remoteFailedNodes: remoteSearchFailedNodes,
|
||||
onSelectRemoteFile: (nodeId, file) => {
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
if (node) void stackActions.loadFileOnNode(node, file);
|
||||
|
||||
@@ -54,7 +54,7 @@ export function useStackListState() {
|
||||
const { isCollapsed, toggle: toggleCollapse } = useSidebarGroupCollapse(activeNode?.id);
|
||||
const { runBulk } = useBulkStackActions();
|
||||
|
||||
const { hits: remoteSearchHits, loading: remoteSearchLoading } = useCrossNodeStackSearch({
|
||||
const { hits: remoteSearchHits, failedNodes: remoteSearchFailedNodes, loading: remoteSearchLoading } = useCrossNodeStackSearch({
|
||||
query: searchQuery,
|
||||
enabled: true,
|
||||
excludeNodeId: activeNode?.id,
|
||||
@@ -359,5 +359,6 @@ export function useStackListState() {
|
||||
pinned, pin, unpin, isPinned,
|
||||
isCollapsed, toggleCollapse,
|
||||
remoteSearchLoading,
|
||||
remoteSearchFailedNodes,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ArrowUpRight, Loader2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ArrowUpRight, Loader2, AlertCircle, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useStackKeyboardShortcuts } from '@/hooks/useStackKeyboardShortcuts';
|
||||
import { CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -18,6 +18,12 @@ interface RemoteNodeResult {
|
||||
files: { file: string; status: StackRowStatus }[];
|
||||
}
|
||||
|
||||
interface RemoteSearchFailure {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface StackListProps {
|
||||
files: string[];
|
||||
isLoading: boolean;
|
||||
@@ -37,6 +43,7 @@ export interface StackListProps {
|
||||
buildMenuCtx: (file: string) => StackMenuCtx;
|
||||
remoteResults: RemoteNodeResult[];
|
||||
remoteLoading: boolean;
|
||||
remoteFailedNodes: RemoteSearchFailure[];
|
||||
onSelectRemoteFile: (nodeId: number, file: string) => void;
|
||||
}
|
||||
|
||||
@@ -107,9 +114,11 @@ export function StackList(props: StackListProps & StackListBulkProps) {
|
||||
stackUpdates, gitSourcePendingMap, pinnedFiles, isCollapsed, toggleCollapse,
|
||||
isBusy, getDisplayName, onSelectFile, buildMenuCtx,
|
||||
bulkMode, selectedFiles, onToggleSelect,
|
||||
remoteResults, remoteLoading, onSelectRemoteFile,
|
||||
remoteResults, remoteLoading, remoteFailedNodes, onSelectRemoteFile,
|
||||
} = props;
|
||||
|
||||
const [failedNodesExpanded, setFailedNodesExpanded] = useState(false);
|
||||
|
||||
const groups = useMemo(
|
||||
() => buildGroups(files, pinnedFiles, stackLabelMap),
|
||||
[files, pinnedFiles, stackLabelMap],
|
||||
@@ -171,12 +180,39 @@ export function StackList(props: StackListProps & StackListBulkProps) {
|
||||
</StackGroup>
|
||||
))}
|
||||
|
||||
{searchQuery.trim() && (remoteLoading || remoteResults.length > 0) && (
|
||||
{searchQuery.trim() && (remoteLoading || remoteResults.length > 0 || remoteFailedNodes.length > 0) && (
|
||||
<div className="mt-3 pt-3 border-t border-glass-border">
|
||||
<h3 className="text-[10px] font-medium tracking-[0.08em] uppercase text-stat-subtitle px-4 pb-2 flex items-center gap-2">
|
||||
Other nodes
|
||||
{remoteLoading && <Loader2 className="w-3 h-3 animate-spin text-stat-icon" strokeWidth={1.5} />}
|
||||
</h3>
|
||||
{remoteFailedNodes.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFailedNodesExpanded(prev => !prev)}
|
||||
className="w-full mx-4 mb-2 flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-[0.06em] text-warning hover:text-warning/80 cursor-pointer"
|
||||
style={{ width: 'calc(100% - 2rem)' }}
|
||||
aria-expanded={failedNodesExpanded}
|
||||
title={remoteFailedNodes.map(f => `${f.nodeName}: ${f.reason}`).join('\n')}
|
||||
>
|
||||
<AlertCircle className="w-3 h-3 shrink-0" strokeWidth={1.5} />
|
||||
<span className="shrink-0">
|
||||
{remoteFailedNodes.length} {remoteFailedNodes.length === 1 ? 'node' : 'nodes'} unreachable
|
||||
</span>
|
||||
{failedNodesExpanded
|
||||
? <ChevronDown className="w-3 h-3 shrink-0" strokeWidth={1.5} />
|
||||
: <ChevronRight className="w-3 h-3 shrink-0" strokeWidth={1.5} />}
|
||||
</button>
|
||||
)}
|
||||
{failedNodesExpanded && remoteFailedNodes.length > 0 && (
|
||||
<div className="px-4 pb-2 space-y-0.5">
|
||||
{remoteFailedNodes.map(f => (
|
||||
<div key={f.nodeId} className="text-[10px] font-mono text-stat-subtitle truncate">
|
||||
<span className="text-warning">·</span> {f.nodeName}: {f.reason}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{remoteResults.map(({ nodeId, nodeName, files: remoteFiles }) => (
|
||||
<div key={nodeId} className="mb-2">
|
||||
<div className="px-4 pb-1 flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-[0.06em] text-stat-subtitle">
|
||||
|
||||
@@ -15,6 +15,12 @@ export interface StackHit {
|
||||
status: StackStatus;
|
||||
}
|
||||
|
||||
export interface FailedNode {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
query: string;
|
||||
enabled: boolean;
|
||||
@@ -23,9 +29,15 @@ interface Options {
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
interface NodeOutcome {
|
||||
hits: StackHit[];
|
||||
failure: FailedNode | null;
|
||||
}
|
||||
|
||||
export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Options) {
|
||||
const { nodes } = useNodes();
|
||||
const [hits, setHits] = useState<StackHit[]>([]);
|
||||
const [failedNodes, setFailedNodes] = useState<FailedNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Ref avoids re-running the effect on every NodeContext status tick
|
||||
@@ -36,6 +48,7 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!enabled || !q) {
|
||||
setHits([]);
|
||||
setFailedNodes([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -44,19 +57,29 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio
|
||||
);
|
||||
if (targets.length === 0) {
|
||||
setHits([]);
|
||||
setFailedNodes([]);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const perNode = await Promise.all(targets.map(async (node) => {
|
||||
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 [] as StackHit[];
|
||||
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 : [];
|
||||
const statuses: Record<string, StackStatus> = {};
|
||||
@@ -70,7 +93,7 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
const nodeHits = files
|
||||
.filter(f => f.toLowerCase().includes(q))
|
||||
.map<StackHit>(file => ({
|
||||
nodeId: node.id,
|
||||
@@ -78,12 +101,26 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio
|
||||
file,
|
||||
status: statuses[file] ?? 'unknown',
|
||||
}));
|
||||
} catch {
|
||||
return [] as StackHit[];
|
||||
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;
|
||||
setHits(perNode.flat());
|
||||
setHits(perNode.flatMap(o => o.hits));
|
||||
setFailedNodes(perNode.flatMap(o => (o.failure ? [o.failure] : [])));
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
@@ -94,5 +131,5 @@ export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Optio
|
||||
};
|
||||
}, [enabled, query, excludeNodeId]);
|
||||
|
||||
return { hits, loading };
|
||||
return { hits, failedNodes, loading };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user