diff --git a/frontend/src/components/canvas/SearchBar.tsx b/frontend/src/components/canvas/SearchBar.tsx
index 5dca55c..5eab347 100644
--- a/frontend/src/components/canvas/SearchBar.tsx
+++ b/frontend/src/components/canvas/SearchBar.tsx
@@ -43,15 +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 }> = []
+
+ // 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 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 labelMatch || matches.length > 0 ? [{ node: n, matches }] : []
})
: []
@@ -142,10 +172,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
{totalResults > 0 && (
- {nodeResults.map((n) => (
+ {nodeResults.map((n, idx) => (
))}
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 } }),