From 9cb9d02459f93e49e94e2de9ef5c6637ec5ef2b7 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 5 Apr 2026 02:40:12 +0200 Subject: [PATCH] feat: extend search (Ctrl+F and Ctrl+K) to include pending devices Both SearchBar and SearchModal now fetch and search pending devices by IP, hostname, and service name. Selecting a pending result opens the sidebar to the Pending tab and highlights the matching device. --- frontend/src/App.tsx | 31 +++++++- .../src/components/canvas/CanvasContainer.tsx | 5 +- frontend/src/components/canvas/SearchBar.tsx | 70 +++++++++++++++-- .../src/components/modals/SearchModal.tsx | 75 +++++++++++++++---- .../modals/__tests__/SearchModal.test.tsx | 44 ++++++----- frontend/src/components/panels/Sidebar.tsx | 27 +++++-- 6 files changed, 203 insertions(+), 49 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 85a03e9..635c149 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -44,6 +44,8 @@ export default function App() { const [themeModalOpen, setThemeModalOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false) + const [sidebarForceView, setSidebarForceView] = useState<'pending' | undefined>(undefined) + const [highlightPendingId, setHighlightPendingId] = useState(undefined) const [shortcutsOpen, setShortcutsOpen] = useState(false) const [addNodeOpen, setAddNodeOpen] = useState(false) const [addGroupRectOpen, setAddGroupRectOpen] = useState(false) @@ -370,6 +372,8 @@ export default function App() { onScan={() => setScanConfigOpen(true)} onSave={handleSave} onNodeApproved={setEditNodeId} + forceView={sidebarForceView} + highlightPendingId={highlightPendingId} />
- + { + setHighlightPendingId(undefined) + setSidebarForceView(undefined) + setTimeout(() => { + setHighlightPendingId(deviceId) + setSidebarForceView('pending') + }, 0) + }} + />
{(selectedNodeId || selectedNodeIds.length > 1) && }
@@ -484,7 +500,18 @@ export default function App() { onClose={() => setThemeModalOpen(false)} /> - setSearchOpen(false)} /> + setSearchOpen(false)} + onOpenPending={(deviceId) => { + setHighlightPendingId(undefined) + setSidebarForceView(undefined) + setTimeout(() => { + setHighlightPendingId(deviceId) + setSidebarForceView('pending') + }, 0) + }} + /> setShortcutsOpen(false)} /> diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 486afe1..997aa8f 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -26,9 +26,10 @@ interface CanvasContainerProps { onConnect?: (connection: Connection) => void onEdgeDoubleClick?: (edge: Edge) => void onNodeDragStart?: () => void + onOpenPending?: (deviceId: string) => void } -export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart }: CanvasContainerProps) { +export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) { const [lassoMode, setLassoMode] = useState(true) const { nodes, edges, @@ -101,7 +102,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o size={1} color={theme.colors.canvasDotColor} /> - + setLassoMode((m) => !m)} diff --git a/frontend/src/components/canvas/SearchBar.tsx b/frontend/src/components/canvas/SearchBar.tsx index 84103c2..beac37b 100644 --- a/frontend/src/components/canvas/SearchBar.tsx +++ b/frontend/src/components/canvas/SearchBar.tsx @@ -2,15 +2,27 @@ import { useState, useEffect, useRef } from 'react' import { useReactFlow } from '@xyflow/react' import { Search, X } from 'lucide-react' import { useCanvasStore } from '@/stores/canvasStore' +import { scanApi } from '@/api/client' import { NODE_TYPE_LABELS } from '@/types' +import type { PendingDevice } from '@/components/modals/PendingDeviceModal' -export function SearchBar() { +interface SearchBarProps { + onOpenPending?: (deviceId: string) => void +} + +export function SearchBar({ onOpenPending }: SearchBarProps) { const [open, setOpen] = useState(false) const [query, setQuery] = useState('') + const [pendingDevices, setPendingDevices] = useState([]) const inputRef = useRef(null) const { nodes, setSelectedNode } = useCanvasStore() const { setCenter } = useReactFlow() + useEffect(() => { + if (!open) return + scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {}) + }, [open]) + useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 'f') { @@ -31,7 +43,7 @@ export function SearchBar() { }, [open]) const q = query.toLowerCase().trim() - const results = q + const nodeResults = q ? nodes.filter((n) => { if (n.data.type === 'groupRect') return false return ( @@ -43,6 +55,19 @@ export function SearchBar() { }) : [] + const pendingResults = q + ? pendingDevices.filter((d) => + d.ip.toLowerCase().includes(q) || + d.hostname?.toLowerCase().includes(q) || + d.services.some((s) => + s.service_name?.toLowerCase().includes(q) || + s.category?.toLowerCase().includes(q) + ) + ).slice(0, 4) + : [] + + const totalResults = nodeResults.length + pendingResults.length + const goToNode = (id: string) => { const node = nodes.find((n) => n.id === id) if (!node) return @@ -101,7 +126,7 @@ export function SearchBar() { /> {query && ( - {results.length} result{results.length !== 1 ? 's' : ''} + {totalResults} result{totalResults !== 1 ? 's' : ''} )}
- {results.length > 0 && ( + {totalResults > 0 && (
- {results.map((n) => ( + {nodeResults.map((n) => ( ))} + {pendingResults.length > 0 && nodeResults.length > 0 && ( +
+ )} + {pendingResults.map((d) => { + const serviceName = d.services.find((s) => s.service_name)?.service_name + return ( + + ) + })}
)} - {q && results.length === 0 && ( + {q && totalResults === 0 && (
No results for “{query}”
diff --git a/frontend/src/components/modals/SearchModal.tsx b/frontend/src/components/modals/SearchModal.tsx index 5ae862c..10f4b5c 100644 --- a/frontend/src/components/modals/SearchModal.tsx +++ b/frontend/src/components/modals/SearchModal.tsx @@ -1,34 +1,61 @@ -import { useState, useCallback } from 'react' +import { useState, useCallback, useEffect } from 'react' import { useReactFlow } from '@xyflow/react' import { Search } from 'lucide-react' import { useCanvasStore } from '@/stores/canvasStore' +import { scanApi } from '@/api/client' +import type { PendingDevice } from '@/components/modals/PendingDeviceModal' interface SearchModalProps { open: boolean onClose: () => void + onOpenPending: (deviceId: string) => void } -export function SearchModal({ open, onClose }: SearchModalProps) { +export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps) { const [query, setQuery] = useState('') + const [pendingDevices, setPendingDevices] = useState([]) const nodes = useCanvasStore((s) => s.nodes) const setSelectedNode = useCanvasStore((s) => s.setSelectedNode) const { fitView } = useReactFlow() + useEffect(() => { + if (!open) return + scanApi.pending().then((res) => setPendingDevices(res.data)).catch(() => {}) + }, [open]) + const searchable = nodes.filter((n) => n.data.type !== 'groupRect') const q = query.toLowerCase() - const results = q.length === 0 ? [] : searchable.filter((n) => + + const nodeResults = q.length === 0 ? [] : searchable.filter((n) => n.data.label?.toLowerCase().includes(q) || n.data.ip?.toLowerCase().includes(q) || n.data.hostname?.toLowerCase().includes(q) - ).slice(0, 8) + ).slice(0, 6) - const handleSelect = useCallback((nodeId: string) => { + const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) => + d.ip.toLowerCase().includes(q) || + d.hostname?.toLowerCase().includes(q) || + d.services.some((s) => + s.service_name?.toLowerCase().includes(q) || + s.category?.toLowerCase().includes(q) + ) + ).slice(0, 4) + + const totalResults = nodeResults.length + pendingResults.length + + const handleSelectNode = useCallback((nodeId: string) => { setSelectedNode(nodeId) fitView({ nodes: [{ id: nodeId }], duration: 600, padding: 0.4, maxZoom: 1.5 }) onClose() setQuery('') }, [fitView, setSelectedNode, onClose]) + const handleSelectPending = useCallback((deviceId: string) => { + onOpenPending(deviceId) + onClose() + setQuery('') + }, [onOpenPending, onClose]) + if (!open) return null return ( @@ -43,23 +70,24 @@ export function SearchModal({ open, onClose }: SearchModalProps) { autoFocus value={query} onChange={(e) => setQuery(e.target.value)} - placeholder="Search nodes by label, IP, hostname…" + placeholder="Search nodes, pending devices by IP or service…" className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none" onKeyDown={(e) => { if (e.key === 'Escape') { onClose(); setQuery('') } - if (e.key === 'Enter' && results.length > 0) handleSelect(results[0].id) + if (e.key === 'Enter' && nodeResults.length > 0) handleSelectNode(nodeResults[0].id) + if (e.key === 'Enter' && nodeResults.length === 0 && pendingResults.length > 0) handleSelectPending(pendingResults[0].id) }} /> ESC
- {results.length > 0 && ( -