diff --git a/data/network.json.example b/data/network.json.example index 69c9004..1668705 100644 --- a/data/network.json.example +++ b/data/network.json.example @@ -42,6 +42,10 @@ "ipAddress": "192.168.1.2", "type": "Network Switch", "notes": "Optional notes", + "rack": { + "name": "Example Rack", + "unit": 12 + }, "ports": [ { "portName": "1", diff --git a/src/lib/components/NetworkDiagram.svelte b/src/lib/components/NetworkDiagram.svelte index 0f9998f..d60142f 100644 --- a/src/lib/components/NetworkDiagram.svelte +++ b/src/lib/components/NetworkDiagram.svelte @@ -23,6 +23,8 @@ import loadNetworkData from '$lib/data/loadNetworkData'; import { buildIpamReport, suggestNextFreeIp, type IpamReport } from '$lib/data/ipam'; import { vlanColor } from '$lib/graph/vlanPalette'; + import { buildRackLayout, knownRackNames } from '$lib/data/rackLayout'; + import RackView from './RackView.svelte'; import { validateNetworkData, type ValidationIssue } from '$lib/data/networkSchema'; import transformNetworkDataToGraph from '$lib/graph/transformNetworkData'; import { computeSearchMatches } from '$lib/graph/searchHighlight'; @@ -34,7 +36,7 @@ export let jsonPath = '/data/network.json'; type SaveState = 'saved' | 'saving' | 'unsaved' | 'error'; - type DiagramViewMode = 'network' | 'device'; + type DiagramViewMode = 'network' | 'device' | 'rack'; type Position = { x: number; y: number }; type AddModalKind = 'machine' | 'device' | null; type DeleteTarget = @@ -161,6 +163,9 @@ applyEmphasis(); } + $: rackLayout = buildRackLayout(networkData); + $: rackNames = knownRackNames(networkData); + $: ipamReport = buildIpamReport(networkData); $: ipamWarnings = ipamReport.duplicates.map( (duplicate) => `Duplicate IP ${duplicate.ip}: assigned to ${duplicate.owners.join(' and ')}.` @@ -888,8 +893,10 @@ function onDiagramViewSelectChange(event: Event) { const nextValue = (event.currentTarget as HTMLSelectElement).value; - diagramViewMode = nextValue === 'device' ? 'device' : 'network'; - refreshGraph(); + diagramViewMode = nextValue === 'device' || nextValue === 'rack' ? nextValue : 'network'; + if (diagramViewMode !== 'rack') { + refreshGraph(); + } } function isHostCollapsed(hostId: string): boolean { @@ -1424,6 +1431,47 @@ updateDevicePortConnection(deviceIndex, portIndex, next); } + function updateRackField( + kind: OwnerKind, + index: number, + field: 'name' | 'unit' | 'heightU', + value: string + ) { + mutateDraft( + (draft) => { + const entity = kind === 'machine' ? draft.machines[index] : draft.devices[index]; + if (!entity) { + return; + } + if (field === 'name') { + const name = value.trim(); + if (!name) { + delete entity.rack; + return; + } + entity.rack = { + name, + unit: entity.rack?.unit ?? 1, + ...(entity.rack?.heightU ? { heightU: entity.rack.heightU } : {}) + }; + return; + } + if (!entity.rack) { + return; + } + const parsed = Number(value); + if (field === 'unit') { + entity.rack.unit = Number.isInteger(parsed) && parsed >= 1 ? parsed : 1; + } else if (Number.isInteger(parsed) && parsed >= 1) { + entity.rack.heightU = parsed; + } else { + delete entity.rack.heightU; + } + }, + { autosave: true } + ); + } + function updatePortCableField( kind: OwnerKind, ownerName: string, @@ -1841,6 +1889,20 @@
+ {#if diagramViewMode === 'rack'} + { + const { kind, name } = event.detail; + const index = + kind === 'machine' ? findMachineIndexByName(name) : findDeviceIndexByName(name); + if (index >= 0) { + selectedTarget = { type: kind, index }; + } + }} + /> + {/if} +
{#if mapControlsCollapsed} @@ -1879,12 +1941,13 @@ > +
+ + {#each rackNames as rackName (rackName)} + + {/each} + @@ -2375,6 +2450,61 @@
+
+

Rack

+
+ + + +
+
+

Virtual Machines

@@ -2676,6 +2806,61 @@
+
+

Rack

+
+ + + +
+
+

Ports

diff --git a/src/lib/components/RackView.svelte b/src/lib/components/RackView.svelte new file mode 100644 index 0000000..cfc6e2e --- /dev/null +++ b/src/lib/components/RackView.svelte @@ -0,0 +1,215 @@ + + +
+ {#if layout.racks.length === 0} +
+

No rack placements yet

+

+ Open a machine or device and fill in the Rack section (rack name + U position) to see it + here. +

+
+ {:else} +
+ {#each layout.racks as rack (rack.name)} +
+

{rack.name}

+
+ {#each Array.from({ length: rack.heightU }, (_, i) => rack.heightU - i) as unit (unit)} +
+ {unit} +
+ {/each} + {#each rack.slots as slot (`${slot.kind}:${slot.name}`)} + + {/each} +
+
+ {/each} +
+ {#if layout.unrackedCount > 0} +

+ {layout.unrackedCount} + {layout.unrackedCount === 1 ? 'entity is' : 'entities are'} not rack-mounted. +

+ {/if} + {/if} +
+ + diff --git a/src/lib/data/rackLayout.ts b/src/lib/data/rackLayout.ts new file mode 100644 index 0000000..7435f04 --- /dev/null +++ b/src/lib/data/rackLayout.ts @@ -0,0 +1,117 @@ +import type { NetworkData } from '../types'; + +export interface RackSlot { + kind: 'machine' | 'device'; + name: string; + index: number; + unit: number; + heightU: number; + iconKey?: string; + subtitle: string; +} + +export interface RackColumn { + name: string; + heightU: number; + slots: RackSlot[]; +} + +export interface RackLayout { + racks: RackColumn[]; + overlaps: string[]; + unrackedCount: number; +} + +const minimumRackHeightU = 12; + +export function buildRackLayout(data: NetworkData): RackLayout { + const slotsByRack = new Map(); + let unrackedCount = 0; + + const place = (slot: Omit & { heightU?: number }, rackName: string) => { + const key = rackName.trim(); + const slots = slotsByRack.get(key) ?? []; + slots.push({ ...slot, heightU: Math.max(1, Math.round(slot.heightU ?? 1)) }); + slotsByRack.set(key, 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 overlaps: 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)); + const topUnit = Math.max(...sorted.map((slot) => slot.unit + slot.heightU - 1)); + for (let i = 0; i < sorted.length; i += 1) { + for (let j = i + 1; j < sorted.length; j += 1) { + const a = sorted[i]; + const b = sorted[j]; + const aTop = a.unit + a.heightU - 1; + const bTop = b.unit + b.heightU - 1; + if (a.unit <= bTop && b.unit <= aTop) { + overlaps.push( + `Rack "${name}": "${a.name}" (U${a.unit}–U${aTop}) overlaps "${b.name}" (U${b.unit}–U${bTop}).` + ); + } + } + } + return { + name, + heightU: Math.max(minimumRackHeightU, topUnit), + slots: sorted + }; + }); + + return { racks, overlaps, unrackedCount }; +} + +export function knownRackNames(data: NetworkData): string[] { + const names = new Set(); + for (const machine of data.machines) { + if (machine.rack?.name) { + names.add(machine.rack.name); + } + } + for (const device of data.devices) { + if (device.rack?.name) { + names.add(device.rack.name); + } + } + return [...names].sort((a, b) => a.localeCompare(b)); +} diff --git a/src/lib/shared/networkSchemaCore.mjs b/src/lib/shared/networkSchemaCore.mjs index 057a967..4297a0f 100644 --- a/src/lib/shared/networkSchemaCore.mjs +++ b/src/lib/shared/networkSchemaCore.mjs @@ -5,8 +5,9 @@ * @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; notes?: string; software: { vms: VM[] }; hardware: Hardware; ports?: Port[] }} Machine - * @typedef {{ name: string; ipAddress: string; type: string; iconKey?: string; notes?: string; ports?: Port[] }} NetworkDevice + * @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 {{ machines: Machine[]; devices: NetworkDevice[]; subnets?: Subnet[] }} NetworkData * @typedef {{ valid: boolean; errors: ValidationIssue[]; data?: NetworkData }} ValidationResult @@ -165,6 +166,33 @@ export function normalizePort(issues, path, value, ownerLabel) { }; } +/** + * @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 @@ -237,6 +265,7 @@ function normalizeMachine(issues, path, value) { 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)) { @@ -329,6 +358,7 @@ function normalizeMachine(issues, path, value) { operatingSystem, ...(iconKey ? { iconKey } : {}), ...(notes !== undefined ? { notes } : {}), + ...(rack ? { rack } : {}), software: { vms }, hardware: { cpu: hardware.cpu, @@ -360,6 +390,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)) { @@ -387,6 +418,7 @@ function normalizeDevice(issues, path, value) { type, ...(iconKey ? { iconKey } : {}), ...(notes !== undefined ? { notes } : {}), + ...(rack ? { rack } : {}), ports }; } diff --git a/src/lib/types.ts b/src/lib/types.ts index eda2958..22caab5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -17,6 +17,12 @@ export interface Port { 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; @@ -47,6 +53,7 @@ export interface Machine { software: Software; hardware: Hardware; ports?: Port[]; + rack?: RackPlacement; } export interface NetworkDevice { @@ -56,6 +63,7 @@ export interface NetworkDevice { iconKey?: string; notes?: string; ports?: Port[]; + rack?: RackPlacement; } export interface Subnet { diff --git a/static/data/network.json b/static/data/network.json index aaba535..765346b 100644 --- a/static/data/network.json +++ b/static/data/network.json @@ -88,7 +88,11 @@ "port": "wan" } } - ] + ], + "rack": { + "name": "Lab Rack", + "unit": 10 + } }, { "machineName": "Asustor NAS", @@ -272,7 +276,12 @@ "port": "8" } } - ] + ], + "rack": { + "name": "Lab Rack", + "unit": 7, + "heightU": 2 + } }, { "machineName": "AI Server", @@ -310,7 +319,12 @@ "port": "9" } } - ] + ], + "rack": { + "name": "Lab Rack", + "unit": 4, + "heightU": 3 + } }, { "machineName": "Immich Mini PC", @@ -506,7 +520,11 @@ "portName": "24", "speedGbps": 1 } - ] + ], + "rack": { + "name": "Lab Rack", + "unit": 12 + } }, { "name": "WAN Uplink", @@ -592,7 +610,12 @@ "portName": "port0", "speedGbps": 1 } - ] + ], + "rack": { + "name": "Lab Rack", + "unit": 1, + "heightU": 2 + } }, { "name": "Waveshare",