feat(*): Colour nodes by VLAN with a clickable legend filter

This commit is contained in:
Josh Creek
2026-07-12 20:21:57 +01:00
parent acf63a9707
commit 2144eda352
5 changed files with 166 additions and 16 deletions
+120 -15
View File
@@ -21,6 +21,7 @@
} from '$lib/data/networkEditor';
import loadNetworkData from '$lib/data/loadNetworkData';
import { buildIpamReport, suggestNextFreeIp, type IpamReport } from '$lib/data/ipam';
import { vlanColor } from '$lib/graph/vlanPalette';
import { validateNetworkData, type ValidationIssue } from '$lib/data/networkSchema';
import transformNetworkDataToGraph from '$lib/graph/transformNetworkData';
import { computeSearchMatches } from '$lib/graph/searchHighlight';
@@ -84,6 +85,7 @@
let searchMatchCount = 0;
let lastTransformResult: GraphTransformResult | null = null;
let showIpamPanel = false;
let activeVlanFilter: number | null = null;
let machineVmIndex: GraphTransformResult['machineVmIndex'] = {};
let collapsedHosts: Record<string, boolean> = {};
let exportFileName = 'network-diagram';
@@ -137,6 +139,27 @@
});
$: visibleIconDefinitions = filteredIconDefinitions.slice(0, iconResultLimit);
$: vlanLegend = (() => {
const seen = new Set<number>();
const entries: Array<{ vlanId: number; label: string; color: string }> = [];
for (const subnet of networkData.subnets ?? []) {
if (typeof subnet.vlanId !== 'number' || seen.has(subnet.vlanId)) {
continue;
}
seen.add(subnet.vlanId);
entries.push({
vlanId: subnet.vlanId,
label: subnet.name ? `${subnet.name} (${subnet.cidr})` : subnet.cidr,
color: vlanColor(subnet.vlanId)
});
}
return entries.sort((a, b) => a.vlanId - b.vlanId);
})();
$: if (activeVlanFilter !== null && !vlanLegend.some((entry) => entry.vlanId === activeVlanFilter)) {
activeVlanFilter = null;
applyEmphasis();
}
$: ipamReport = buildIpamReport(networkData);
$: ipamWarnings = ipamReport.duplicates.map(
(duplicate) => `Duplicate IP ${duplicate.ip}: assigned to ${duplicate.owners.join(' and ')}.`
@@ -548,6 +571,13 @@
color: theme === 'dark' ? '#94a3b8' : '#64748b'
}
},
{
selector: 'node[vlanColor]',
style: {
'border-width': 4,
'border-color': 'data(vlanColor)'
}
},
{
selector: 'node.search-match',
style: {
@@ -976,16 +1006,18 @@
applyPhysicalEdgeDeconfliction(cy);
applyEthernetLabelVisibility(showEthernetLabels);
}
applySearchEmphasis();
applyEmphasis();
tooltip.visible = false;
}
function applySearchEmphasis() {
function applyEmphasis() {
if (!cy) {
return;
}
if (!searchQuery.trim()) {
const hasQuery = Boolean(searchQuery.trim());
const hasVlanFilter = activeVlanFilter !== null;
if (!hasQuery && !hasVlanFilter) {
cy.batch(() => {
cy?.elements().removeClass('dimmed search-match');
});
@@ -993,23 +1025,28 @@
return;
}
const matches = lastTransformResult
? computeSearchMatches(lastTransformResult, searchQuery)
: new Set<string>();
const searchMatches =
hasQuery && lastTransformResult
? computeSearchMatches(lastTransformResult, searchQuery)
: null;
const passing = new Set<string>();
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) {
const matchesSearch = !searchMatches || searchMatches.has(node.id());
const matchesVlan = !hasVlanFilter || node.data('vlanId') === activeVlanFilter;
const passes = matchesSearch && matchesVlan;
node.toggleClass('search-match', Boolean(searchMatches) && passes);
node.toggleClass('dimmed', !passes);
if (passes) {
passing.add(node.id());
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);
const touchesPassing =
passing.has(String(edge.data('source'))) || passing.has(String(edge.data('target')));
edge.toggleClass('dimmed', !touchesPassing);
}
});
searchMatchCount = matchCount;
@@ -1017,7 +1054,12 @@
function clearSearch() {
searchQuery = '';
applySearchEmphasis();
applyEmphasis();
}
function toggleVlanFilter(vlanId: number) {
activeVlanFilter = activeVlanFilter === vlanId ? null : vlanId;
applyEmphasis();
}
function revalidateDraft() {
@@ -1751,7 +1793,7 @@
placeholder="Find name, IP, role…"
aria-label="Search diagram nodes"
bind:value={searchQuery}
on:input={applySearchEmphasis}
on:input={applyEmphasis}
/>
{#if searchQuery.trim()}
<span class="search-count">{searchMatchCount}</span>
@@ -1840,6 +1882,23 @@
{/if}
</div>
{#if vlanLegend.length > 0}
<div class="vlan-legend" role="group" aria-label="VLAN legend">
{#each vlanLegend as entry (entry.vlanId)}
<button
type="button"
class="vlan-legend-item"
class:active={activeVlanFilter === entry.vlanId}
title={`Show only VLAN ${entry.vlanId}`}
on:click={() => toggleVlanFilter(entry.vlanId)}
>
<span class="vlan-swatch" style={`background: ${entry.color};`}></span>
VLAN {entry.vlanId}{entry.label}
</button>
{/each}
</div>
{/if}
{#if showIpamPanel}
<div class="ipam-panel">
<div class="ipam-panel-head">
@@ -2985,6 +3044,52 @@
background: color-mix(in oklab, var(--panel-bg) 82%, #3b82f6 18%);
}
.vlan-legend {
position: absolute;
left: 0.75rem;
bottom: 0.75rem;
z-index: 14;
display: flex;
flex-direction: column;
gap: 0.3rem;
background: color-mix(in oklab, var(--panel-bg) 93%, transparent 7%);
border: 1px solid var(--panel-border);
border-radius: 10px;
padding: 0.45rem 0.55rem;
max-width: min(70vw, 300px);
}
.vlan-legend-item {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 1px solid transparent;
background: transparent;
color: var(--panel-contrast);
font-size: 0.74rem;
font-weight: 600;
border-radius: 7px;
padding: 0.2rem 0.35rem;
cursor: pointer;
text-align: left;
}
.vlan-legend-item:hover {
border-color: var(--panel-border);
}
.vlan-legend-item.active {
border-color: #3b82f6;
background: color-mix(in oklab, var(--panel-bg) 82%, #3b82f6 18%);
}
.vlan-swatch {
width: 0.75rem;
height: 0.75rem;
border-radius: 3px;
flex-shrink: 0;
}
.ipam-panel {
position: absolute;
right: 0.75rem;