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
+195 -10
View File
@@ -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">
+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>