mirror of
https://github.com/jcreek/OpenNetworkDiagram.git
synced 2026-07-14 03:23:44 +00:00
feat(*): Add rack elevation view with U positions
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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,9 +893,11 @@
|
|||||||
|
|
||||||
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';
|
||||||
|
if (diagramViewMode !== 'rack') {
|
||||||
refreshGraph();
|
refreshGraph();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isHostCollapsed(hostId: string): boolean {
|
function isHostCollapsed(hostId: string): boolean {
|
||||||
return collapsedHosts[hostId] === true;
|
return collapsedHosts[hostId] === true;
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {{ 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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user