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
+119 -14
View File
@@ -21,6 +21,7 @@
} from '$lib/data/networkEditor'; } from '$lib/data/networkEditor';
import loadNetworkData from '$lib/data/loadNetworkData'; import loadNetworkData from '$lib/data/loadNetworkData';
import { buildIpamReport, suggestNextFreeIp, type IpamReport } from '$lib/data/ipam'; import { buildIpamReport, suggestNextFreeIp, type IpamReport } from '$lib/data/ipam';
import { vlanColor } from '$lib/graph/vlanPalette';
import { validateNetworkData, type ValidationIssue } from '$lib/data/networkSchema'; import { validateNetworkData, type ValidationIssue } from '$lib/data/networkSchema';
import transformNetworkDataToGraph from '$lib/graph/transformNetworkData'; import transformNetworkDataToGraph from '$lib/graph/transformNetworkData';
import { computeSearchMatches } from '$lib/graph/searchHighlight'; import { computeSearchMatches } from '$lib/graph/searchHighlight';
@@ -84,6 +85,7 @@
let searchMatchCount = 0; let searchMatchCount = 0;
let lastTransformResult: GraphTransformResult | null = null; let lastTransformResult: GraphTransformResult | null = null;
let showIpamPanel = false; let showIpamPanel = false;
let activeVlanFilter: number | null = null;
let machineVmIndex: GraphTransformResult['machineVmIndex'] = {}; let machineVmIndex: GraphTransformResult['machineVmIndex'] = {};
let collapsedHosts: Record<string, boolean> = {}; let collapsedHosts: Record<string, boolean> = {};
let exportFileName = 'network-diagram'; let exportFileName = 'network-diagram';
@@ -137,6 +139,27 @@
}); });
$: visibleIconDefinitions = filteredIconDefinitions.slice(0, iconResultLimit); $: 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); $: ipamReport = buildIpamReport(networkData);
$: ipamWarnings = ipamReport.duplicates.map( $: ipamWarnings = ipamReport.duplicates.map(
(duplicate) => `Duplicate IP ${duplicate.ip}: assigned to ${duplicate.owners.join(' and ')}.` (duplicate) => `Duplicate IP ${duplicate.ip}: assigned to ${duplicate.owners.join(' and ')}.`
@@ -548,6 +571,13 @@
color: theme === 'dark' ? '#94a3b8' : '#64748b' color: theme === 'dark' ? '#94a3b8' : '#64748b'
} }
}, },
{
selector: 'node[vlanColor]',
style: {
'border-width': 4,
'border-color': 'data(vlanColor)'
}
},
{ {
selector: 'node.search-match', selector: 'node.search-match',
style: { style: {
@@ -976,16 +1006,18 @@
applyPhysicalEdgeDeconfliction(cy); applyPhysicalEdgeDeconfliction(cy);
applyEthernetLabelVisibility(showEthernetLabels); applyEthernetLabelVisibility(showEthernetLabels);
} }
applySearchEmphasis(); applyEmphasis();
tooltip.visible = false; tooltip.visible = false;
} }
function applySearchEmphasis() { function applyEmphasis() {
if (!cy) { if (!cy) {
return; return;
} }
if (!searchQuery.trim()) { const hasQuery = Boolean(searchQuery.trim());
const hasVlanFilter = activeVlanFilter !== null;
if (!hasQuery && !hasVlanFilter) {
cy.batch(() => { cy.batch(() => {
cy?.elements().removeClass('dimmed search-match'); cy?.elements().removeClass('dimmed search-match');
}); });
@@ -993,23 +1025,28 @@
return; return;
} }
const matches = lastTransformResult const searchMatches =
hasQuery && lastTransformResult
? computeSearchMatches(lastTransformResult, searchQuery) ? computeSearchMatches(lastTransformResult, searchQuery)
: new Set<string>(); : null;
const passing = new Set<string>();
let matchCount = 0; let matchCount = 0;
cy.batch(() => { cy.batch(() => {
for (const node of cy?.nodes().toArray() ?? []) { for (const node of cy?.nodes().toArray() ?? []) {
const isMatch = matches.has(node.id()); const matchesSearch = !searchMatches || searchMatches.has(node.id());
node.toggleClass('search-match', isMatch); const matchesVlan = !hasVlanFilter || node.data('vlanId') === activeVlanFilter;
node.toggleClass('dimmed', !isMatch); const passes = matchesSearch && matchesVlan;
if (isMatch) { node.toggleClass('search-match', Boolean(searchMatches) && passes);
node.toggleClass('dimmed', !passes);
if (passes) {
passing.add(node.id());
matchCount += 1; matchCount += 1;
} }
} }
for (const edge of cy?.edges().toArray() ?? []) { for (const edge of cy?.edges().toArray() ?? []) {
const touchesMatch = const touchesPassing =
matches.has(String(edge.data('source'))) || matches.has(String(edge.data('target'))); passing.has(String(edge.data('source'))) || passing.has(String(edge.data('target')));
edge.toggleClass('dimmed', !touchesMatch); edge.toggleClass('dimmed', !touchesPassing);
} }
}); });
searchMatchCount = matchCount; searchMatchCount = matchCount;
@@ -1017,7 +1054,12 @@
function clearSearch() { function clearSearch() {
searchQuery = ''; searchQuery = '';
applySearchEmphasis(); applyEmphasis();
}
function toggleVlanFilter(vlanId: number) {
activeVlanFilter = activeVlanFilter === vlanId ? null : vlanId;
applyEmphasis();
} }
function revalidateDraft() { function revalidateDraft() {
@@ -1751,7 +1793,7 @@
placeholder="Find name, IP, role…" placeholder="Find name, IP, role…"
aria-label="Search diagram nodes" aria-label="Search diagram nodes"
bind:value={searchQuery} bind:value={searchQuery}
on:input={applySearchEmphasis} on:input={applyEmphasis}
/> />
{#if searchQuery.trim()} {#if searchQuery.trim()}
<span class="search-count">{searchMatchCount}</span> <span class="search-count">{searchMatchCount}</span>
@@ -1840,6 +1882,23 @@
{/if} {/if}
</div> </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} {#if showIpamPanel}
<div class="ipam-panel"> <div class="ipam-panel">
<div class="ipam-panel-head"> <div class="ipam-panel-head">
@@ -2985,6 +3044,52 @@
background: color-mix(in oklab, var(--panel-bg) 82%, #3b82f6 18%); 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 { .ipam-panel {
position: absolute; position: absolute;
right: 0.75rem; 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 { resolveIconPath } from '../config/iconRegistry';
import { vlanColor } from './vlanPalette';
import type { GraphEdgeElement, GraphNodeElement, GraphTransformResult } from './types'; import type { GraphEdgeElement, GraphNodeElement, GraphTransformResult } from './types';
interface EndpointOwner { interface EndpointOwner {
@@ -55,6 +57,19 @@ function connectionKey(a: string, b: string): string {
return a < b ? `${a}|${b}` : `${b}|${a}`; 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 { function edgeSpeed(a: number | undefined, b: number | undefined): number | undefined {
if (typeof a === 'number' && typeof b === 'number') { if (typeof a === 'number' && typeof b === 'number') {
return Math.min(a, b); return Math.min(a, b);
@@ -82,6 +97,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
> = {}; > = {};
const ownersByLookup = new Map<string, EndpointOwner>(); const ownersByLookup = new Map<string, EndpointOwner>();
const seenWarnings = new Set<string>(); const seenWarnings = new Set<string>();
const resolveVlan = buildVlanResolver(data.subnets);
const warn = (message: string) => { const warn = (message: string) => {
if (!seenWarnings.has(message)) { if (!seenWarnings.has(message)) {
@@ -124,6 +140,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
iconUrl: resolveIconPath(machine.iconKey), iconUrl: resolveIconPath(machine.iconKey),
nodeWidth: 200, nodeWidth: 200,
nodeHeight: 110, nodeHeight: 110,
...resolveVlan(machine.ipAddress),
details: { details: {
type: 'machine', type: 'machine',
name: machine.machineName, name: machine.machineName,
@@ -162,6 +179,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
iconUrl: resolveIconPath(vm.iconKey), iconUrl: resolveIconPath(vm.iconKey),
nodeWidth: 140, nodeWidth: 140,
nodeHeight: 66, nodeHeight: 66,
...resolveVlan(vm.ipAddress),
details: { details: {
type: 'vm', type: 'vm',
name: vm.name, name: vm.name,
@@ -217,6 +235,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
iconUrl: resolveIconPath(device.iconKey), iconUrl: resolveIconPath(device.iconKey),
nodeWidth: 176, nodeWidth: 176,
nodeHeight: 116, nodeHeight: 116,
...resolveVlan(device.ipAddress),
details: { details: {
type: 'device', type: 'device',
name: device.name, name: device.name,
+2
View File
@@ -64,6 +64,8 @@ export interface GraphNodeData {
nodeHeight?: number; nodeHeight?: number;
iconUrl?: string; iconUrl?: string;
iconKey?: string; iconKey?: string;
vlanId?: number;
vlanColor?: string;
details?: GraphNodeDetails; 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", "cidr": "10.0.0.0/24",
"name": "LAN", "name": "LAN",
"vlanId": 1 "vlanId": 1
},
{
"cidr": "10.0.20.0/24",
"name": "IoT",
"vlanId": 20
} }
] ]
} }