mirror of
https://github.com/jcreek/OpenNetworkDiagram.git
synced 2026-07-14 03:23:44 +00:00
feat(*): Colour nodes by VLAN with a clickable legend filter
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user