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;
+20 -1
View File
@@ -1,5 +1,7 @@
import type { ConnectedPortRef, NetworkData, Port } from '../types';
import type { ConnectedPortRef, NetworkData, Port, Subnet } from '../types';
import { cidrContains, parseCidr } from '../data/ipam';
import { resolveIconPath } from '../config/iconRegistry';
import { vlanColor } from './vlanPalette';
import type { GraphEdgeElement, GraphNodeElement, GraphTransformResult } from './types';
interface EndpointOwner {
@@ -55,6 +57,19 @@ function connectionKey(a: string, b: string): string {
return a < b ? `${a}|${b}` : `${b}|${a}`;
}
function buildVlanResolver(subnets: Subnet[] | undefined) {
const vlanSubnets = (subnets ?? [])
.filter((subnet) => typeof subnet.vlanId === 'number')
.sort((a, b) => (parseCidr(b.cidr)?.prefix ?? 0) - (parseCidr(a.cidr)?.prefix ?? 0));
return (ip: string): { vlanId: number; vlanColor: string } | undefined => {
const home = vlanSubnets.find((subnet) => cidrContains(subnet.cidr, ip));
if (!home || typeof home.vlanId !== 'number') {
return undefined;
}
return { vlanId: home.vlanId, vlanColor: vlanColor(home.vlanId) };
};
}
function edgeSpeed(a: number | undefined, b: number | undefined): number | undefined {
if (typeof a === 'number' && typeof b === 'number') {
return Math.min(a, b);
@@ -82,6 +97,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
> = {};
const ownersByLookup = new Map<string, EndpointOwner>();
const seenWarnings = new Set<string>();
const resolveVlan = buildVlanResolver(data.subnets);
const warn = (message: string) => {
if (!seenWarnings.has(message)) {
@@ -124,6 +140,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
iconUrl: resolveIconPath(machine.iconKey),
nodeWidth: 200,
nodeHeight: 110,
...resolveVlan(machine.ipAddress),
details: {
type: 'machine',
name: machine.machineName,
@@ -162,6 +179,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
iconUrl: resolveIconPath(vm.iconKey),
nodeWidth: 140,
nodeHeight: 66,
...resolveVlan(vm.ipAddress),
details: {
type: 'vm',
name: vm.name,
@@ -217,6 +235,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
iconUrl: resolveIconPath(device.iconKey),
nodeWidth: 176,
nodeHeight: 116,
...resolveVlan(device.ipAddress),
details: {
type: 'device',
name: device.name,
+2
View File
@@ -64,6 +64,8 @@ export interface GraphNodeData {
nodeHeight?: number;
iconUrl?: string;
iconKey?: string;
vlanId?: number;
vlanColor?: string;
details?: GraphNodeDetails;
}
+19
View File
@@ -0,0 +1,19 @@
// Distinct from the kind colours (machine blue, VM green, device amber) so a
// VLAN border reads as VLAN, not as an entity kind. Mid-saturation hues stay
// legible against both the light and dark node fills.
const palette = [
'#e11d48',
'#7c3aed',
'#0891b2',
'#ca8a04',
'#15803d',
'#c2410c',
'#4f46e5',
'#0d9488',
'#be185d',
'#57534e'
];
export function vlanColor(vlanId: number): string {
return palette[Math.abs(Math.trunc(vlanId)) % palette.length];
}
+5
View File
@@ -577,6 +577,11 @@
"cidr": "10.0.0.0/24",
"name": "LAN",
"vlanId": 1
},
{
"cidr": "10.0.20.0/24",
"name": "IoT",
"vlanId": 20
}
]
}