9 Commits

19 changed files with 2876 additions and 116 deletions
+63 -4
View File
@@ -22,8 +22,13 @@ Open Network Diagram helps you document your infrastructure in a visual UI while
- Network view with ethernet labels to make physical and logical links easy to read.
- Non-network view for host-first inventory and service mapping.
- Rack view: managed racks with U positions and heights, side-by-side shelf items, and click-through to the editor.
- Node search that highlights matches by name, IP, role or OS and dims everything else.
- IPAM panel: per-subnet utilization, duplicate-IP conflict detection, and next-free-IP suggestions when adding machines, devices or VMs.
- Subnet and VLAN declarations, with nodes colour-coded by VLAN and a clickable legend filter.
- Cable tracking on connections (type, colour, length) with colour-coded links and hover details.
- Expandable VM lists per machine for quick virtualization visibility.
- Modal editor for machines/devices with live diagram updates.
- Modal editor for machines/devices with live diagram updates, notes and MAC address fields.
- JSON-backed persistence with autosave in self-hosted mode.
- Docker-first deployment with writable data volume support.
- Optional read-only mode for public demos and safe sharing.
@@ -32,6 +37,8 @@ Open Network Diagram helps you document your infrastructure in a visual UI while
## Why Home Lab Users Use It
- Keep an always-up-to-date map of machines, VMs, and devices.
- Find a free IP and spot duplicate assignments without a spreadsheet.
- Know exactly what's in your rack and where, down to the U.
- Edit quickly through a modal UI instead of hand-editing large diagrams.
- Persist everything to JSON so backups and Git workflows stay simple.
- Stay fully self-hosted with no runtime dependency on external APIs.
@@ -78,6 +85,10 @@ docker rm open-network-diagram
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| ![Open Network Diagram network view with ethernet labels](screenshot1.png) | ![Open Network Diagram non-network view with VMs expanded](screenshot2.png) | ![Open Network Diagram modal editing a machine](screenshot3.png) |
| IPAM panel with subnet utilization | Rack view with shelf items |
| ------------------------------------------------------------------ | -------------------------------------------------------- |
| ![Open Network Diagram IPAM panel with subnet utilization](screenshot4.png) | ![Open Network Diagram rack view with shelf items](screenshot5.png) |
## Docker Compose Option
If you prefer compose:
@@ -151,25 +162,73 @@ Environment variables:
"ipAddress": "10.0.0.3",
"role": "Hypervisor",
"operatingSystem": "Proxmox",
"notes": "Primary router box.",
"software": {
"vms": [{ "name": "OpnSense", "role": "Router", "ipAddress": "10.0.0.4" }]
"vms": [
{
"name": "OpnSense",
"role": "Router",
"ipAddress": "10.0.0.4",
"macAddress": "bc:24:11:5a:2e:01"
}
]
},
"hardware": {
"cpu": "Intel N100",
"ram": "8GB",
"networkPorts": 4
}
},
"ports": [
{
"portName": "eth0",
"speedGbps": 1,
"connectedTo": {
"device": "Switch",
"port": "1",
"cable": { "type": "Cat6", "color": "blue", "lengthM": 1 }
}
}
],
"rack": { "name": "Lab Rack", "unit": 10 }
}
],
"devices": [{ "name": "Switch", "ipAddress": "10.0.0.2", "type": "Nintendo Switch" }]
"devices": [
{
"name": "Switch",
"ipAddress": "10.0.0.2",
"type": "Network Switch",
"ports": [
{
"portName": "1",
"speedGbps": 1,
"connectedTo": {
"device": "ProxRouter",
"port": "eth0",
"cable": { "type": "Cat6", "color": "blue", "lengthM": 1 }
}
}
],
"rack": { "name": "Lab Rack", "unit": 12 }
}
],
"subnets": [{ "cidr": "10.0.0.0/24", "name": "LAN", "vlanId": 1 }],
"racks": [{ "name": "Lab Rack", "heightU": 12 }]
}
```
Notes on the optional fields:
- `subnets` powers the IPAM panel and VLAN colouring (`vlanId` is optional per subnet).
- `racks` declares rack names and total units for the rack view; machines/devices opt in with a `rack` placement (`unit` is the bottom U, `heightU` defaults to 1). Items with the identical U range render side by side as shelf-mates.
- `cable` on a `connectedTo` records type/colour/length and is kept identical on both ends automatically.
- `notes` (machines and devices) and `macAddress` (ports and VMs) are free text.
### Project Structure
```text
OpenNetworkDiagram/
├── src/ # Svelte app source
├── src/lib/shared/ # Schema + persistence core (shared with server.mjs)
├── src/lib/config/vendorIconManifest.ts # Generated local icon catalog
├── static/data/network.json # Demo dataset (Netlify)
├── static/icons/vendor/ # Vendored icon assets (runtime-local)
+21 -1
View File
@@ -5,10 +5,12 @@
"ipAddress": "192.168.1.10",
"role": "Hypervisor",
"operatingSystem": "Linux",
"notes": "Optional notes",
"ports": [
{
"portName": "eth0",
"speedGbps": 1,
"macAddress": "aa:bb:cc:dd:ee:01",
"connectedTo": {
"device": "Example Switch",
"port": "1"
@@ -20,7 +22,8 @@
{
"name": "Example VM",
"role": "Service",
"ipAddress": "192.168.1.20"
"ipAddress": "192.168.1.20",
"macAddress": "aa:bb:cc:dd:ee:02"
}
]
},
@@ -39,6 +42,10 @@
"ipAddress": "192.168.1.2",
"type": "Network Switch",
"notes": "Optional notes",
"rack": {
"name": "Example Rack",
"unit": 12
},
"ports": [
{
"portName": "1",
@@ -50,5 +57,18 @@
}
]
}
],
"subnets": [
{
"cidr": "192.168.1.0/24",
"name": "LAN",
"vlanId": 1
}
],
"racks": [
{
"name": "Example Rack",
"heightU": 24
}
]
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 219 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

After

Width:  |  Height:  |  Size: 264 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 191 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 270 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

File diff suppressed because it is too large Load Diff
+222
View File
@@ -0,0 +1,222 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { resolveIconPath } from '$lib/config/iconRegistry';
import type { RackLayout, RackSlot } from '$lib/data/rackLayout';
export let layout: RackLayout;
const dispatch = createEventDispatcher<{
select: { kind: RackSlot['kind']; name: string };
}>();
const unitHeightPx = 34;
function slotStyle(slot: RackSlot, rackHeightU: number): string {
const top = (rackHeightU - (slot.unit + slot.heightU - 1)) * unitHeightPx;
const laneWidth = `(100% - 2.45rem) / ${slot.laneCount}`;
const left = `calc(2.1rem + (${laneWidth}) * ${slot.laneIndex})`;
const width = `calc(${laneWidth} - 4px)`;
return `top: ${top}px; height: ${slot.heightU * unitHeightPx - 4}px; left: ${left}; width: ${width};`;
}
</script>
<div class="rack-view">
{#if layout.racks.length === 0}
<div class="rack-empty">
<h3>No rack placements yet</h3>
<p>
Open a machine or device and fill in the Rack section (rack name + U position) to see it
here.
</p>
</div>
{:else}
<div class="rack-columns">
{#each layout.racks as rack (rack.name)}
<div class="rack-column">
<h3>{rack.name} <span class="rack-height-tag">{rack.heightU}U</span></h3>
<div class="rack-frame" style={`height: ${rack.heightU * unitHeightPx}px;`}>
{#each Array.from({ length: rack.heightU }, (_, i) => rack.heightU - i) as unit (unit)}
<div class="rack-unit-row" style={`height: ${unitHeightPx}px;`}>
<span class="rack-unit-label">{unit}</span>
</div>
{/each}
{#each rack.slots as slot (`${slot.kind}:${slot.name}`)}
<button
type="button"
class="rack-slot"
class:machine={slot.kind === 'machine'}
class:device={slot.kind === 'device'}
style={slotStyle(slot, rack.heightU)}
title={`${slot.name} — U${slot.unit}${slot.heightU > 1 ? `U${slot.unit + slot.heightU - 1}` : ''}`}
on:click={() => dispatch('select', { kind: slot.kind, name: slot.name })}
>
{#if resolveIconPath(slot.iconKey)}
<img src={resolveIconPath(slot.iconKey)} alt="" loading="lazy" />
{/if}
<span class="rack-slot-text">
<span class="rack-slot-name">{slot.name}</span>
<span class="rack-slot-subtitle">{slot.subtitle}</span>
</span>
<span class="rack-slot-units">{slot.heightU}U</span>
</button>
{/each}
</div>
</div>
{/each}
</div>
{#if layout.unrackedCount > 0}
<p class="rack-footnote">
{layout.unrackedCount}
{layout.unrackedCount === 1 ? 'entity is' : 'entities are'} not rack-mounted.
</p>
{/if}
{/if}
</div>
<style>
.rack-view {
position: absolute;
inset: 0;
overflow: auto;
padding: 10rem 1rem 1rem;
background: var(--app-bg, transparent);
}
.rack-empty {
max-width: 420px;
margin: 4rem auto 0;
text-align: center;
color: var(--muted-text);
}
.rack-empty h3 {
margin: 0 0 0.5rem;
color: var(--panel-contrast);
}
.rack-columns {
display: flex;
gap: 2rem;
align-items: flex-end;
justify-content: center;
flex-wrap: wrap;
}
.rack-column h3 {
margin: 0 0 0.5rem;
text-align: center;
font-size: 0.95rem;
color: var(--panel-contrast);
}
.rack-height-tag {
font-size: 0.7rem;
font-weight: 700;
color: var(--muted-text);
}
.rack-frame {
position: relative;
width: min(86vw, 340px);
border: 2px solid var(--panel-border);
border-radius: 8px;
background: color-mix(in oklab, var(--panel-bg) 88%, transparent 12%);
overflow: hidden;
}
.rack-unit-row {
display: flex;
align-items: center;
border-bottom: 1px dashed color-mix(in oklab, var(--panel-border) 55%, transparent 45%);
box-sizing: border-box;
}
.rack-unit-row:last-child {
border-bottom: none;
}
.rack-unit-label {
width: 1.9rem;
text-align: center;
font-size: 0.62rem;
font-weight: 700;
color: var(--muted-text);
}
.rack-slot {
position: absolute;
margin-top: 2px;
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid;
border-radius: 6px;
padding: 0.15rem 0.5rem;
cursor: pointer;
text-align: left;
overflow: hidden;
box-sizing: border-box;
}
.rack-slot.machine {
background: color-mix(in oklab, var(--panel-bg) 55%, #3b82f6 45%);
border-color: #3b82f6;
}
.rack-slot.device {
background: color-mix(in oklab, var(--panel-bg) 55%, #d97706 45%);
border-color: #d97706;
}
.rack-slot:hover {
filter: brightness(1.08);
}
.rack-slot img {
width: 1.4rem;
height: 1.4rem;
object-fit: contain;
flex-shrink: 0;
}
.rack-slot-text {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.rack-slot-name {
font-size: 0.78rem;
font-weight: 700;
color: var(--panel-contrast);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rack-slot-subtitle {
font-size: 0.66rem;
font-weight: 600;
color: var(--muted-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rack-slot-units {
font-size: 0.64rem;
font-weight: 700;
color: var(--muted-text);
flex-shrink: 0;
}
.rack-footnote {
text-align: center;
font-size: 0.76rem;
font-weight: 600;
color: var(--muted-text);
margin-top: 1rem;
}
</style>
+240
View File
@@ -0,0 +1,240 @@
import type { NetworkData, Subnet } from '../types';
export type IpOwnerKind = 'machine' | 'device' | 'vm';
export interface IpAssignment {
ip: string;
ownerLabel: string;
ownerKind: IpOwnerKind;
}
export interface SubnetReport {
cidr: string;
name?: string;
vlanId?: number;
declared: boolean;
used: number;
capacity: number;
members: IpAssignment[];
}
export interface IpamReport {
subnets: SubnetReport[];
duplicates: Array<{ ip: string; owners: string[] }>;
unparsed: IpAssignment[];
}
/** Parses a dotted-quad IPv4 address into a 32-bit number; null for anything else ("DHCP", empty, IPv6). */
export function parseIpv4(text: string): number | null {
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(text.trim());
if (!match) {
return null;
}
const octets = match.slice(1).map(Number);
if (octets.some((octet) => octet > 255)) {
return null;
}
return octets[0] * 2 ** 24 + octets[1] * 2 ** 16 + octets[2] * 256 + octets[3];
}
function formatIpv4(value: number): string {
return [
Math.floor(value / 2 ** 24) % 256,
Math.floor(value / 2 ** 16) % 256,
Math.floor(value / 256) % 256,
value % 256
].join('.');
}
export function parseCidr(cidr: string): { base: number; prefix: number } | null {
const slash = cidr.indexOf('/');
if (slash < 0) {
return null;
}
const base = parseIpv4(cidr.slice(0, slash));
const prefix = Number(cidr.slice(slash + 1));
if (base === null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) {
return null;
}
const hostBlock = 2 ** (32 - prefix);
return { base: Math.floor(base / hostBlock) * hostBlock, prefix };
}
export function cidrContains(cidr: string, ip: string): boolean {
const parsed = parseCidr(cidr);
const value = parseIpv4(ip);
if (!parsed || value === null) {
return false;
}
const hostBlock = 2 ** (32 - parsed.prefix);
return Math.floor(value / hostBlock) * hostBlock === parsed.base;
}
export function inferSlash24(ip: string): string | null {
const value = parseIpv4(ip);
if (value === null) {
return null;
}
return `${formatIpv4(Math.floor(value / 256) * 256)}/24`;
}
export function collectIpAssignments(data: NetworkData): IpAssignment[] {
const assignments: IpAssignment[] = [];
for (const machine of data.machines) {
assignments.push({
ip: machine.ipAddress,
ownerLabel: machine.machineName,
ownerKind: 'machine'
});
for (const vm of machine.software?.vms ?? []) {
assignments.push({
ip: vm.ipAddress,
ownerLabel: `${vm.name} (VM on ${machine.machineName})`,
ownerKind: 'vm'
});
}
}
for (const device of data.devices) {
assignments.push({ ip: device.ipAddress, ownerLabel: device.name, ownerKind: 'device' });
}
return assignments;
}
function hostCapacity(prefix: number): number {
if (prefix >= 31) {
return prefix === 31 ? 2 : 1;
}
return 2 ** (32 - prefix) - 2;
}
export function buildIpamReport(data: NetworkData): IpamReport {
const assignments = collectIpAssignments(data);
const declared = data.subnets ?? [];
const parsed: Array<IpAssignment & { value: number }> = [];
const unparsed: IpAssignment[] = [];
for (const assignment of assignments) {
const value = parseIpv4(assignment.ip);
if (value === null) {
unparsed.push(assignment);
} else {
parsed.push({ ...assignment, value });
}
}
const byIp = new Map<string, IpAssignment[]>();
for (const assignment of parsed) {
const normalizedIp = formatIpv4(assignment.value);
const owners = byIp.get(normalizedIp) ?? [];
owners.push(assignment);
byIp.set(normalizedIp, owners);
}
const duplicates = [...byIp.entries()]
.filter(([, owners]) => owners.length > 1)
.map(([ip, owners]) => ({ ip, owners: owners.map((owner) => owner.ownerLabel) }))
.sort((a, b) => (parseIpv4(a.ip) ?? 0) - (parseIpv4(b.ip) ?? 0));
const subnets: SubnetReport[] = [];
const claimed = new Set<IpAssignment>();
const declaredSorted = [...declared].sort(
(a, b) => (parseCidr(b.cidr)?.prefix ?? 0) - (parseCidr(a.cidr)?.prefix ?? 0)
);
const reportBySubnet = new Map<Subnet, IpAssignment[]>();
for (const subnet of declaredSorted) {
reportBySubnet.set(subnet, []);
}
for (const assignment of parsed) {
// most-specific declared subnet wins; each IP is counted once
const home = declaredSorted.find((subnet) => cidrContains(subnet.cidr, assignment.ip));
if (home) {
reportBySubnet.get(home)?.push(assignment);
claimed.add(assignment);
}
}
for (const subnet of declared) {
const members = (reportBySubnet.get(subnet) ?? []).sort(
(a, b) => (parseIpv4(a.ip) ?? 0) - (parseIpv4(b.ip) ?? 0)
);
const prefix = parseCidr(subnet.cidr)?.prefix ?? 24;
subnets.push({
cidr: subnet.cidr,
name: subnet.name,
vlanId: subnet.vlanId,
declared: true,
used: new Set(members.map((member) => member.ip)).size,
capacity: hostCapacity(prefix),
members
});
}
const inferredGroups = new Map<string, IpAssignment[]>();
for (const assignment of parsed) {
if (claimed.has(assignment)) {
continue;
}
const inferred = inferSlash24(assignment.ip);
if (!inferred) {
continue;
}
const members = inferredGroups.get(inferred) ?? [];
members.push(assignment);
inferredGroups.set(inferred, members);
}
const inferredSorted = [...inferredGroups.entries()].sort(
(a, b) => (parseCidr(a[0])?.base ?? 0) - (parseCidr(b[0])?.base ?? 0)
);
for (const [cidr, members] of inferredSorted) {
members.sort((a, b) => (parseIpv4(a.ip) ?? 0) - (parseIpv4(b.ip) ?? 0));
subnets.push({
cidr,
declared: false,
used: new Set(members.map((member) => member.ip)).size,
capacity: hostCapacity(24),
members
});
}
return { subnets, duplicates, unparsed };
}
/**
* Lowest unused host address in the subnet, skipping the network address,
* broadcast address and .1 (conventionally the gateway). Defaults to the
* most-populated subnet when no CIDR is given.
*/
export function suggestNextFreeIp(data: NetworkData, cidr?: string): string | null {
const report = buildIpamReport(data);
const target = cidr
? (report.subnets.find((subnet) => subnet.cidr === cidr) ??
(parseCidr(cidr) ? { cidr, members: [] as IpAssignment[] } : undefined))
: [...report.subnets].sort((a, b) => b.used - a.used)[0];
if (!target) {
return null;
}
const parsed = parseCidr(target.cidr);
if (!parsed || parsed.prefix > 30) {
return null;
}
const used = new Set<number>();
for (const assignment of collectIpAssignments(data)) {
const value = parseIpv4(assignment.ip);
if (value !== null) {
used.add(value);
}
}
const size = 2 ** (32 - parsed.prefix);
const first = parsed.base + 1;
const last = parsed.base + size - 2;
for (let candidate = first; candidate <= last; candidate += 1) {
if (candidate % 256 === 1) {
continue;
}
if (!used.has(candidate)) {
return formatIpv4(candidate);
}
}
return null;
}
+94 -3
View File
@@ -1,4 +1,4 @@
import type { Machine, NetworkData, NetworkDevice, Port, VM } from '../types';
import type { CableInfo, Machine, NetworkData, NetworkDevice, Port, VM } from '../types';
export type OwnerKind = 'machine' | 'device';
@@ -119,9 +119,10 @@ function ensureReciprocalConnections(data: NetworkData) {
continue;
}
const expectedReciprocal = {
const expectedReciprocal: Port['connectedTo'] = {
device: owner.name,
port: port.portName
port: port.portName,
...(port.connectedTo.cable ? { cable: deepClone(port.connectedTo.cable) } : {})
};
if (!targetPort.connectedTo) {
targetPort.connectedTo = expectedReciprocal;
@@ -132,6 +133,19 @@ function ensureReciprocalConnections(data: NetworkData) {
equalsIgnoreCase(targetPort.connectedTo.device, owner.name) &&
equalsIgnoreCase(targetPort.connectedTo.port, port.portName);
if (hasMatchingReciprocal) {
// keep cable metadata identical on both ends; the lexicographically
// smaller owner:port key is the canonical copy so the sync is deterministic
const ownKey = `${owner.name}:${port.portName}`.toLowerCase();
const targetKey = `${targetOwner.name}:${targetPort.portName}`.toLowerCase();
const [from, to] =
ownKey <= targetKey
? [port.connectedTo, targetPort.connectedTo]
: [targetPort.connectedTo, port.connectedTo];
if (from.cable) {
to.cable = deepClone(from.cable);
} else {
delete to.cable;
}
continue;
}
}
@@ -330,6 +344,83 @@ export function deletePort(
return cloned;
}
export function renameRack(data: NetworkData, previousName: string, nextName: string): NetworkData {
const cloned = cloneNetworkData(data);
const trimmed = nextName.trim();
if (!trimmed || equalsIgnoreCase(previousName, trimmed)) {
return cloned;
}
for (const machine of cloned.machines) {
if (machine.rack && equalsIgnoreCase(machine.rack.name, previousName)) {
machine.rack.name = trimmed;
}
}
for (const device of cloned.devices) {
if (device.rack && equalsIgnoreCase(device.rack.name, previousName)) {
device.rack.name = trimmed;
}
}
const definition = cloned.racks?.find((rack) => equalsIgnoreCase(rack.name, previousName));
if (definition) {
definition.name = trimmed;
}
// renaming onto an existing rack merges them; keep the first definition
if (cloned.racks) {
const seen = new Set<string>();
cloned.racks = cloned.racks.filter((rack) => {
const key = rack.name.toLowerCase();
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
return cloned;
}
export function setPortCable(
data: NetworkData,
kind: OwnerKind,
ownerName: string,
portName: string,
cable: CableInfo | undefined
): NetworkData {
const cloned = cloneNetworkData(data);
const owner = findOwnerByKindAndName(cloned, kind, ownerName);
const port = owner ? findPort(owner, portName) : undefined;
if (!owner || !port?.connectedTo) {
return cloned;
}
const entries = Object.entries(cable ?? {}).filter(([, value]) =>
typeof value === 'number' ? Number.isFinite(value) : Boolean(value)
);
const normalized = entries.length > 0 ? (Object.fromEntries(entries) as CableInfo) : undefined;
if (normalized) {
port.connectedTo.cable = deepClone(normalized);
} else {
delete port.connectedTo.cable;
}
const peerOwner = findOwnerByName(cloned, port.connectedTo.device);
const peerPort = peerOwner ? findPort(peerOwner, port.connectedTo.port) : undefined;
if (
peerPort?.connectedTo &&
equalsIgnoreCase(peerPort.connectedTo.device, owner.name) &&
equalsIgnoreCase(peerPort.connectedTo.port, port.portName)
) {
if (normalized) {
peerPort.connectedTo.cable = deepClone(normalized);
} else {
delete peerPort.connectedTo.cable;
}
}
return cloned;
}
export function setPortConnection(
data: NetworkData,
kind: OwnerKind,
+240
View File
@@ -0,0 +1,240 @@
import type { NetworkData } from '../types';
export interface RackSlot {
kind: 'machine' | 'device';
name: string;
index: number;
unit: number;
heightU: number;
iconKey?: string;
subtitle: string;
/** Horizontal lane when several items share the same U (e.g. on a shelf). */
laneIndex: number;
laneCount: number;
}
export interface RackColumn {
name: string;
heightU: number;
declared: boolean;
declaredHeightU?: number;
slots: RackSlot[];
}
export interface RackLayout {
racks: RackColumn[];
warnings: string[];
unrackedCount: number;
}
const minimumRackHeightU = 12;
function slotsOverlap(a: RackSlot, b: RackSlot): boolean {
const aTop = a.unit + a.heightU - 1;
const bTop = b.unit + b.heightU - 1;
return a.unit <= bTop && b.unit <= aTop;
}
function sameRange(a: RackSlot, b: RackSlot): boolean {
return a.unit === b.unit && a.heightU === b.heightU;
}
/**
* Assigns horizontal lanes so overlapping items render side by side.
* Items occupying the exact same U range are treated as sharing a shelf and
* do not warn; partially overlapping ranges are reported as conflicts.
*/
function assignLanes(slots: RackSlot[], rackName: string, warnings: string[]) {
// connected components of the overlap graph
const componentOf = new Map<RackSlot, number>();
let componentCount = 0;
for (const slot of slots) {
const touching = slots.filter((other) => componentOf.has(other) && slotsOverlap(slot, other));
const componentIds = new Set(touching.map((other) => componentOf.get(other) ?? 0));
if (componentIds.size === 0) {
componentOf.set(slot, componentCount);
componentCount += 1;
continue;
}
const [target] = [...componentIds].sort((a, b) => a - b);
componentOf.set(slot, target);
for (const [other, id] of componentOf.entries()) {
if (componentIds.has(id)) {
componentOf.set(other, target);
}
}
}
const componentSlots = new Map<number, RackSlot[]>();
for (const slot of slots) {
const id = componentOf.get(slot) ?? 0;
const members = componentSlots.get(id) ?? [];
members.push(slot);
componentSlots.set(id, members);
}
for (const members of componentSlots.values()) {
const ordered = [...members].sort((a, b) => a.unit - b.unit || a.name.localeCompare(b.name));
const lanes: RackSlot[][] = [];
for (const slot of ordered) {
let placed = false;
for (const [laneIndex, lane] of lanes.entries()) {
if (!lane.some((other) => slotsOverlap(slot, other))) {
lane.push(slot);
slot.laneIndex = laneIndex;
placed = true;
break;
}
}
if (!placed) {
slot.laneIndex = lanes.length;
lanes.push([slot]);
}
}
for (const slot of members) {
slot.laneCount = lanes.length;
}
for (let i = 0; i < ordered.length; i += 1) {
for (let j = i + 1; j < ordered.length; j += 1) {
const a = ordered[i];
const b = ordered[j];
if (slotsOverlap(a, b) && !sameRange(a, b)) {
warnings.push(
`Rack "${rackName}": "${a.name}" (U${a.unit}U${a.unit + a.heightU - 1}) partially overlaps "${b.name}" (U${b.unit}U${b.unit + b.heightU - 1}).`
);
}
}
}
}
}
export function buildRackLayout(data: NetworkData): RackLayout {
const slotsByRack = new Map<string, RackSlot[]>();
const canonicalNames = new Map<string, string>();
let unrackedCount = 0;
const canonical = (rawName: string): string => {
const trimmed = rawName.trim();
const key = trimmed.toLowerCase();
const existing = canonicalNames.get(key);
if (existing) {
return existing;
}
canonicalNames.set(key, trimmed);
return trimmed;
};
for (const definition of data.racks ?? []) {
const name = canonical(definition.name);
if (!slotsByRack.has(name)) {
slotsByRack.set(name, []);
}
}
const place = (
slot: Omit<RackSlot, 'heightU' | 'laneIndex' | 'laneCount'> & { heightU?: number },
rackName: string
) => {
const name = canonical(rackName);
const slots = slotsByRack.get(name) ?? [];
slots.push({
...slot,
heightU: Math.max(1, Math.round(slot.heightU ?? 1)),
laneIndex: 0,
laneCount: 1
});
slotsByRack.set(name, slots);
};
for (const [index, machine] of data.machines.entries()) {
if (!machine.rack?.name) {
unrackedCount += 1;
continue;
}
place(
{
kind: 'machine',
name: machine.machineName,
index,
unit: machine.rack.unit,
heightU: machine.rack.heightU,
iconKey: machine.iconKey,
subtitle: machine.role
},
machine.rack.name
);
}
for (const [index, device] of data.devices.entries()) {
if (!device.rack?.name) {
unrackedCount += 1;
continue;
}
place(
{
kind: 'device',
name: device.name,
index,
unit: device.rack.unit,
heightU: device.rack.heightU,
iconKey: device.iconKey,
subtitle: device.type
},
device.rack.name
);
}
const declaredByName = new Map(
(data.racks ?? []).map((definition) => [definition.name.trim().toLowerCase(), definition])
);
const warnings: string[] = [];
const racks: RackColumn[] = [...slotsByRack.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, slots]) => {
const sorted = [...slots].sort((a, b) => b.unit - a.unit || a.name.localeCompare(b.name));
assignLanes(sorted, name, warnings);
const topUnit =
sorted.length > 0 ? Math.max(...sorted.map((slot) => slot.unit + slot.heightU - 1)) : 0;
const definition = declaredByName.get(name.toLowerCase());
const declaredHeightU = definition?.heightU;
if (typeof declaredHeightU === 'number' && topUnit > declaredHeightU) {
warnings.push(
`Rack "${name}" is declared as ${declaredHeightU}U but items reach U${topUnit}.`
);
}
const heightU =
typeof declaredHeightU === 'number'
? Math.max(declaredHeightU, topUnit)
: Math.max(minimumRackHeightU, topUnit);
return {
name,
heightU,
declared: Boolean(definition),
declaredHeightU,
slots: sorted
};
});
return { racks, warnings, unrackedCount };
}
export function knownRackNames(data: NetworkData): string[] {
const names = new Map<string, string>();
const add = (name: string | undefined) => {
const trimmed = name?.trim();
if (trimmed && !names.has(trimmed.toLowerCase())) {
names.set(trimmed.toLowerCase(), trimmed);
}
};
for (const definition of data.racks ?? []) {
add(definition.name);
}
for (const machine of data.machines) {
add(machine.rack?.name);
}
for (const device of data.devices) {
add(device.rack?.name);
}
return [...names.values()].sort((a, b) => a.localeCompare(b));
}
+53
View File
@@ -0,0 +1,53 @@
import type { GraphNodeDetails, GraphNodeElement, GraphTransformResult } from './types';
function collectSearchableText(node: GraphNodeElement): string[] {
const texts: string[] = [node.data.rawName ?? node.data.label];
const details = node.data.details as GraphNodeDetails | undefined;
if (!details) {
return texts;
}
texts.push(details.ip);
if (details.type === 'machine') {
texts.push(details.role, details.os);
for (const vm of details.vms) {
texts.push(vm.name, vm.ip, vm.role);
}
} else if (details.type === 'device') {
texts.push(details.deviceType);
} else {
texts.push(details.role);
}
return texts;
}
/**
* Returns the IDs of nodes matching the query (case-insensitive substring on
* name, IP, role, OS and device type). When a VM matches, its host machine ID
* is included too so hits remain visible while the host's VMs are collapsed.
*/
export function computeSearchMatches(
transformed: Pick<GraphTransformResult, 'nodes'>,
query: string
): Set<string> {
const matches = new Set<string>();
const normalized = query.trim().toLowerCase();
if (!normalized) {
return matches;
}
for (const node of transformed.nodes) {
const isMatch = collectSearchableText(node).some((text) =>
text.toLowerCase().includes(normalized)
);
if (!isMatch) {
continue;
}
matches.add(node.data.id);
if (node.data.kind === 'vm' && node.data.hostMachineId) {
matches.add(node.data.hostMachineId);
}
}
return matches;
}
+52 -3
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,41 @@ function connectionKey(a: string, b: string): string {
return a < b ? `${a}|${b}` : `${b}|${a}`;
}
const cableColorHex: Record<string, string> = {
blue: '#3b82f6',
red: '#ef4444',
green: '#22c55e',
yellow: '#eab308',
orange: '#f97316',
purple: '#a855f7',
pink: '#ec4899',
white: '#e2e8f0',
grey: '#94a3b8',
gray: '#94a3b8',
black: '#334155'
};
function resolveCableColor(name: string | undefined): string | undefined {
if (!name) {
return undefined;
}
const trimmed = name.trim();
return cableColorHex[trimmed.toLowerCase()] ?? (/^#[0-9a-fA-F]{3,8}$/.test(trimmed) ? trimmed : undefined);
}
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 +119,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)) {
@@ -110,7 +148,8 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
const machineVms = sourceMachineVms.map((vm) => ({
name: vm.name,
ip: vm.ipAddress,
role: vm.role
role: vm.role,
macAddress: vm.macAddress
}));
nodes.push({
@@ -123,6 +162,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,
@@ -133,6 +173,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
cpu: machine.hardware?.cpu ?? 'Unknown',
ram: machine.hardware?.ram ?? 'Unknown',
gpu: machine.hardware?.gpu ?? undefined,
notes: machine.notes,
ports: machinePortsList,
vmCount: machineVms.length,
vms: machineVms
@@ -160,12 +201,14 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
iconUrl: resolveIconPath(vm.iconKey),
nodeWidth: 140,
nodeHeight: 66,
...resolveVlan(vm.ipAddress),
details: {
type: 'vm',
name: vm.name,
iconKey: vm.iconKey,
ip: vm.ipAddress,
role: vm.role,
macAddress: vm.macAddress,
hostName: machine.machineName
}
}
@@ -214,6 +257,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,
@@ -293,6 +337,7 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
physicalEdgeCounter += 1;
const speedGbps = edgeSpeed(port.speedGbps, targetPort.speedGbps);
const cable = port.connectedTo?.cable ?? targetPort.connectedTo?.cable;
edges.push({
data: {
@@ -304,7 +349,11 @@ export default function transformNetworkDataToGraph(data: NetworkData): GraphTra
sourcePort: port.portName,
targetPort: connectedTo.port,
speedGbps,
reciprocal
reciprocal,
...(cable?.type ? { cableType: cable.type } : {}),
...(cable?.color ? { cableColorName: cable.color } : {}),
...(resolveCableColor(cable?.color) ? { cableColor: resolveCableColor(cable?.color) } : {}),
...(typeof cable?.lengthM === 'number' ? { cableLengthM: cable.lengthM } : {})
}
});
}
+15
View File
@@ -4,9 +4,15 @@ export type GraphEdgeKind = 'physical' | 'hosting';
export interface PortDetails {
portName: string;
speedGbps?: number;
macAddress?: string;
connectedTo?: {
device: string;
port: string;
cable?: {
type?: string;
color?: string;
lengthM?: number;
};
};
}
@@ -20,12 +26,14 @@ export interface MachineDetails {
cpu: string;
ram: string;
gpu?: string;
notes?: string;
ports: PortDetails[];
vmCount: number;
vms: Array<{
name: string;
ip: string;
role: string;
macAddress?: string;
}>;
}
@@ -45,6 +53,7 @@ export interface VmDetails {
iconKey?: string;
ip: string;
role: string;
macAddress?: string;
hostName: string;
}
@@ -60,6 +69,8 @@ export interface GraphNodeData {
nodeHeight?: number;
iconUrl?: string;
iconKey?: string;
vlanId?: number;
vlanColor?: string;
details?: GraphNodeDetails;
}
@@ -73,6 +84,10 @@ export interface GraphEdgeData {
targetPort?: string;
speedGbps?: number;
reciprocal?: boolean;
cableType?: string;
cableColorName?: string;
cableColor?: string;
cableLengthM?: number;
}
export interface GraphNodeElement {
+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];
}
+212 -9
View File
@@ -1,12 +1,16 @@
/**
* @typedef {{ path: string; message: string }} ValidationIssue
* @typedef {{ device: string; port: string }} ConnectedTo
* @typedef {{ portName: string; speedGbps?: number; connectedTo?: ConnectedTo }} Port
* @typedef {{ name: string; role: string; ipAddress: string; iconKey?: string }} VM
* @typedef {{ type?: string; color?: string; lengthM?: number }} CableInfo
* @typedef {{ device: string; port: string; cable?: CableInfo }} ConnectedTo
* @typedef {{ portName: string; speedGbps?: number; macAddress?: string; connectedTo?: ConnectedTo }} Port
* @typedef {{ name: string; role: string; ipAddress: string; iconKey?: string; macAddress?: string }} VM
* @typedef {{ cpu: string; ram: string; networkPorts: number; networkPortSpeedGbps?: number; gpu?: string }} Hardware
* @typedef {{ machineName: string; ipAddress: string; role: string; operatingSystem: string; iconKey?: string; software: { vms: VM[] }; hardware: Hardware; ports?: Port[] }} Machine
* @typedef {{ name: string; ipAddress: string; type: string; iconKey?: string; notes?: string; ports?: Port[] }} NetworkDevice
* @typedef {{ machines: Machine[]; devices: NetworkDevice[] }} NetworkData
* @typedef {{ name: string; unit: number; heightU?: number }} RackPlacement
* @typedef {{ machineName: string; ipAddress: string; role: string; operatingSystem: string; iconKey?: string; notes?: string; software: { vms: VM[] }; hardware: Hardware; ports?: Port[]; rack?: RackPlacement }} Machine
* @typedef {{ name: string; ipAddress: string; type: string; iconKey?: string; notes?: string; ports?: Port[]; rack?: RackPlacement }} NetworkDevice
* @typedef {{ cidr: string; name?: string; vlanId?: number }} Subnet
* @typedef {{ name: string; heightU?: number }} RackDefinition
* @typedef {{ machines: Machine[]; devices: NetworkDevice[]; subnets?: Subnet[]; racks?: RackDefinition[] }} NetworkData
* @typedef {{ valid: boolean; errors: ValidationIssue[]; data?: NetworkData }} ValidationResult
* @typedef {{ allowEmpty?: boolean; optional?: boolean }} ReadStringOptions
* @typedef {{ optional?: boolean; min?: number }} ReadNumberOptions
@@ -105,6 +109,10 @@ export function normalizePort(issues, path, value, ownerLabel) {
optional: true,
min: 0
});
const macAddress = readString(issues, `${path}.macAddress`, value.macAddress, {
optional: true,
allowEmpty: true
});
let connectedTo;
if (value.connectedTo !== undefined) {
@@ -113,8 +121,36 @@ export function normalizePort(issues, path, value, ownerLabel) {
} else {
const device = readString(issues, `${path}.connectedTo.device`, value.connectedTo.device);
const port = readString(issues, `${path}.connectedTo.port`, value.connectedTo.port);
let cable;
const cableRaw = value.connectedTo.cable;
if (cableRaw !== undefined) {
if (!isRecord(cableRaw)) {
issues.push({ path: `${path}.connectedTo.cable`, message: 'must be an object' });
} else {
const cableType = readString(issues, `${path}.connectedTo.cable.type`, cableRaw.type, {
optional: true,
allowEmpty: true
});
const cableColor = readString(issues, `${path}.connectedTo.cable.color`, cableRaw.color, {
optional: true,
allowEmpty: true
});
const lengthM = readNumber(issues, `${path}.connectedTo.cable.lengthM`, cableRaw.lengthM, {
optional: true,
min: 0
});
const candidate = {
...(cableType ? { type: cableType } : {}),
...(cableColor ? { color: cableColor } : {}),
...(typeof lengthM === 'number' ? { lengthM } : {})
};
if (Object.keys(candidate).length > 0) {
cable = candidate;
}
}
}
if (device && port) {
connectedTo = { device, port };
connectedTo = { device, port, ...(cable ? { cable } : {}) };
}
}
}
@@ -126,10 +162,38 @@ export function normalizePort(issues, path, value, ownerLabel) {
return {
portName,
...(typeof speedGbps === 'number' ? { speedGbps } : {}),
...(macAddress ? { macAddress } : {}),
...(connectedTo ? { connectedTo } : {})
};
}
/**
* @param {ValidationIssue[]} issues
* @param {string} path
* @param {unknown} value
* @returns {RackPlacement | undefined}
*/
function normalizeRack(issues, path, value) {
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
issues.push({ path, message: 'must be an object (rack placement)' });
return undefined;
}
const name = readString(issues, `${path}.name`, value.name);
const unit = readNumber(issues, `${path}.unit`, value.unit, { min: 1 });
const heightU = readNumber(issues, `${path}.heightU`, value.heightU, { optional: true, min: 1 });
if (!name || typeof unit !== 'number') {
return undefined;
}
return {
name,
unit,
...(typeof heightU === 'number' ? { heightU } : {})
};
}
/**
* @param {ValidationIssue[]} issues
* @param {PortEntry[]} portEntries
@@ -166,6 +230,10 @@ export function normalizeVm(issues, path, value) {
const role = readString(issues, `${path}.role`, value.role);
const ipAddress = readString(issues, `${path}.ipAddress`, value.ipAddress);
const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
const macAddress = readString(issues, `${path}.macAddress`, value.macAddress, {
optional: true,
allowEmpty: true
});
if (!name || !role || !ipAddress) {
return null;
@@ -175,7 +243,8 @@ export function normalizeVm(issues, path, value) {
name,
role,
ipAddress,
...(iconKey ? { iconKey } : {})
...(iconKey ? { iconKey } : {}),
...(macAddress ? { macAddress } : {})
};
}
@@ -196,6 +265,8 @@ function normalizeMachine(issues, path, value) {
const role = readString(issues, `${path}.role`, value.role);
const operatingSystem = readString(issues, `${path}.operatingSystem`, value.operatingSystem);
const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
const notes = readString(issues, `${path}.notes`, value.notes, { optional: true, allowEmpty: true });
const rack = normalizeRack(issues, `${path}.rack`, value.rack);
const softwareRaw = value.software;
if (!isRecord(softwareRaw)) {
@@ -287,6 +358,8 @@ function normalizeMachine(issues, path, value) {
role,
operatingSystem,
...(iconKey ? { iconKey } : {}),
...(notes !== undefined ? { notes } : {}),
...(rack ? { rack } : {}),
software: { vms },
hardware: {
cpu: hardware.cpu,
@@ -318,6 +391,7 @@ function normalizeDevice(issues, path, value) {
const type = readString(issues, `${path}.type`, value.type);
const notes = readString(issues, `${path}.notes`, value.notes, { optional: true, allowEmpty: true });
const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
const rack = normalizeRack(issues, `${path}.rack`, value.rack);
const portsRaw = value.ports;
if (portsRaw !== undefined && !Array.isArray(portsRaw)) {
@@ -345,10 +419,84 @@ function normalizeDevice(issues, path, value) {
type,
...(iconKey ? { iconKey } : {}),
...(notes !== undefined ? { notes } : {}),
...(rack ? { rack } : {}),
ports
};
}
const ipv4CidrPattern = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/;
/**
* @param {string} cidr
* @returns {boolean}
*/
export function isValidIpv4Cidr(cidr) {
const match = ipv4CidrPattern.exec(cidr);
if (!match) {
return false;
}
const octets = match.slice(1, 5).map(Number);
const prefix = Number(match[5]);
return octets.every((octet) => octet <= 255) && prefix <= 32;
}
/**
* @param {ValidationIssue[]} issues
* @param {string} path
* @param {unknown} value
* @returns {Subnet | null}
*/
function normalizeSubnet(issues, path, value) {
if (!isRecord(value)) {
issues.push({ path, message: 'must be an object (subnet)' });
return null;
}
const cidr = readString(issues, `${path}.cidr`, value.cidr);
if (cidr && !isValidIpv4Cidr(cidr)) {
issues.push({ path: `${path}.cidr`, message: 'must be an IPv4 CIDR like "192.168.1.0/24"' });
return null;
}
const name = readString(issues, `${path}.name`, value.name, { optional: true, allowEmpty: true });
const vlanId = readNumber(issues, `${path}.vlanId`, value.vlanId, { optional: true, min: 1 });
if (typeof vlanId === 'number' && vlanId > 4094) {
issues.push({ path: `${path}.vlanId`, message: 'must be <= 4094' });
return null;
}
if (!cidr) {
return null;
}
return {
cidr,
...(name ? { name } : {}),
...(typeof vlanId === 'number' ? { vlanId } : {})
};
}
/**
* @param {ValidationIssue[]} issues
* @param {string} path
* @param {unknown} value
* @returns {RackDefinition | null}
*/
function normalizeRackDefinition(issues, path, value) {
if (!isRecord(value)) {
issues.push({ path, message: 'must be an object (rack)' });
return null;
}
const name = readString(issues, `${path}.name`, value.name);
const heightU = readNumber(issues, `${path}.heightU`, value.heightU, { optional: true, min: 1 });
if (!name) {
return null;
}
return {
name,
...(typeof heightU === 'number' ? { heightU } : {})
};
}
/**
* @param {unknown} value
* @returns {ValidationResult}
@@ -426,9 +574,64 @@ export function validateNetworkData(value) {
}
}
const subnetsRaw = value.subnets;
const subnetsProvided = subnetsRaw !== undefined;
if (subnetsProvided && !Array.isArray(subnetsRaw)) {
issues.push({ path: '$.subnets', message: 'must be an array when provided' });
}
const subnets = [];
const subnetEntries = [];
for (const [subnetIndex, subnetValue] of (Array.isArray(subnetsRaw) ? subnetsRaw : []).entries()) {
const subnet = normalizeSubnet(issues, `$.subnets[${subnetIndex}]`, subnetValue);
if (subnet) {
subnets.push(subnet);
subnetEntries.push({ subnet, originalIndex: subnetIndex });
}
}
const seenCidrs = new Set();
for (const { subnet, originalIndex } of subnetEntries) {
if (seenCidrs.has(subnet.cidr)) {
issues.push({
path: `$.subnets[${originalIndex}].cidr`,
message: `duplicate subnet CIDR "${subnet.cidr}"`
});
}
seenCidrs.add(subnet.cidr);
}
const racksRaw = value.racks;
const racksProvided = racksRaw !== undefined;
if (racksProvided && !Array.isArray(racksRaw)) {
issues.push({ path: '$.racks', message: 'must be an array when provided' });
}
const racks = [];
const rackEntries = [];
for (const [rackIndex, rackValue] of (Array.isArray(racksRaw) ? racksRaw : []).entries()) {
const rack = normalizeRackDefinition(issues, `$.racks[${rackIndex}]`, rackValue);
if (rack) {
racks.push(rack);
rackEntries.push({ rack, originalIndex: rackIndex });
}
}
const seenRackNames = new Set();
for (const { rack, originalIndex } of rackEntries) {
const key = rack.name.toLowerCase();
if (seenRackNames.has(key)) {
issues.push({
path: `$.racks[${originalIndex}].name`,
message: `duplicate rack name "${rack.name}"`
});
}
seenRackNames.add(key);
}
const data = {
machines,
devices
devices,
...(subnetsProvided ? { subnets } : {}),
...(racksProvided ? { racks } : {})
};
return {
+31
View File
@@ -1,14 +1,28 @@
export interface CableInfo {
type?: string;
color?: string;
lengthM?: number;
}
export interface ConnectedPortRef {
device: string;
port: string;
cable?: CableInfo;
}
export interface Port {
portName: string;
speedGbps?: number; // default to 1 if not provided
macAddress?: string;
connectedTo?: ConnectedPortRef;
}
export interface RackPlacement {
name: string;
unit: number; // bottom U position, 1-based
heightU?: number; // defaults to 1
}
export interface Hardware {
cpu: string;
ram: string;
@@ -22,6 +36,7 @@ export interface VM {
role: string;
ipAddress: string;
iconKey?: string;
macAddress?: string;
}
export interface Software {
@@ -34,9 +49,11 @@ export interface Machine {
role: string;
operatingSystem: string;
iconKey?: string;
notes?: string;
software: Software;
hardware: Hardware;
ports?: Port[];
rack?: RackPlacement;
}
export interface NetworkDevice {
@@ -46,9 +63,23 @@ export interface NetworkDevice {
iconKey?: string;
notes?: string;
ports?: Port[];
rack?: RackPlacement;
}
export interface Subnet {
cidr: string;
name?: string;
vlanId?: number;
}
export interface RackDefinition {
name: string;
heightU?: number;
}
export interface NetworkData {
machines: Machine[];
devices: NetworkDevice[];
subnets?: Subnet[];
racks?: RackDefinition[];
}
+96 -14
View File
@@ -6,12 +6,14 @@
"role": "Hypervisor",
"operatingSystem": "Proxmox",
"iconKey": "homarr:proxmox",
"notes": "Primary router box; do not power off without failover in place.",
"software": {
"vms": [
{
"name": "OpnSense",
"role": "Router/Firewall/Gateway (DHCP 10.0.0.100-254)",
"ipAddress": "10.0.0.1"
"ipAddress": "10.0.0.1",
"macAddress": "bc:24:11:5a:2e:01"
},
{
"name": "TPLink Omada Controller",
@@ -50,9 +52,15 @@
{
"portName": "eth0",
"speedGbps": 1,
"macAddress": "84:47:09:1c:aa:10",
"connectedTo": {
"device": "Gigabit Switch",
"port": "1"
"port": "1",
"cable": {
"type": "Cat6",
"color": "blue",
"lengthM": 1
}
}
},
{
@@ -64,7 +72,12 @@
"speedGbps": 1,
"connectedTo": {
"device": "Gigabit Switch",
"port": "2"
"port": "2",
"cable": {
"type": "Cat6",
"color": "blue",
"lengthM": 1
}
}
},
{
@@ -75,7 +88,11 @@
"port": "wan"
}
}
]
],
"rack": {
"name": "Lab Rack",
"unit": 10
}
},
{
"machineName": "Asustor NAS",
@@ -98,7 +115,12 @@
"speedGbps": 1,
"connectedTo": {
"device": "Gigabit Switch",
"port": "3"
"port": "3",
"cable": {
"type": "Cat5e",
"color": "red",
"lengthM": 2
}
}
}
]
@@ -254,7 +276,12 @@
"port": "8"
}
}
]
],
"rack": {
"name": "Lab Rack",
"unit": 7,
"heightU": 2
}
},
{
"machineName": "AI Server",
@@ -292,7 +319,12 @@
"port": "9"
}
}
]
],
"rack": {
"name": "Lab Rack",
"unit": 4,
"heightU": 3
}
},
{
"machineName": "Immich Mini PC",
@@ -334,7 +366,12 @@
"speedGbps": 1,
"connectedTo": {
"device": "ProxRouter",
"port": "eth0"
"port": "eth0",
"cable": {
"type": "Cat6",
"color": "blue",
"lengthM": 1
}
}
},
{
@@ -342,7 +379,12 @@
"speedGbps": 1,
"connectedTo": {
"device": "ProxRouter",
"port": "eth2"
"port": "eth2",
"cable": {
"type": "Cat6",
"color": "blue",
"lengthM": 1
}
}
},
{
@@ -350,7 +392,12 @@
"speedGbps": 1,
"connectedTo": {
"device": "Asustor NAS",
"port": "eth0"
"port": "eth0",
"cable": {
"type": "Cat5e",
"color": "red",
"lengthM": 2
}
}
},
{
@@ -473,7 +520,11 @@
"portName": "24",
"speedGbps": 1
}
]
],
"rack": {
"name": "Lab Rack",
"unit": 12
}
},
{
"name": "WAN Uplink",
@@ -505,7 +556,11 @@
"port": "11"
}
}
]
],
"rack": {
"name": "Lab Rack",
"unit": 3
}
},
{
"name": "3DS",
@@ -547,7 +602,11 @@
"port": "20"
}
}
]
],
"rack": {
"name": "Lab Rack",
"unit": 3
}
},
{
"name": "UPS Pi",
@@ -559,7 +618,12 @@
"portName": "port0",
"speedGbps": 1
}
]
],
"rack": {
"name": "Lab Rack",
"unit": 1,
"heightU": 2
}
},
{
"name": "Waveshare",
@@ -568,5 +632,23 @@
"notes": "Port link not yet modeled.",
"ports": []
}
],
"subnets": [
{
"cidr": "10.0.0.0/24",
"name": "LAN",
"vlanId": 1
},
{
"cidr": "10.0.20.0/24",
"name": "IoT",
"vlanId": 20
}
],
"racks": [
{
"name": "Lab Rack",
"heightU": 12
}
]
}