mirror of
https://github.com/jcreek/OpenNetworkDiagram.git
synced 2026-07-13 19:13:43 +00:00
feat(*): Add rack elevation view with U positions
This commit is contained in:
@@ -42,6 +42,10 @@
|
||||
"ipAddress": "192.168.1.2",
|
||||
"type": "Network Switch",
|
||||
"notes": "Optional notes",
|
||||
"rack": {
|
||||
"name": "Example Rack",
|
||||
"unit": 12
|
||||
},
|
||||
"ports": [
|
||||
{
|
||||
"portName": "1",
|
||||
|
||||
@@ -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 @@
|
||||
<div class="diagram-stage" bind:this={diagramStage}>
|
||||
<div class="diagram-canvas" bind:this={container}></div>
|
||||
|
||||
{#if diagramViewMode === 'rack'}
|
||||
<RackView
|
||||
layout={rackLayout}
|
||||
on:select={(event) => {
|
||||
const { kind, name } = event.detail;
|
||||
const index =
|
||||
kind === 'machine' ? findMachineIndexByName(name) : findDeviceIndexByName(name);
|
||||
if (index >= 0) {
|
||||
selectedTarget = { type: kind, index };
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="map-controls-shell">
|
||||
{#if mapControlsCollapsed}
|
||||
<button type="button" class="controls-toggle" on:click={toggleMapControls}>Show Controls</button>
|
||||
@@ -1879,12 +1941,13 @@
|
||||
>
|
||||
<option value="network">Network view (with ethernet)</option>
|
||||
<option value="device">Device view (without ethernet)</option>
|
||||
<option value="rack">Rack view (physical layout)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label
|
||||
class="toggle-control"
|
||||
class:disabled={diagramViewMode === 'device'}
|
||||
title={diagramViewMode === 'device' ? 'Ethernet labels are available in Network view.' : undefined}
|
||||
class:disabled={diagramViewMode !== 'network'}
|
||||
title={diagramViewMode !== 'network' ? 'Ethernet labels are available in Network view.' : undefined}
|
||||
>
|
||||
<span>Ethernet Labels</span>
|
||||
<input
|
||||
@@ -1892,7 +1955,7 @@
|
||||
type="checkbox"
|
||||
checked={showEthernetLabels}
|
||||
aria-label="Toggle ethernet labels"
|
||||
disabled={diagramViewMode === 'device'}
|
||||
disabled={diagramViewMode !== 'network'}
|
||||
on:change={onEthernetLabelsToggleChange}
|
||||
/>
|
||||
<span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
|
||||
@@ -1904,7 +1967,14 @@
|
||||
>
|
||||
IPAM
|
||||
</button>
|
||||
<button type="button" on:click={() => (showExportModal = true)}>Export PNG</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={diagramViewMode === 'rack'}
|
||||
title={diagramViewMode === 'rack' ? 'PNG export is available in the graph views.' : undefined}
|
||||
on:click={() => (showExportModal = true)}
|
||||
>
|
||||
Export PNG
|
||||
</button>
|
||||
<label class="toggle-control">
|
||||
<span>Dark Mode</span>
|
||||
<input
|
||||
@@ -2136,11 +2206,11 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if warnings.length + ipamWarnings.length > 0}
|
||||
{#if warnings.length + ipamWarnings.length + rackLayout.overlaps.length > 0}
|
||||
<details class="warnings-panel">
|
||||
<summary>Data warnings ({warnings.length + ipamWarnings.length})</summary>
|
||||
<summary>Data warnings ({warnings.length + ipamWarnings.length + rackLayout.overlaps.length})</summary>
|
||||
<ul>
|
||||
{#each [...warnings, ...ipamWarnings] as warning (warning)}
|
||||
{#each [...warnings, ...ipamWarnings, ...rackLayout.overlaps] as warning (warning)}
|
||||
<li>{warning}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -2209,6 +2279,11 @@
|
||||
<p class="icon-results-empty">No icons match your search.</p>
|
||||
{/if}
|
||||
</div>
|
||||
<datalist id="rack-name-options">
|
||||
{#each rackNames as rackName (rackName)}
|
||||
<option value={rackName}></option>
|
||||
{/each}
|
||||
</datalist>
|
||||
<datalist id="cable-type-options">
|
||||
<option value="Cat5e"></option>
|
||||
<option value="Cat6"></option>
|
||||
@@ -2375,6 +2450,61 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="edit-section">
|
||||
<h4>Rack</h4>
|
||||
<div class="field-row">
|
||||
<label>
|
||||
Rack Name
|
||||
<input
|
||||
type="text"
|
||||
list="rack-name-options"
|
||||
placeholder="Leave blank if not racked"
|
||||
value={selectedMachine.rack?.name ?? ''}
|
||||
on:change={(event) =>
|
||||
updateRackField(
|
||||
'machine',
|
||||
selectedTarget.index,
|
||||
'name',
|
||||
(event.currentTarget as HTMLInputElement).value
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Bottom U
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
disabled={!selectedMachine.rack}
|
||||
value={selectedMachine.rack?.unit ?? ''}
|
||||
on:change={(event) =>
|
||||
updateRackField(
|
||||
'machine',
|
||||
selectedTarget.index,
|
||||
'unit',
|
||||
(event.currentTarget as HTMLInputElement).value
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Height (U)
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="1"
|
||||
disabled={!selectedMachine.rack}
|
||||
value={selectedMachine.rack?.heightU ?? ''}
|
||||
on:change={(event) =>
|
||||
updateRackField(
|
||||
'machine',
|
||||
selectedTarget.index,
|
||||
'heightU',
|
||||
(event.currentTarget as HTMLInputElement).value
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="edit-section">
|
||||
<h4>Virtual Machines</h4>
|
||||
<div class="inline-actions">
|
||||
@@ -2676,6 +2806,61 @@
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="edit-section">
|
||||
<h4>Rack</h4>
|
||||
<div class="field-row">
|
||||
<label>
|
||||
Rack Name
|
||||
<input
|
||||
type="text"
|
||||
list="rack-name-options"
|
||||
placeholder="Leave blank if not racked"
|
||||
value={selectedDevice.rack?.name ?? ''}
|
||||
on:change={(event) =>
|
||||
updateRackField(
|
||||
'device',
|
||||
selectedTarget.index,
|
||||
'name',
|
||||
(event.currentTarget as HTMLInputElement).value
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Bottom U
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
disabled={!selectedDevice.rack}
|
||||
value={selectedDevice.rack?.unit ?? ''}
|
||||
on:change={(event) =>
|
||||
updateRackField(
|
||||
'device',
|
||||
selectedTarget.index,
|
||||
'unit',
|
||||
(event.currentTarget as HTMLInputElement).value
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Height (U)
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="1"
|
||||
disabled={!selectedDevice.rack}
|
||||
value={selectedDevice.rack?.heightU ?? ''}
|
||||
on:change={(event) =>
|
||||
updateRackField(
|
||||
'device',
|
||||
selectedTarget.index,
|
||||
'heightU',
|
||||
(event.currentTarget as HTMLInputElement).value
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="edit-section">
|
||||
<h4>Ports</h4>
|
||||
<datalist id="device-connectable-owner-options">
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<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;
|
||||
return `top: ${top}px; height: ${slot.heightU * unitHeightPx - 4}px;`;
|
||||
}
|
||||
</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}</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: 6.5rem 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-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;
|
||||
left: 2.1rem;
|
||||
right: 0.35rem;
|
||||
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>
|
||||
@@ -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<string, RackSlot[]>();
|
||||
let unrackedCount = 0;
|
||||
|
||||
const place = (slot: Omit<RackSlot, 'heightU'> & { 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<string>();
|
||||
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));
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user