Merge pull request #294 from Floyddotnet/main
feat: search for nodes and properties
This commit is contained in:
@@ -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 && (
|
||||
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
|
||||
{nodeResults.map((n) => (
|
||||
{nodeResults.map((n, idx) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => goToNode(n.id)}
|
||||
key={`${n.node.id}-${idx}`}
|
||||
onClick={() => goToNode(n.node.id)}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
@@ -161,15 +191,19 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
||||
>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{n.data.label}
|
||||
{n.node.data.label}
|
||||
</span>
|
||||
{n.data.ip && (
|
||||
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
||||
{n.data.ip}
|
||||
{n.matches.length > 0 && (
|
||||
<span style={{ maxWidth: "50%", display: 'flex', flexDirection: 'column', gap: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{n.matches.map((m, mIdx) => (
|
||||
<span key={`${m.key}-${mIdx}`} style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0, display: 'inline-block' }}>
|
||||
{m.display_value}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 10, color: '#6e7681', flexShrink: 0 }}>
|
||||
{NODE_TYPE_LABELS[n.data.type] ?? n.data.type}
|
||||
{NODE_TYPE_LABELS[n.node.data.type] ?? n.node.data.type}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -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(<SearchBar />)
|
||||
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(<SearchBar />)
|
||||
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(<SearchBar />)
|
||||
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(<SearchBar />)
|
||||
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 } }),
|
||||
|
||||
Reference in New Issue
Block a user