feat(*): Add rack elevation view with U positions

This commit is contained in:
Josh Creek
2026-07-12 20:24:16 +01:00
parent b4c7d1bce4
commit b4f5f12731
7 changed files with 601 additions and 17 deletions
+4
View File
@@ -42,6 +42,10 @@
"ipAddress": "192.168.1.2", "ipAddress": "192.168.1.2",
"type": "Network Switch", "type": "Network Switch",
"notes": "Optional notes", "notes": "Optional notes",
"rack": {
"name": "Example Rack",
"unit": 12
},
"ports": [ "ports": [
{ {
"portName": "1", "portName": "1",
+195 -10
View File
@@ -23,6 +23,8 @@
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 { 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 { 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';
@@ -34,7 +36,7 @@
export let jsonPath = '/data/network.json'; export let jsonPath = '/data/network.json';
type SaveState = 'saved' | 'saving' | 'unsaved' | 'error'; type SaveState = 'saved' | 'saving' | 'unsaved' | 'error';
type DiagramViewMode = 'network' | 'device'; type DiagramViewMode = 'network' | 'device' | 'rack';
type Position = { x: number; y: number }; type Position = { x: number; y: number };
type AddModalKind = 'machine' | 'device' | null; type AddModalKind = 'machine' | 'device' | null;
type DeleteTarget = type DeleteTarget =
@@ -161,6 +163,9 @@
applyEmphasis(); applyEmphasis();
} }
$: rackLayout = buildRackLayout(networkData);
$: rackNames = knownRackNames(networkData);
$: 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 ')}.`
@@ -888,8 +893,10 @@
function onDiagramViewSelectChange(event: Event) { function onDiagramViewSelectChange(event: Event) {
const nextValue = (event.currentTarget as HTMLSelectElement).value; const nextValue = (event.currentTarget as HTMLSelectElement).value;
diagramViewMode = nextValue === 'device' ? 'device' : 'network'; diagramViewMode = nextValue === 'device' || nextValue === 'rack' ? nextValue : 'network';
refreshGraph(); if (diagramViewMode !== 'rack') {
refreshGraph();
}
} }
function isHostCollapsed(hostId: string): boolean { function isHostCollapsed(hostId: string): boolean {
@@ -1424,6 +1431,47 @@
updateDevicePortConnection(deviceIndex, portIndex, next); 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( function updatePortCableField(
kind: OwnerKind, kind: OwnerKind,
ownerName: string, ownerName: string,
@@ -1841,6 +1889,20 @@
<div class="diagram-stage" bind:this={diagramStage}> <div class="diagram-stage" bind:this={diagramStage}>
<div class="diagram-canvas" bind:this={container}></div> <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"> <div class="map-controls-shell">
{#if mapControlsCollapsed} {#if mapControlsCollapsed}
<button type="button" class="controls-toggle" on:click={toggleMapControls}>Show Controls</button> <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="network">Network view (with ethernet)</option>
<option value="device">Device view (without ethernet)</option> <option value="device">Device view (without ethernet)</option>
<option value="rack">Rack view (physical layout)</option>
</select> </select>
</label> </label>
<label <label
class="toggle-control" class="toggle-control"
class:disabled={diagramViewMode === 'device'} class:disabled={diagramViewMode !== 'network'}
title={diagramViewMode === 'device' ? 'Ethernet labels are available in Network view.' : undefined} title={diagramViewMode !== 'network' ? 'Ethernet labels are available in Network view.' : undefined}
> >
<span>Ethernet Labels</span> <span>Ethernet Labels</span>
<input <input
@@ -1892,7 +1955,7 @@
type="checkbox" type="checkbox"
checked={showEthernetLabels} checked={showEthernetLabels}
aria-label="Toggle ethernet labels" aria-label="Toggle ethernet labels"
disabled={diagramViewMode === 'device'} disabled={diagramViewMode !== 'network'}
on:change={onEthernetLabelsToggleChange} on:change={onEthernetLabelsToggleChange}
/> />
<span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span> <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span>
@@ -1904,7 +1967,14 @@
> >
IPAM IPAM
</button> </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"> <label class="toggle-control">
<span>Dark Mode</span> <span>Dark Mode</span>
<input <input
@@ -2136,11 +2206,11 @@
{/if} {/if}
</div> </div>
{#if warnings.length + ipamWarnings.length > 0} {#if warnings.length + ipamWarnings.length + rackLayout.overlaps.length > 0}
<details class="warnings-panel"> <details class="warnings-panel">
<summary>Data warnings ({warnings.length + ipamWarnings.length})</summary> <summary>Data warnings ({warnings.length + ipamWarnings.length + rackLayout.overlaps.length})</summary>
<ul> <ul>
{#each [...warnings, ...ipamWarnings] as warning (warning)} {#each [...warnings, ...ipamWarnings, ...rackLayout.overlaps] as warning (warning)}
<li>{warning}</li> <li>{warning}</li>
{/each} {/each}
</ul> </ul>
@@ -2209,6 +2279,11 @@
<p class="icon-results-empty">No icons match your search.</p> <p class="icon-results-empty">No icons match your search.</p>
{/if} {/if}
</div> </div>
<datalist id="rack-name-options">
{#each rackNames as rackName (rackName)}
<option value={rackName}></option>
{/each}
</datalist>
<datalist id="cable-type-options"> <datalist id="cable-type-options">
<option value="Cat5e"></option> <option value="Cat5e"></option>
<option value="Cat6"></option> <option value="Cat6"></option>
@@ -2375,6 +2450,61 @@
</div> </div>
</section> </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"> <section class="edit-section">
<h4>Virtual Machines</h4> <h4>Virtual Machines</h4>
<div class="inline-actions"> <div class="inline-actions">
@@ -2676,6 +2806,61 @@
</label> </label>
</section> </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"> <section class="edit-section">
<h4>Ports</h4> <h4>Ports</h4>
<datalist id="device-connectable-owner-options"> <datalist id="device-connectable-owner-options">
+215
View File
@@ -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>
+117
View File
@@ -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));
}
+34 -2
View File
@@ -5,8 +5,9 @@
* @typedef {{ portName: string; speedGbps?: number; macAddress?: string; connectedTo?: ConnectedTo }} Port * @typedef {{ portName: string; speedGbps?: number; macAddress?: string; connectedTo?: ConnectedTo }} Port
* @typedef {{ name: string; role: string; ipAddress: string; iconKey?: string; macAddress?: string }} VM * @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 {{ 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; unit: number; heightU?: number }} RackPlacement
* @typedef {{ name: string; ipAddress: string; type: string; iconKey?: string; notes?: string; ports?: Port[] }} NetworkDevice * @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 {{ cidr: string; name?: string; vlanId?: number }} Subnet
* @typedef {{ machines: Machine[]; devices: NetworkDevice[]; subnets?: Subnet[] }} NetworkData * @typedef {{ machines: Machine[]; devices: NetworkDevice[]; subnets?: Subnet[] }} NetworkData
* @typedef {{ valid: boolean; errors: ValidationIssue[]; data?: NetworkData }} ValidationResult * @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 {ValidationIssue[]} issues
* @param {PortEntry[]} portEntries * @param {PortEntry[]} portEntries
@@ -237,6 +265,7 @@ function normalizeMachine(issues, path, value) {
const operatingSystem = readString(issues, `${path}.operatingSystem`, value.operatingSystem); const operatingSystem = readString(issues, `${path}.operatingSystem`, value.operatingSystem);
const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true }); const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
const notes = readString(issues, `${path}.notes`, value.notes, { optional: true, allowEmpty: 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; const softwareRaw = value.software;
if (!isRecord(softwareRaw)) { if (!isRecord(softwareRaw)) {
@@ -329,6 +358,7 @@ function normalizeMachine(issues, path, value) {
operatingSystem, operatingSystem,
...(iconKey ? { iconKey } : {}), ...(iconKey ? { iconKey } : {}),
...(notes !== undefined ? { notes } : {}), ...(notes !== undefined ? { notes } : {}),
...(rack ? { rack } : {}),
software: { vms }, software: { vms },
hardware: { hardware: {
cpu: hardware.cpu, cpu: hardware.cpu,
@@ -360,6 +390,7 @@ function normalizeDevice(issues, path, value) {
const type = readString(issues, `${path}.type`, value.type); const type = readString(issues, `${path}.type`, value.type);
const notes = readString(issues, `${path}.notes`, value.notes, { optional: true, allowEmpty: true }); const notes = readString(issues, `${path}.notes`, value.notes, { optional: true, allowEmpty: true });
const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true }); const iconKey = readString(issues, `${path}.iconKey`, value.iconKey, { optional: true });
const rack = normalizeRack(issues, `${path}.rack`, value.rack);
const portsRaw = value.ports; const portsRaw = value.ports;
if (portsRaw !== undefined && !Array.isArray(portsRaw)) { if (portsRaw !== undefined && !Array.isArray(portsRaw)) {
@@ -387,6 +418,7 @@ function normalizeDevice(issues, path, value) {
type, type,
...(iconKey ? { iconKey } : {}), ...(iconKey ? { iconKey } : {}),
...(notes !== undefined ? { notes } : {}), ...(notes !== undefined ? { notes } : {}),
...(rack ? { rack } : {}),
ports ports
}; };
} }
+8
View File
@@ -17,6 +17,12 @@ export interface Port {
connectedTo?: ConnectedPortRef; connectedTo?: ConnectedPortRef;
} }
export interface RackPlacement {
name: string;
unit: number; // bottom U position, 1-based
heightU?: number; // defaults to 1
}
export interface Hardware { export interface Hardware {
cpu: string; cpu: string;
ram: string; ram: string;
@@ -47,6 +53,7 @@ export interface Machine {
software: Software; software: Software;
hardware: Hardware; hardware: Hardware;
ports?: Port[]; ports?: Port[];
rack?: RackPlacement;
} }
export interface NetworkDevice { export interface NetworkDevice {
@@ -56,6 +63,7 @@ export interface NetworkDevice {
iconKey?: string; iconKey?: string;
notes?: string; notes?: string;
ports?: Port[]; ports?: Port[];
rack?: RackPlacement;
} }
export interface Subnet { export interface Subnet {
+28 -5
View File
@@ -88,7 +88,11 @@
"port": "wan" "port": "wan"
} }
} }
] ],
"rack": {
"name": "Lab Rack",
"unit": 10
}
}, },
{ {
"machineName": "Asustor NAS", "machineName": "Asustor NAS",
@@ -272,7 +276,12 @@
"port": "8" "port": "8"
} }
} }
] ],
"rack": {
"name": "Lab Rack",
"unit": 7,
"heightU": 2
}
}, },
{ {
"machineName": "AI Server", "machineName": "AI Server",
@@ -310,7 +319,12 @@
"port": "9" "port": "9"
} }
} }
] ],
"rack": {
"name": "Lab Rack",
"unit": 4,
"heightU": 3
}
}, },
{ {
"machineName": "Immich Mini PC", "machineName": "Immich Mini PC",
@@ -506,7 +520,11 @@
"portName": "24", "portName": "24",
"speedGbps": 1 "speedGbps": 1
} }
] ],
"rack": {
"name": "Lab Rack",
"unit": 12
}
}, },
{ {
"name": "WAN Uplink", "name": "WAN Uplink",
@@ -592,7 +610,12 @@
"portName": "port0", "portName": "port0",
"speedGbps": 1 "speedGbps": 1
} }
] ],
"rack": {
"name": "Lab Rack",
"unit": 1,
"heightU": 2
}
}, },
{ {
"name": "Waveshare", "name": "Waveshare",