From 9cea45945c91b5aa62a9a37b6e01ee1ead2751bb Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Sun, 12 Jul 2026 20:18:18 +0100 Subject: [PATCH] feat(*): Add node search with highlight and dim on the diagram --- src/lib/components/NetworkDiagram.svelte | 135 +++++++++++++++++++++++ src/lib/graph/searchHighlight.ts | 53 +++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/lib/graph/searchHighlight.ts diff --git a/src/lib/components/NetworkDiagram.svelte b/src/lib/components/NetworkDiagram.svelte index b8ec12d..2623700 100644 --- a/src/lib/components/NetworkDiagram.svelte +++ b/src/lib/components/NetworkDiagram.svelte @@ -22,6 +22,7 @@ import loadNetworkData from '$lib/data/loadNetworkData'; import { validateNetworkData, type ValidationIssue } from '$lib/data/networkSchema'; import transformNetworkDataToGraph from '$lib/graph/transformNetworkData'; + import { computeSearchMatches } from '$lib/graph/searchHighlight'; import type { GraphNodeData, GraphTransformResult } from '$lib/graph/types'; import { themeMode, type ThemeMode } from '$lib/stores/theme'; import type { NetworkData, Port } from '$lib/types'; @@ -78,6 +79,9 @@ let showEthernetLabels = false; let diagramViewMode: DiagramViewMode = 'network'; + let searchQuery = ''; + let searchMatchCount = 0; + let lastTransformResult: GraphTransformResult | null = null; let machineVmIndex: GraphTransformResult['machineVmIndex'] = {}; let collapsedHosts: Record = {}; let exportFileName = 'network-diagram'; @@ -514,6 +518,25 @@ 'font-size': 8, color: theme === 'dark' ? '#94a3b8' : '#64748b' } + }, + { + selector: 'node.search-match', + style: { + 'border-width': 4, + 'border-color': '#f59e0b' + } + }, + { + selector: 'node.dimmed', + style: { + opacity: 0.15 + } + }, + { + selector: 'edge.dimmed', + style: { + opacity: 0.1 + } } ]; } @@ -910,6 +933,7 @@ } const transformed = transformNetworkDataToGraph(networkData); + lastTransformResult = transformed; machineVmIndex = transformed.machineVmIndex; syncCollapsedHosts(machineVmIndex); const visible = buildVisibleElements(transformed); @@ -923,9 +947,50 @@ applyPhysicalEdgeDeconfliction(cy); applyEthernetLabelVisibility(showEthernetLabels); } + applySearchEmphasis(); tooltip.visible = false; } + function applySearchEmphasis() { + if (!cy) { + return; + } + + if (!searchQuery.trim()) { + cy.batch(() => { + cy?.elements().removeClass('dimmed search-match'); + }); + searchMatchCount = 0; + return; + } + + const matches = lastTransformResult + ? computeSearchMatches(lastTransformResult, searchQuery) + : new Set(); + let matchCount = 0; + cy.batch(() => { + for (const node of cy?.nodes().toArray() ?? []) { + const isMatch = matches.has(node.id()); + node.toggleClass('search-match', isMatch); + node.toggleClass('dimmed', !isMatch); + if (isMatch) { + matchCount += 1; + } + } + for (const edge of cy?.edges().toArray() ?? []) { + const touchesMatch = + matches.has(String(edge.data('source'))) || matches.has(String(edge.data('target'))); + edge.toggleClass('dimmed', !touchesMatch); + } + }); + searchMatchCount = matchCount; + } + + function clearSearch() { + searchQuery = ''; + applySearchEmphasis(); + } + function revalidateDraft() { const validation = validateNetworkData(networkData); validationIssues = validation.errors; @@ -1612,6 +1677,27 @@ {:else}
+
+ + {#if searchQuery.trim()} + {searchMatchCount} + + {/if} +
@@ -2540,6 +2626,55 @@ min-width: 13.5rem; } + .search-control { + display: inline-flex; + align-items: center; + gap: 0.3rem; + border: 1px solid var(--panel-border); + background: var(--panel-bg); + color: var(--panel-contrast); + padding: 0.2rem 0.35rem; + border-radius: 7px; + } + + .search-control input { + border: none; + background: transparent; + color: var(--panel-contrast); + font-size: 0.8rem; + font-weight: 600; + width: 10.5rem; + outline: none; + } + + .search-control input::placeholder { + color: color-mix(in oklab, var(--panel-contrast) 55%, transparent 45%); + font-weight: 500; + } + + .search-control:focus-within { + border-color: #60a5fa; + } + + .search-count { + font-size: 0.72rem; + font-weight: 700; + color: #f59e0b; + min-width: 1.1rem; + text-align: center; + } + + .search-clear { + border: none; + background: transparent; + color: var(--panel-contrast); + font-size: 0.95rem; + font-weight: 700; + line-height: 1; + padding: 0 0.15rem; + cursor: pointer; + } + .select-control { display: inline-flex; align-items: center; diff --git a/src/lib/graph/searchHighlight.ts b/src/lib/graph/searchHighlight.ts new file mode 100644 index 0000000..ac86ce6 --- /dev/null +++ b/src/lib/graph/searchHighlight.ts @@ -0,0 +1,53 @@ +import type { GraphNodeDetails, GraphNodeElement, GraphTransformResult } from './types'; + +function collectSearchableText(node: GraphNodeElement): string[] { + const texts: string[] = [node.data.rawName ?? node.data.label]; + const details = node.data.details as GraphNodeDetails | undefined; + if (!details) { + return texts; + } + + texts.push(details.ip); + if (details.type === 'machine') { + texts.push(details.role, details.os); + for (const vm of details.vms) { + texts.push(vm.name, vm.ip, vm.role); + } + } else if (details.type === 'device') { + texts.push(details.deviceType); + } else { + texts.push(details.role); + } + return texts; +} + +/** + * Returns the IDs of nodes matching the query (case-insensitive substring on + * name, IP, role, OS and device type). When a VM matches, its host machine ID + * is included too so hits remain visible while the host's VMs are collapsed. + */ +export function computeSearchMatches( + transformed: Pick, + query: string +): Set { + const matches = new Set(); + const normalized = query.trim().toLowerCase(); + if (!normalized) { + return matches; + } + + for (const node of transformed.nodes) { + const isMatch = collectSearchableText(node).some((text) => + text.toLowerCase().includes(normalized) + ); + if (!isMatch) { + continue; + } + matches.add(node.data.id); + if (node.data.kind === 'vm' && node.data.hostMachineId) { + matches.add(node.data.hostMachineId); + } + } + + return matches; +}