diff --git a/src/lib/components/NetworkDiagram.svelte b/src/lib/components/NetworkDiagram.svelte index e04fa1a..fd46bf5 100644 --- a/src/lib/components/NetworkDiagram.svelte +++ b/src/lib/components/NetworkDiagram.svelte @@ -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 = {}; let exportFileName = 'network-diagram'; @@ -137,6 +139,27 @@ }); $: visibleIconDefinitions = filteredIconDefinitions.slice(0, iconResultLimit); + $: vlanLegend = (() => { + const seen = new Set(); + 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(); + const searchMatches = + hasQuery && lastTransformResult + ? computeSearchMatches(lastTransformResult, searchQuery) + : null; + const passing = 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) { + 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()} {searchMatchCount} @@ -1840,6 +1882,23 @@ {/if} + {#if vlanLegend.length > 0} +
+ {#each vlanLegend as entry (entry.vlanId)} + + {/each} +
+ {/if} + {#if showIpamPanel}
@@ -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; diff --git a/src/lib/graph/transformNetworkData.ts b/src/lib/graph/transformNetworkData.ts index 50478ed..0108a26 100644 --- a/src/lib/graph/transformNetworkData.ts +++ b/src/lib/graph/transformNetworkData.ts @@ -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(); const seenWarnings = new Set(); + 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, diff --git a/src/lib/graph/types.ts b/src/lib/graph/types.ts index 06ac6d4..161e649 100644 --- a/src/lib/graph/types.ts +++ b/src/lib/graph/types.ts @@ -64,6 +64,8 @@ export interface GraphNodeData { nodeHeight?: number; iconUrl?: string; iconKey?: string; + vlanId?: number; + vlanColor?: string; details?: GraphNodeDetails; } diff --git a/src/lib/graph/vlanPalette.ts b/src/lib/graph/vlanPalette.ts new file mode 100644 index 0000000..688e4aa --- /dev/null +++ b/src/lib/graph/vlanPalette.ts @@ -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]; +} diff --git a/static/data/network.json b/static/data/network.json index a3af8ab..cd75de5 100644 --- a/static/data/network.json +++ b/static/data/network.json @@ -577,6 +577,11 @@ "cidr": "10.0.0.0/24", "name": "LAN", "vlanId": 1 + }, + { + "cidr": "10.0.20.0/24", + "name": "IoT", + "vlanId": 20 } ] }