From bdf3b6ea40b429b5d9b2242ce27c90b90ca19f8b Mon Sep 17 00:00:00 2001 From: Pouzor Date: Wed, 1 Apr 2026 23:32:28 +0200 Subject: [PATCH] feat: add Ctrl+F search bar to canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ctrl+F / Cmd+F opens floating search bar at top-center of canvas - Filters nodes by label, IP, hostname and service name (case-insensitive) - Shows match count and no-results message - Click result selects node and flies camera to it with animation - Escape or × closes the bar - groupRect nodes excluded from results - Handles grouped nodes with correct absolute position for navigation --- .../src/components/canvas/CanvasContainer.tsx | 2 + frontend/src/components/canvas/SearchBar.tsx | 160 ++++++++++++++++++ .../canvas/__tests__/SearchBar.test.tsx | 146 ++++++++++++++++ 3 files changed, 308 insertions(+) create mode 100644 frontend/src/components/canvas/SearchBar.tsx create mode 100644 frontend/src/components/canvas/__tests__/SearchBar.test.tsx diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index bb7e493..330e3da 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -18,6 +18,7 @@ import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' import { nodeTypes } from './nodes/nodeTypes' import { edgeTypes } from './edges/edgeTypes' +import { SearchBar } from './SearchBar' import type { NodeData, EdgeData } from '@/types' interface CanvasContainerProps { @@ -88,6 +89,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 new file mode 100644 index 0000000..84103c2 --- /dev/null +++ b/frontend/src/components/canvas/SearchBar.tsx @@ -0,0 +1,160 @@ +import { useState, useEffect, useRef } from 'react' +import { useReactFlow } from '@xyflow/react' +import { Search, X } from 'lucide-react' +import { useCanvasStore } from '@/stores/canvasStore' +import { NODE_TYPE_LABELS } from '@/types' + +export function SearchBar() { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const inputRef = useRef(null) + const { nodes, setSelectedNode } = useCanvasStore() + const { setCenter } = useReactFlow() + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'f') { + e.preventDefault() + setOpen(true) + } + if (e.key === 'Escape') { + setOpen(false) + setQuery('') + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, []) + + useEffect(() => { + if (open) inputRef.current?.focus() + }, [open]) + + const q = query.toLowerCase().trim() + const results = q + ? nodes.filter((n) => { + if (n.data.type === 'groupRect') return false + return ( + n.data.label?.toLowerCase().includes(q) || + n.data.ip?.toLowerCase().includes(q) || + n.data.hostname?.toLowerCase().includes(q) || + (n.data.services ?? []).some((s) => s.service_name?.toLowerCase().includes(q)) + ) + }) + : [] + + const goToNode = (id: string) => { + const node = nodes.find((n) => n.id === id) + if (!node) return + setSelectedNode(id) + // For grouped nodes, add parent's absolute position + let absX = node.position.x + let absY = node.position.y + if (node.parentId) { + const parent = nodes.find((n) => n.id === node.parentId) + if (parent) { absX += parent.position.x; absY += parent.position.y } + } + const w = node.measured?.width ?? node.width ?? 200 + const h = node.measured?.height ?? node.height ?? 80 + setCenter(absX + w / 2, absY + h / 2, { zoom: 1.5, duration: 500 }) + setOpen(false) + setQuery('') + } + + if (!open) return null + + return ( +
+
+
+ + setQuery(e.target.value)} + placeholder="Search by name, IP, hostname or service…" + style={{ + flex: 1, + background: 'transparent', + border: 'none', + outline: 'none', + color: '#e6edf3', + fontSize: 13, + }} + /> + {query && ( + + {results.length} result{results.length !== 1 ? 's' : ''} + + )} + +
+ + {results.length > 0 && ( +
+ {results.map((n) => ( + + ))} +
+ )} + + {q && results.length === 0 && ( +
+ No results for “{query}” +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/canvas/__tests__/SearchBar.test.tsx b/frontend/src/components/canvas/__tests__/SearchBar.test.tsx new file mode 100644 index 0000000..e254bd2 --- /dev/null +++ b/frontend/src/components/canvas/__tests__/SearchBar.test.tsx @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { SearchBar } from '../SearchBar' +import * as canvasStore from '@/stores/canvasStore' + +vi.mock('@/stores/canvasStore') + +vi.mock('@xyflow/react', () => ({ + useReactFlow: () => ({ setCenter: vi.fn() }), +})) + +function makeNode(id: string, overrides = {}) { + return { + id, + type: 'server', + position: { x: 0, y: 0 }, + data: { label: id, type: 'server', status: 'online', services: [], ip: null, hostname: null }, + ...overrides, + } +} + +function setupStore(nodes: unknown[] = []) { + vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ + nodes, + setSelectedNode: vi.fn(), + } as unknown as ReturnType) +} + +function openSearch() { + fireEvent.keyDown(window, { key: 'f', ctrlKey: true }) +} + +describe('SearchBar', () => { + beforeEach(() => { + setupStore([]) + vi.clearAllMocks() + }) + + it('is hidden by default', () => { + render() + expect(screen.queryByPlaceholderText(/search/i)).toBeNull() + }) + + it('opens on Ctrl+F', () => { + render() + openSearch() + expect(screen.getByPlaceholderText(/search/i)).toBeDefined() + }) + + it('opens on Cmd+F', () => { + render() + fireEvent.keyDown(window, { key: 'f', metaKey: true }) + expect(screen.getByPlaceholderText(/search/i)).toBeDefined() + }) + + it('closes on Escape', () => { + render() + openSearch() + fireEvent.keyDown(window, { key: 'Escape' }) + expect(screen.queryByPlaceholderText(/search/i)).toBeNull() + }) + + it('closes when X button is clicked', () => { + render() + openSearch() + fireEvent.click(screen.getByLabelText('Close search')) + expect(screen.queryByPlaceholderText(/search/i)).toBeNull() + }) + + it('filters by label', () => { + setupStore([ + makeNode('n1', { data: { label: 'My Router', type: 'router', status: 'online', services: [], ip: null, hostname: null } }), + makeNode('n2', { data: { label: 'My NAS', type: 'nas', status: 'online', services: [], ip: null, hostname: null } }), + ]) + render() + openSearch() + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'router' } }) + expect(screen.getByText('My Router')).toBeDefined() + expect(screen.queryByText('My NAS')).toBeNull() + }) + + it('filters by IP', () => { + setupStore([ + makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: '192.168.1.10', hostname: null } }), + makeNode('n2', { data: { label: 'Server B', type: 'server', status: 'online', services: [], ip: '10.0.0.1', hostname: null } }), + ]) + render() + openSearch() + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: '192.168' } }) + expect(screen.getByText('Server A')).toBeDefined() + expect(screen.queryByText('Server B')).toBeNull() + }) + + it('filters by service name', () => { + setupStore([ + makeNode('n1', { data: { label: 'Web Server', type: 'server', status: 'online', services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }], ip: null, hostname: null } }), + makeNode('n2', { data: { label: 'DB Server', type: 'server', status: 'online', services: [{ service_name: 'mysql', port: 3306, protocol: 'tcp' }], ip: null, hostname: null } }), + ]) + render() + openSearch() + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'nginx' } }) + expect(screen.getByText('Web Server')).toBeDefined() + expect(screen.queryByText('DB Server')).toBeNull() + }) + + it('excludes groupRect nodes from results', () => { + setupStore([ + makeNode('gr1', { data: { label: 'DMZ Zone', type: 'groupRect', status: 'unknown', services: [], ip: null, hostname: null } }), + ]) + render() + openSearch() + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'dmz' } }) + expect(screen.queryByText('DMZ Zone')).toBeNull() + }) + + it('shows no-results message when query has no matches', () => { + render() + openSearch() + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'zzznomatch' } }) + expect(screen.getByText(/no results/i)).toBeDefined() + }) + + it('calls setSelectedNode when a result is clicked', () => { + const setSelectedNode = vi.fn() + vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ + nodes: [makeNode('n1', { data: { label: 'My Server', type: 'server', status: 'online', services: [], ip: null, hostname: null } })], + setSelectedNode, + } as unknown as ReturnType) + render() + openSearch() + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'my server' } }) + fireEvent.click(screen.getByText('My Server')) + expect(setSelectedNode).toHaveBeenCalledWith('n1') + }) + + it('shows result count', () => { + setupStore([ + makeNode('n1', { data: { label: 'Alpha', type: 'server', status: 'online', services: [], ip: null, hostname: null } }), + makeNode('n2', { data: { label: 'Beta', type: 'server', status: 'online', services: [], ip: null, hostname: null } }), + ]) + render() + openSearch() + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'a' } }) + expect(screen.getByText(/2 results/i)).toBeDefined() + }) +})