diff --git a/docs/docs.json b/docs/docs.json index d9b33c9d..f3b16ca5 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -95,6 +95,7 @@ "pages": [ "features/overview", "features/dashboard", + "features/global-search", "features/sidebar", "features/stack-management", "features/editor", diff --git a/docs/features/global-search.mdx b/docs/features/global-search.mdx new file mode 100644 index 00000000..2edd8732 --- /dev/null +++ b/docs/features/global-search.mdx @@ -0,0 +1,58 @@ +--- +title: Global Search +description: Jump to any page, node, or stack from anywhere in the app with a single keystroke. +--- + +The **global search palette** lets you move around Sencho without reaching for the mouse. It covers top-level navigation, every configured node, and every stack on every online node in your fleet. + + + Sencho global search palette filtering stacks across nodes + + +## Opening the palette + +Three ways to open it: + +- Press Ctrl + K (or Cmd + K on macOS) from anywhere in the app. +- Click the search icon in the top bar, left of the notification bell. +- Press Esc to close it at any time. + +The shortcut works from the dashboard, editor, fleet view, resources, and every other screen. + +## What you can find + +The palette groups results into three sections: + +| Group | What it contains | What happens when you pick one | +|-------|------------------|--------------------------------| +| **Pages** | Every top-level navigation destination (Home, Fleet, Resources, App Store, Logs, Auto-Update, Console, Audit, Schedules) | Navigates to that page | +| **Nodes** | Every node in your fleet, with a green dot for online and a grey dot for offline | Switches the active node without leaving the current page | +| **Stacks** | Every compose stack on every online node, matched by filename | Switches to the stack's node and opens it in the editor | + +Each stack row shows a status dot (running, exited, or unknown) on the left and the node name as a tag on the right. That way you always know which host a match lives on before you pick it. + +## Typing to filter + +Start typing and the results narrow in real time. 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. +- `opsix` — selects the Opsix node and switches the active context to it. +- `db` — lists every stack with "db" in its name, showing which node each one lives on. + +When a query matches stacks on a remote node, selecting one switches the active node and opens the editor on that stack in a single action. + +## Cross-node search + +Stack search fans out across every online node in your fleet. Offline nodes are skipped so one unreachable host never slows the palette down. Results from the active node and remote nodes appear together, sorted by the order nodes were added. + +The palette caps the Stacks group at 50 results. If you have more matches than that, keep typing to narrow the query and the extras will come into view. + +## Keyboard navigation + +Once the palette is open: + +- / — move through the results. +- Enter — pick the highlighted result. +- Esc — close the palette. + +Offline nodes show as greyed-out and are not selectable. diff --git a/docs/images/global-search/palette-stacks.png b/docs/images/global-search/palette-stacks.png new file mode 100644 index 00000000..88f63d87 Binary files /dev/null and b/docs/images/global-search/palette-stacks.png differ diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 7252dab4..11abceb6 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -51,6 +51,12 @@ import { SecurityHistoryView } from './SecurityHistoryView'; import { SENCHO_NAVIGATE_EVENT } from './NodeManager'; import type { SenchoNavigateDetail } from './NodeManager'; import { NodeSwitcher } from './NodeSwitcher'; +import { + GlobalCommandPalette, + GlobalCommandPaletteProvider, + GlobalCommandPaletteTrigger, +} from './GlobalCommandPalette'; +import { useCrossNodeStackSearch } from '@/hooks/useCrossNodeStackSearch'; import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; import type { SenchoOpenLogsDetail } from '@/lib/events'; import { useNodes } from '@/context/NodeContext'; @@ -314,8 +320,6 @@ export default function EditorLayout() { const [isEditing, setIsEditing] = useState(false); const [editingCompose, setEditingCompose] = useState(false); const [searchQuery, setSearchQuery] = useState(''); - const [remoteStackResults, setRemoteStackResults] = useState>>({}); - const [remoteSearchLoading, setRemoteSearchLoading] = useState(false); const [stackStatuses, setStackStatuses] = useState({}); const [stackPorts, setStackPorts] = useState>({}); const [labels, setLabels] = useState([]); @@ -417,19 +421,6 @@ export default function EditorLayout() { localStorage.setItem('sencho-theme', theme); }, [isDarkMode, theme]); - // ⌘K / Ctrl+K — focus stack search input - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - const input = document.querySelector('[cmdk-input]'); - input?.focus(); - } - }; - window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }, []); - // Listen for cross-component navigation (e.g., NodeManager → Schedules) useEffect(() => { const handler = (e: Event) => { @@ -443,69 +434,19 @@ export default function EditorLayout() { return () => window.removeEventListener(SENCHO_NAVIGATE_EVENT, handler); }, []); - // Global stack search: when the user types a query, fan out to every other online - // node and fetch its stack list so the sidebar can surface matches from the whole - // fleet. Debounced 250ms; cleared as soon as the query is empty. - useEffect(() => { - const query = searchQuery.trim().toLowerCase(); - if (!query) { - setRemoteStackResults({}); - setRemoteSearchLoading(false); - return; + // Fan out stack search across other online nodes so the sidebar can surface matches from the whole fleet. + const { hits: remoteSearchHits, loading: remoteSearchLoading } = useCrossNodeStackSearch({ + query: searchQuery, + enabled: true, + excludeNodeId: activeNode?.id, + }); + const remoteStackResults = useMemo(() => { + const out: Record> = {}; + for (const hit of remoteSearchHits) { + (out[hit.nodeId] ??= []).push({ file: hit.file, status: hit.status }); } - const otherNodes = nodes.filter(n => n.id !== activeNode?.id && n.status !== 'offline'); - if (otherNodes.length === 0) { - setRemoteStackResults({}); - return; - } - const controller = new AbortController(); - const timer = setTimeout(async () => { - setRemoteSearchLoading(true); - try { - const entries = await Promise.all(otherNodes.map(async (node) => { - const empty = [] as Array<{ file: string; status: 'running' | 'exited' | 'unknown' }>; - 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 [node.id, empty] as const; - const listData = await listRes.json(); - const list: string[] = Array.isArray(listData) ? listData : []; - const statuses: Record = {}; - if (statusRes.ok) { - const raw = await statusRes.json(); - for (const [key, val] of Object.entries(raw)) { - if (typeof val === 'string') { - statuses[key] = val as 'running' | 'exited' | 'unknown'; - } else if (val && typeof val === 'object' && 'status' in val) { - statuses[key] = (val as StackStatusInfo).status; - } - } - } - const matches = list - .filter(f => f.toLowerCase().includes(query)) - .map(file => ({ file, status: statuses[file] ?? 'unknown' as const })); - return [node.id, matches] as const; - } catch { - return [node.id, empty] as const; - } - })); - if (controller.signal.aborted) return; - const next: Record> = {}; - for (const [id, matches] of entries) { - if (matches.length > 0) next[id] = matches; - } - setRemoteStackResults(next); - } finally { - if (!controller.signal.aborted) setRemoteSearchLoading(false); - } - }, 250); - return () => { - clearTimeout(timer); - controller.abort(); - }; - }, [searchQuery, activeNode?.id, nodes]); + return out; + }, [remoteSearchHits]); // Force Monaco to re-measure its container after the tab switch DOM settles. // Monaco's internal child is position:static with an explicit pixel height that @@ -2143,7 +2084,13 @@ export default function EditorLayout() { ) : null; return ( +
+ {/* Left Sidebar (Stacks) */} } notifications={ setStackMisconfigScanId(null)} />
+
); } diff --git a/frontend/src/components/GlobalCommandPalette.tsx b/frontend/src/components/GlobalCommandPalette.tsx new file mode 100644 index 00000000..a3ce7c70 --- /dev/null +++ b/frontend/src/components/GlobalCommandPalette.tsx @@ -0,0 +1,227 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { Search } from 'lucide-react'; +import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { DialogDescription, DialogTitle } from '@/components/ui/dialog'; +import { useNodes, type Node } from '@/context/NodeContext'; +import { cn } from '@/lib/utils'; +import { + useCrossNodeStackSearch, + type StackHit, + type StackStatus, +} from '@/hooks/useCrossNodeStackSearch'; +import type { TopBarNavItem } from './TopBar'; + +const MAX_STACK_HITS = 50; + +const statusDot: Record = { + running: 'bg-success', + exited: 'bg-muted-foreground', + unknown: 'bg-muted-foreground/60', +}; + +interface PaletteState { + open: boolean; + setOpen: (open: boolean) => void; + toggle: () => void; +} + +const PaletteContext = createContext(null); + +export function GlobalCommandPaletteProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + // Let cmdk's own Ctrl+K handling take precedence when focus is already inside a palette + const target = e.target as HTMLElement | null; + if (target?.closest('[cmdk-root]')) return; + e.preventDefault(); + setOpen(prev => !prev); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + + const value = useMemo( + () => ({ open, setOpen, toggle: () => setOpen(prev => !prev) }), + [open], + ); + + return {children}; +} + +function usePaletteState(): PaletteState { + const ctx = useContext(PaletteContext); + if (!ctx) throw new Error('usePaletteState must be used within GlobalCommandPaletteProvider'); + return ctx; +} + +export function GlobalCommandPaletteTrigger() { + const { setOpen } = usePaletteState(); + return ( + + ); +} + +interface GlobalCommandPaletteProps { + navItems: TopBarNavItem[]; + onNavigate: (value: string) => void; + onSelectStack: (node: Node, filename: string) => void; +} + +export function GlobalCommandPalette({ navItems, onNavigate, onSelectStack }: GlobalCommandPaletteProps) { + const { open, setOpen } = usePaletteState(); + const { nodes, activeNode, setActiveNode } = useNodes(); + const [query, setQuery] = useState(''); + + const { hits: remoteHits, loading: stacksLoading } = useCrossNodeStackSearch({ + query, + enabled: open, + }); + const stackHits = useMemo(() => remoteHits.slice(0, MAX_STACK_HITS), [remoteHits]); + + const handleOpenChange = useCallback((next: boolean) => { + setOpen(next); + if (!next) setQuery(''); + }, [setOpen]); + + const handleSelectNav = useCallback((value: string) => { + handleOpenChange(false); + onNavigate(value); + }, [handleOpenChange, onNavigate]); + + const handleSelectNode = useCallback((node: Node) => { + handleOpenChange(false); + setActiveNode(node); + }, [handleOpenChange, setActiveNode]); + + const handleSelectStack = useCallback((hit: StackHit) => { + const node = nodes.find(n => n.id === hit.nodeId); + if (!node) return; + handleOpenChange(false); + onSelectStack(node, hit.file); + }, [handleOpenChange, nodes, onSelectStack]); + + const visibleNodes = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return nodes; + return nodes.filter(n => n.name.toLowerCase().includes(q)); + }, [nodes, query]); + + return ( + + + Search + Jump to a page, node, or stack + + + + + + {stacksLoading ? 'Searching...' : 'No results.'} + + + + + {navItems.map(({ value, label, icon: Icon }) => ( + handleSelectNav(value)} + > + + {label} + + ))} + + + {visibleNodes.length > 0 && ( + + {visibleNodes.map(node => { + const isActive = node.id === activeNode?.id; + const offline = node.status === 'offline'; + return ( + handleSelectNode(node)} + disabled={offline} + > + + {node.name} + {isActive && ( + + Active + + )} + + ); + })} + + )} + + {stackHits.length > 0 && ( + + {stackHits.map(hit => ( + handleSelectStack(hit)} + > + + {hit.file} + + {hit.nodeName} + + + ))} + {remoteHits.length > MAX_STACK_HITS && ( +
+ Showing first {MAX_STACK_HITS} of {remoteHits.length} +
+ )} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/TopBar.tsx b/frontend/src/components/TopBar.tsx index 8d30c5c7..19d332a4 100644 --- a/frontend/src/components/TopBar.tsx +++ b/frontend/src/components/TopBar.tsx @@ -17,6 +17,7 @@ interface TopBarProps { onNavigate: (value: string) => void; mobileNavOpen: boolean; onMobileNavOpenChange: (open: boolean) => void; + search?: ReactNode; notifications: ReactNode; userMenu: ReactNode; } @@ -27,6 +28,7 @@ export function TopBar({ onNavigate, mobileNavOpen, onMobileNavOpenChange, + search, notifications, userMenu, }: TopBarProps) { @@ -73,6 +75,7 @@ export function TopBar({ {/* RIGHT ZONE: Utilities + identity pin */}
+ {search} {notifications} {userMenu} diff --git a/frontend/src/components/sidebar/SidebarSearch.tsx b/frontend/src/components/sidebar/SidebarSearch.tsx index b376fe0f..993ed6b4 100644 --- a/frontend/src/components/sidebar/SidebarSearch.tsx +++ b/frontend/src/components/sidebar/SidebarSearch.tsx @@ -5,23 +5,15 @@ interface SidebarSearchProps { onValueChange: (v: string) => void; } -function kbdHint(): string { - if (typeof navigator === 'undefined') return 'Ctrl+K'; - return /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘K' : 'Ctrl+K'; -} - export function SidebarSearch({ value, onValueChange }: SidebarSearchProps) { return ( -
+
- - {kbdHint()} -
); } diff --git a/frontend/src/hooks/useCrossNodeStackSearch.ts b/frontend/src/hooks/useCrossNodeStackSearch.ts new file mode 100644 index 00000000..e198ccb2 --- /dev/null +++ b/frontend/src/hooks/useCrossNodeStackSearch.ts @@ -0,0 +1,98 @@ +import { useEffect, 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; +} + +interface Options { + query: string; + enabled: boolean; + excludeNodeId?: number; +} + +const DEBOUNCE_MS = 250; + +export function useCrossNodeStackSearch({ query, enabled, excludeNodeId }: Options) { + const { nodes } = useNodes(); + const [hits, setHits] = useState([]); + const [loading, setLoading] = useState(false); + + // Ref avoids re-running the effect on every NodeContext status tick + const nodesRef = useRef(nodes); + nodesRef.current = nodes; + + useEffect(() => { + const q = query.trim().toLowerCase(); + if (!enabled || !q) { + setHits([]); + setLoading(false); + return; + } + const targets = nodesRef.current.filter( + n => n.status !== 'offline' && n.id !== excludeNodeId, + ); + if (targets.length === 0) { + setHits([]); + return; + } + const controller = new AbortController(); + const timer = setTimeout(async () => { + setLoading(true); + try { + const perNode = await Promise.all(targets.map(async (node) => { + 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[]; + const rawList = await listRes.json(); + const files: string[] = Array.isArray(rawList) ? rawList : []; + const statuses: Record = {}; + 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; + } + } + } + return files + .filter(f => f.toLowerCase().includes(q)) + .map(file => ({ + nodeId: node.id, + nodeName: node.name, + file, + status: statuses[file] ?? 'unknown', + })); + } catch { + return [] as StackHit[]; + } + })); + if (controller.signal.aborted) return; + setHits(perNode.flat()); + } finally { + if (!controller.signal.aborted) setLoading(false); + } + }, DEBOUNCE_MS); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [enabled, query, excludeNodeId]); + + return { hits, loading }; +}