From 9000f8221325216da0198d0438e03af9782eacd9 Mon Sep 17 00:00:00 2001 From: Peter Becker Date: Fri, 17 Jul 2026 23:00:20 +0200 Subject: [PATCH 1/2] add search for nodes and properties --- frontend/src/components/canvas/SearchBar.tsx | 68 ++++++++++++++------ 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/canvas/SearchBar.tsx b/frontend/src/components/canvas/SearchBar.tsx index 5dca55c..7bfff65 100644 --- a/frontend/src/components/canvas/SearchBar.tsx +++ b/frontend/src/components/canvas/SearchBar.tsx @@ -43,17 +43,45 @@ export function SearchBar({ onOpenPending }: SearchBarProps) { }, [open]) const q = query.toLowerCase().trim() + const nodeResults = 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)) - ) - }) - : [] + ? nodes.flatMap((n) => { + if (n.data.type === 'groupRect') return [] + + const contains = (v?: string | null) => v?.toLowerCase().includes(q) + + const matches: Array<{ key: string; display_value: string }> = [] + + for (const [key, value] of [ + ['label', n.data.label], + ['notes', n.data.notes], + ['ip', n.data.ip], + ['hostname', n.data.hostname], + ] as const) { + if (contains(value)) { + matches.push({ key, display_value: value ?? '' }) + } + } + + for (const s of n.data.services ?? []) { + if (contains(s.service_name)) { + matches.push({ key: 'services', display_value: s.service_name ?? '' }) + } + } + + for (const p of (n.data.properties ?? []).filter((p) => p.visible)) { + if (contains(p.key) || contains(p.value)) { + matches.push({ + key: 'properties', + display_value: `${p.key ?? ''} = ${p.value ?? ''}`, + }) + } + } + + return matches.length > 0 ? [{ node: n, matches: matches }] : [] + }) + : []; + const pendingResults = q ? pendingDevices.filter((d) => @@ -142,10 +170,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) { {totalResults > 0 && (
- {nodeResults.map((n) => ( + {nodeResults.map((n, idx) => ( ))} From a364a95cb5f5e2c5ed62c7ebcf4a3d3fd3192fe3 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sat, 18 Jul 2026 01:17:36 +0200 Subject: [PATCH 2/2] fix(search): clean up node/property search matches - add tests for notes, property key/value, hidden-property exclusion, matched-value display - fix flatMap indentation and stray semicolon - use stable match key instead of display_value (avoids dup-key collisions) - keep label searchable but not repeated in matched-values column --- frontend/src/components/canvas/SearchBar.tsx | 62 ++++++++++--------- .../canvas/__tests__/SearchBar.test.tsx | 44 +++++++++++++ 2 files changed, 76 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/canvas/SearchBar.tsx b/frontend/src/components/canvas/SearchBar.tsx index 7bfff65..5eab347 100644 --- a/frontend/src/components/canvas/SearchBar.tsx +++ b/frontend/src/components/canvas/SearchBar.tsx @@ -45,43 +45,45 @@ export function SearchBar({ onOpenPending }: SearchBarProps) { const q = query.toLowerCase().trim() const nodeResults = q - ? nodes.flatMap((n) => { - if (n.data.type === 'groupRect') return [] + ? nodes.flatMap((n) => { + if (n.data.type === 'groupRect') return [] - const contains = (v?: string | null) => v?.toLowerCase().includes(q) + const contains = (v?: string | null) => v?.toLowerCase().includes(q) - const matches: Array<{ key: string; display_value: string }> = [] + const matches: Array<{ key: string; display_value: string }> = [] - for (const [key, value] of [ - ['label', n.data.label], - ['notes', n.data.notes], - ['ip', n.data.ip], - ['hostname', n.data.hostname], - ] as const) { - if (contains(value)) { - matches.push({ key, display_value: value ?? '' }) + // Label matches make the node a result, but it is already shown in the + // header — don't repeat it in the matched-values column. + const labelMatch = contains(n.data.label) + + for (const [key, value] of [ + ['notes', n.data.notes], + ['ip', n.data.ip], + ['hostname', n.data.hostname], + ] as const) { + if (contains(value)) { + matches.push({ key, display_value: value ?? '' }) + } } - } - for (const s of n.data.services ?? []) { - if (contains(s.service_name)) { - matches.push({ key: 'services', display_value: s.service_name ?? '' }) + for (const s of n.data.services ?? []) { + if (contains(s.service_name)) { + matches.push({ key: 'services', display_value: s.service_name ?? '' }) + } } - } - for (const p of (n.data.properties ?? []).filter((p) => p.visible)) { - if (contains(p.key) || contains(p.value)) { - matches.push({ - key: 'properties', - display_value: `${p.key ?? ''} = ${p.value ?? ''}`, - }) + for (const p of (n.data.properties ?? []).filter((p) => p.visible)) { + if (contains(p.key) || contains(p.value)) { + matches.push({ + key: 'properties', + display_value: `${p.key ?? ''} = ${p.value ?? ''}`, + }) + } } - } - return matches.length > 0 ? [{ node: n, matches: matches }] : [] - }) - : []; - + return labelMatch || matches.length > 0 ? [{ node: n, matches }] : [] + }) + : [] const pendingResults = q ? pendingDevices.filter((d) => @@ -193,8 +195,8 @@ export function SearchBar({ onOpenPending }: SearchBarProps) { {n.matches.length > 0 && ( - {n.matches.map((m) => ( - + {n.matches.map((m, mIdx) => ( + {m.display_value} ))} diff --git a/frontend/src/components/canvas/__tests__/SearchBar.test.tsx b/frontend/src/components/canvas/__tests__/SearchBar.test.tsx index e254bd2..8e3911d 100644 --- a/frontend/src/components/canvas/__tests__/SearchBar.test.tsx +++ b/frontend/src/components/canvas/__tests__/SearchBar.test.tsx @@ -103,6 +103,50 @@ describe('SearchBar', () => { expect(screen.queryByText('DB Server')).toBeNull() }) + it('filters by notes', () => { + setupStore([ + makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: null, hostname: null, notes: 'backup target every night' } }), + makeNode('n2', { data: { label: 'Server B', type: 'server', status: 'online', services: [], ip: null, hostname: null, notes: 'primary web host' } }), + ]) + render() + openSearch() + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'backup' } }) + expect(screen.getByText('Server A')).toBeDefined() + expect(screen.queryByText('Server B')).toBeNull() + }) + + it('filters by visible property key or value', () => { + setupStore([ + makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: null, hostname: null, properties: [{ key: 'rack', value: 'B12', icon: null, visible: true }] } }), + makeNode('n2', { data: { label: 'Server B', type: 'server', status: 'online', services: [], ip: null, hostname: null, properties: [{ key: 'rack', value: 'A01', icon: null, visible: true }] } }), + ]) + render() + openSearch() + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'b12' } }) + expect(screen.getByText('Server A')).toBeDefined() + expect(screen.queryByText('Server B')).toBeNull() + }) + + it('ignores hidden properties', () => { + setupStore([ + makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: null, hostname: null, properties: [{ key: 'secret', value: 'hidden-val', icon: null, visible: false }] } }), + ]) + render() + openSearch() + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'hidden-val' } }) + expect(screen.queryByText('Server A')).toBeNull() + }) + + it('shows the matched value alongside the node label', () => { + setupStore([ + makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: null, hostname: null, properties: [{ key: 'rack', value: 'B12', icon: null, visible: true }] } }), + ]) + render() + openSearch() + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'b12' } }) + expect(screen.getByText('rack = B12')).toBeDefined() + }) + it('excludes groupRect nodes from results', () => { setupStore([ makeNode('gr1', { data: { label: 'DMZ Zone', type: 'groupRect', status: 'unknown', services: [], ip: null, hostname: null } }),