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.
This commit is contained in:
Pouzor
2026-04-05 02:40:12 +02:00
parent 08a3a689c5
commit 9cb9d02459
6 changed files with 203 additions and 49 deletions
+61 -14
View File
@@ -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<PendingDevice[]>([])
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)
}}
/>
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
</div>
{results.length > 0 && (
<ul className="py-1 max-h-64 overflow-y-auto">
{results.map((node) => (
{totalResults > 0 && (
<ul className="py-1 max-h-72 overflow-y-auto">
{nodeResults.map((node) => (
<li
key={node.id}
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
onClick={() => handleSelect(node.id)}
onClick={() => handleSelectNode(node.id)}
>
<span className="text-xs font-mono text-[#00d4ff] w-16 shrink-0">{node.data.type}</span>
<span className="text-sm text-foreground font-medium flex-1 truncate">{node.data.label}</span>
@@ -68,15 +96,34 @@ export function SearchModal({ open, onClose }: SearchModalProps) {
)}
</li>
))}
{pendingResults.length > 0 && nodeResults.length > 0 && (
<li className="px-4 py-1">
<div className="h-px bg-border" />
</li>
)}
{pendingResults.map((device) => {
const serviceName = device.services.find((s) => s.service_name)?.service_name
return (
<li
key={device.id}
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
onClick={() => handleSelectPending(device.id)}
>
<span className="text-xs font-mono text-[#e3b341] w-16 shrink-0">pending</span>
<span className="text-sm text-foreground font-medium flex-1 truncate font-mono">{device.hostname ?? device.ip}</span>
<span className="text-xs font-mono text-muted-foreground shrink-0">{serviceName ?? device.ip}</span>
</li>
)
})}
</ul>
)}
{q.length > 0 && results.length === 0 && (
<p className="px-4 py-3 text-sm text-muted-foreground">No nodes match "{query}"</p>
{q.length > 0 && totalResults === 0 && (
<p className="px-4 py-3 text-sm text-muted-foreground">No results match "{query}"</p>
)}
{q.length === 0 && (
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes</p>
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes and pending devices</p>
)}
</div>
</div>
@@ -10,6 +10,12 @@ vi.mock('@xyflow/react', () => ({
useReactFlow: () => ({ fitView: mockFitView }),
}))
vi.mock('@/api/client', () => ({
scanApi: { pending: vi.fn().mockResolvedValue({ data: [] }) },
}))
const mockOnOpenPending = vi.fn()
function makeNode(id: string, overrides: Partial<NodeData> = {}): Node<NodeData> {
return {
id,
@@ -32,32 +38,32 @@ describe('SearchModal', () => {
})
it('renders nothing when closed', () => {
render(<SearchModal open={false} onClose={vi.fn()} />)
render(<SearchModal open={false} onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
expect(screen.queryByPlaceholderText(/search nodes/i)).toBeNull()
})
it('renders search input when open', () => {
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
expect(screen.getByPlaceholderText(/search nodes/i)).toBeDefined()
})
it('shows "Type to search" hint when query is empty', () => {
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
expect(screen.getByText(/type to search/i)).toBeDefined()
})
it('shows no results message when query has no matches', () => {
useCanvasStore.setState({ nodes: [makeNode('router', { label: 'Router' })] })
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zzz' } })
expect(screen.getByText(/no nodes match/i)).toBeDefined()
expect(screen.getByText(/no results match/i)).toBeDefined()
})
it('filters nodes by label', () => {
useCanvasStore.setState({
nodes: [makeNode('n1', { label: 'My Router' }), makeNode('n2', { label: 'NAS Server' })],
})
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'router' } })
expect(screen.getByText('My Router')).toBeDefined()
expect(screen.queryByText('NAS Server')).toBeNull()
@@ -70,7 +76,7 @@ describe('SearchModal', () => {
makeNode('n2', { label: 'Box B', ip: '10.0.0.1' }),
],
})
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: '192.168' } })
expect(screen.getByText('Box A')).toBeDefined()
expect(screen.queryByText('Box B')).toBeNull()
@@ -83,7 +89,7 @@ describe('SearchModal', () => {
makeNode('n2', { label: 'B', hostname: 'nas.local' }),
],
})
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'pve' } })
expect(screen.getByText('A')).toBeDefined()
expect(screen.queryByText('B')).toBeNull()
@@ -96,25 +102,25 @@ describe('SearchModal', () => {
makeNode('g1', { label: 'Zone A', type: 'groupRect' }),
],
})
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'zone' } })
expect(screen.getByText(/no nodes match/i)).toBeDefined()
expect(screen.getByText(/no results match/i)).toBeDefined()
})
it('limits results to 8 nodes', () => {
it('limits node results to 6', () => {
useCanvasStore.setState({
nodes: Array.from({ length: 12 }, (_, i) => makeNode(`n${i}`, { label: `Server ${i}` })),
})
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'server' } })
const items = screen.getAllByText(/Server \d/)
expect(items).toHaveLength(8)
expect(items).toHaveLength(6)
})
it('selects node and closes on result click', () => {
const onClose = vi.fn()
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Proxmox' })] })
render(<SearchModal open onClose={onClose} />)
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'prox' } })
fireEvent.click(screen.getByText('Proxmox'))
expect(useCanvasStore.getState().selectedNodeId).toBe('n1')
@@ -125,7 +131,7 @@ describe('SearchModal', () => {
it('selects first result and closes on Enter key', () => {
const onClose = vi.fn()
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'Switch' })] })
render(<SearchModal open onClose={onClose} />)
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
const input = screen.getByPlaceholderText(/search nodes/i)
fireEvent.change(input, { target: { value: 'switch' } })
fireEvent.keyDown(input, { key: 'Enter' })
@@ -135,14 +141,14 @@ describe('SearchModal', () => {
it('closes on Escape key', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />)
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
fireEvent.keyDown(screen.getByPlaceholderText(/search nodes/i), { key: 'Escape' })
expect(onClose).toHaveBeenCalledOnce()
})
it('closes when clicking backdrop', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />)
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
// The backdrop is the fixed inset div — clicking it fires onClose
const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement
fireEvent.click(backdrop)
@@ -151,14 +157,14 @@ describe('SearchModal', () => {
it('does not close when clicking inside the search box', () => {
const onClose = vi.fn()
render(<SearchModal open onClose={onClose} />)
render(<SearchModal open onClose={onClose} onOpenPending={mockOnOpenPending} />)
fireEvent.click(screen.getByPlaceholderText(/search nodes/i))
expect(onClose).not.toHaveBeenCalled()
})
it('search is case-insensitive', () => {
useCanvasStore.setState({ nodes: [makeNode('n1', { label: 'My NAS' })] })
render(<SearchModal open onClose={vi.fn()} />)
render(<SearchModal open onClose={vi.fn()} onOpenPending={mockOnOpenPending} />)
fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'MY NAS' } })
expect(screen.getByText('My NAS')).toBeDefined()
})