mirror of
https://github.com/jcreek/OpenNetworkDiagram.git
synced 2026-07-13 19:13:43 +00:00
feat(*): Make racks first-class with dropdown selection and shelf support
This commit is contained in:
@@ -64,5 +64,11 @@
|
|||||||
"name": "LAN",
|
"name": "LAN",
|
||||||
"vlanId": 1
|
"vlanId": 1
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"racks": [
|
||||||
|
{
|
||||||
|
"name": "Example Rack",
|
||||||
|
"heightU": 24
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
deletePort,
|
deletePort,
|
||||||
renameOwner,
|
renameOwner,
|
||||||
renamePort,
|
renamePort,
|
||||||
|
renameRack,
|
||||||
setPortCable,
|
setPortCable,
|
||||||
setPortConnection,
|
setPortConnection,
|
||||||
type OwnerKind,
|
type OwnerKind,
|
||||||
@@ -166,6 +167,108 @@
|
|||||||
$: rackLayout = buildRackLayout(networkData);
|
$: rackLayout = buildRackLayout(networkData);
|
||||||
$: rackNames = knownRackNames(networkData);
|
$: rackNames = knownRackNames(networkData);
|
||||||
|
|
||||||
|
let rackNewNameTarget: { kind: OwnerKind; index: number } | null = null;
|
||||||
|
|
||||||
|
function onRackSelectChange(kind: OwnerKind, index: number, value: string) {
|
||||||
|
if (value === '__new__') {
|
||||||
|
rackNewNameTarget = { kind, index };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rackNewNameTarget = null;
|
||||||
|
updateRackField(kind, index, 'name', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitNewRackName(value: string) {
|
||||||
|
const target = rackNewNameTarget;
|
||||||
|
rackNewNameTarget = null;
|
||||||
|
const name = value.trim();
|
||||||
|
if (!target || !name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mutateDraft(
|
||||||
|
(draft) => {
|
||||||
|
draft.racks ??= [];
|
||||||
|
if (!draft.racks.some((rack) => rack.name.toLowerCase() === name.toLowerCase())) {
|
||||||
|
draft.racks.push({ name });
|
||||||
|
}
|
||||||
|
const entity =
|
||||||
|
target.kind === 'machine' ? draft.machines[target.index] : draft.devices[target.index];
|
||||||
|
if (entity) {
|
||||||
|
entity.rack = {
|
||||||
|
name,
|
||||||
|
unit: entity.rack?.unit ?? 1,
|
||||||
|
...(entity.rack?.heightU ? { heightU: entity.rack.heightU } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ autosave: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameRackDefinition(previousName: string, nextName: string) {
|
||||||
|
if (!nextName.trim() || nextName.trim() === previousName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyDraft(renameRack(networkData, previousName, nextName));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRackHeightU(rackName: string, value: string) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
mutateDraft(
|
||||||
|
(draft) => {
|
||||||
|
draft.racks ??= [];
|
||||||
|
let definition = draft.racks.find(
|
||||||
|
(rack) => rack.name.toLowerCase() === rackName.toLowerCase()
|
||||||
|
);
|
||||||
|
if (!definition) {
|
||||||
|
definition = { name: rackName };
|
||||||
|
draft.racks.push(definition);
|
||||||
|
}
|
||||||
|
if (value.trim() && Number.isInteger(parsed) && parsed >= 1) {
|
||||||
|
definition.heightU = parsed;
|
||||||
|
} else {
|
||||||
|
delete definition.heightU;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ autosave: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRackDefinition() {
|
||||||
|
const existing = new Set(rackNames.map((name) => name.toLowerCase()));
|
||||||
|
let candidate = 'New Rack';
|
||||||
|
for (let index = 2; existing.has(candidate.toLowerCase()) && index < 100; index += 1) {
|
||||||
|
candidate = `New Rack ${index}`;
|
||||||
|
}
|
||||||
|
if (existing.has(candidate.toLowerCase())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mutateDraft(
|
||||||
|
(draft) => {
|
||||||
|
draft.racks ??= [];
|
||||||
|
draft.racks.push({ name: candidate });
|
||||||
|
},
|
||||||
|
{ autosave: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRackDefinition(rackName: string) {
|
||||||
|
mutateDraft(
|
||||||
|
(draft) => {
|
||||||
|
if (!draft.racks) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draft.racks = draft.racks.filter(
|
||||||
|
(rack) => rack.name.toLowerCase() !== rackName.toLowerCase()
|
||||||
|
);
|
||||||
|
if (draft.racks.length === 0) {
|
||||||
|
delete draft.racks;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ autosave: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$: 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 ')}.`
|
||||||
@@ -1620,6 +1723,7 @@
|
|||||||
|
|
||||||
function closeSelectedTargetModal() {
|
function closeSelectedTargetModal() {
|
||||||
selectedTarget = null;
|
selectedTarget = null;
|
||||||
|
rackNewNameTarget = null;
|
||||||
clearIconSearch();
|
clearIconSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2011,7 +2115,50 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if vlanLegend.length > 0}
|
{#if diagramViewMode === 'rack'}
|
||||||
|
<div class="racks-manager">
|
||||||
|
<h3>Racks</h3>
|
||||||
|
{#each rackLayout.racks as rack (rack.name)}
|
||||||
|
<div class="racks-manager-row">
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rack.name}
|
||||||
|
on:change={(event) =>
|
||||||
|
renameRackDefinition(rack.name, (event.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Units
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder={String(rack.heightU)}
|
||||||
|
value={rack.declaredHeightU ?? ''}
|
||||||
|
on:change={(event) =>
|
||||||
|
setRackHeightU(rack.name, (event.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{#if rack.declared && rack.slots.length === 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="danger"
|
||||||
|
title="Remove this empty rack"
|
||||||
|
on:click={() => removeRackDefinition(rack.name)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div class="inline-actions">
|
||||||
|
<button type="button" on:click={addRackDefinition}>Add Rack</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if vlanLegend.length > 0 && diagramViewMode !== 'rack'}
|
||||||
<div class="vlan-legend" role="group" aria-label="VLAN legend">
|
<div class="vlan-legend" role="group" aria-label="VLAN legend">
|
||||||
{#each vlanLegend as entry (entry.vlanId)}
|
{#each vlanLegend as entry (entry.vlanId)}
|
||||||
<button
|
<button
|
||||||
@@ -2206,11 +2353,11 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if warnings.length + ipamWarnings.length + rackLayout.overlaps.length > 0}
|
{#if warnings.length + ipamWarnings.length + rackLayout.warnings.length > 0}
|
||||||
<details class="warnings-panel">
|
<details class="warnings-panel">
|
||||||
<summary>Data warnings ({warnings.length + ipamWarnings.length + rackLayout.overlaps.length})</summary>
|
<summary>Data warnings ({warnings.length + ipamWarnings.length + rackLayout.warnings.length})</summary>
|
||||||
<ul>
|
<ul>
|
||||||
{#each [...warnings, ...ipamWarnings, ...rackLayout.overlaps] as warning (warning)}
|
{#each [...warnings, ...ipamWarnings, ...rackLayout.warnings] as warning (warning)}
|
||||||
<li>{warning}</li>
|
<li>{warning}</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -2279,11 +2426,6 @@
|
|||||||
<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>
|
||||||
@@ -2454,21 +2596,37 @@
|
|||||||
<h4>Rack</h4>
|
<h4>Rack</h4>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<label>
|
<label>
|
||||||
Rack Name
|
Rack
|
||||||
<input
|
<select
|
||||||
type="text"
|
value={rackNewNameTarget?.kind === 'machine' &&
|
||||||
list="rack-name-options"
|
rackNewNameTarget?.index === selectedTarget.index
|
||||||
placeholder="Leave blank if not racked"
|
? '__new__'
|
||||||
value={selectedMachine.rack?.name ?? ''}
|
: (selectedMachine.rack?.name ?? '')}
|
||||||
on:change={(event) =>
|
on:change={(event) =>
|
||||||
updateRackField(
|
onRackSelectChange(
|
||||||
'machine',
|
'machine',
|
||||||
selectedTarget.index,
|
selectedTarget.index,
|
||||||
'name',
|
(event.currentTarget as HTMLSelectElement).value
|
||||||
(event.currentTarget as HTMLInputElement).value
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<option value="">Not racked</option>
|
||||||
|
{#each rackNames as rackName (rackName)}
|
||||||
|
<option value={rackName}>{rackName}</option>
|
||||||
|
{/each}
|
||||||
|
<option value="__new__">New rack…</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{#if rackNewNameTarget?.kind === 'machine' && rackNewNameTarget?.index === selectedTarget.index}
|
||||||
|
<label>
|
||||||
|
New Rack Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Lab Rack"
|
||||||
|
on:change={(event) =>
|
||||||
|
submitNewRackName((event.currentTarget as HTMLInputElement).value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{/if}
|
||||||
<label>
|
<label>
|
||||||
Bottom U
|
Bottom U
|
||||||
<input
|
<input
|
||||||
@@ -2810,21 +2968,37 @@
|
|||||||
<h4>Rack</h4>
|
<h4>Rack</h4>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<label>
|
<label>
|
||||||
Rack Name
|
Rack
|
||||||
<input
|
<select
|
||||||
type="text"
|
value={rackNewNameTarget?.kind === 'device' &&
|
||||||
list="rack-name-options"
|
rackNewNameTarget?.index === selectedTarget.index
|
||||||
placeholder="Leave blank if not racked"
|
? '__new__'
|
||||||
value={selectedDevice.rack?.name ?? ''}
|
: (selectedDevice.rack?.name ?? '')}
|
||||||
on:change={(event) =>
|
on:change={(event) =>
|
||||||
updateRackField(
|
onRackSelectChange(
|
||||||
'device',
|
'device',
|
||||||
selectedTarget.index,
|
selectedTarget.index,
|
||||||
'name',
|
(event.currentTarget as HTMLSelectElement).value
|
||||||
(event.currentTarget as HTMLInputElement).value
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<option value="">Not racked</option>
|
||||||
|
{#each rackNames as rackName (rackName)}
|
||||||
|
<option value={rackName}>{rackName}</option>
|
||||||
|
{/each}
|
||||||
|
<option value="__new__">New rack…</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{#if rackNewNameTarget?.kind === 'device' && rackNewNameTarget?.index === selectedTarget.index}
|
||||||
|
<label>
|
||||||
|
New Rack Name
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Lab Rack"
|
||||||
|
on:change={(event) =>
|
||||||
|
submitNewRackName((event.currentTarget as HTMLInputElement).value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
{/if}
|
||||||
<label>
|
<label>
|
||||||
Bottom U
|
Bottom U
|
||||||
<input
|
<input
|
||||||
@@ -3411,6 +3585,79 @@
|
|||||||
background: color-mix(in oklab, var(--panel-bg) 82%, #3b82f6 18%);
|
background: color-mix(in oklab, var(--panel-bg) 82%, #3b82f6 18%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.racks-manager {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.75rem;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
z-index: 14;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
background: color-mix(in oklab, var(--panel-bg) 93%, transparent 7%);
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
max-width: min(80vw, 320px);
|
||||||
|
max-height: 50%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.racks-manager h3 {
|
||||||
|
margin: 0 0 0.15rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.racks-manager-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 4.4rem auto;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.racks-manager-row label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.racks-manager-row input {
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
background: var(--panel-bg);
|
||||||
|
color: var(--panel-contrast);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.25rem 0.35rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.racks-manager-row button {
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
background: var(--panel-bg);
|
||||||
|
color: var(--panel-contrast);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.racks-manager .inline-actions button {
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
background: var(--panel-bg);
|
||||||
|
color: var(--panel-contrast);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.vlan-legend {
|
.vlan-legend {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0.75rem;
|
left: 0.75rem;
|
||||||
|
|||||||
@@ -14,7 +14,10 @@
|
|||||||
|
|
||||||
function slotStyle(slot: RackSlot, rackHeightU: number): string {
|
function slotStyle(slot: RackSlot, rackHeightU: number): string {
|
||||||
const top = (rackHeightU - (slot.unit + slot.heightU - 1)) * unitHeightPx;
|
const top = (rackHeightU - (slot.unit + slot.heightU - 1)) * unitHeightPx;
|
||||||
return `top: ${top}px; height: ${slot.heightU * unitHeightPx - 4}px;`;
|
const laneWidth = `(100% - 2.45rem) / ${slot.laneCount}`;
|
||||||
|
const left = `calc(2.1rem + (${laneWidth}) * ${slot.laneIndex})`;
|
||||||
|
const width = `calc(${laneWidth} - 4px)`;
|
||||||
|
return `top: ${top}px; height: ${slot.heightU * unitHeightPx - 4}px; left: ${left}; width: ${width};`;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -31,7 +34,7 @@
|
|||||||
<div class="rack-columns">
|
<div class="rack-columns">
|
||||||
{#each layout.racks as rack (rack.name)}
|
{#each layout.racks as rack (rack.name)}
|
||||||
<div class="rack-column">
|
<div class="rack-column">
|
||||||
<h3>{rack.name}</h3>
|
<h3>{rack.name} <span class="rack-height-tag">{rack.heightU}U</span></h3>
|
||||||
<div class="rack-frame" style={`height: ${rack.heightU * unitHeightPx}px;`}>
|
<div class="rack-frame" style={`height: ${rack.heightU * unitHeightPx}px;`}>
|
||||||
{#each Array.from({ length: rack.heightU }, (_, i) => rack.heightU - i) as unit (unit)}
|
{#each Array.from({ length: rack.heightU }, (_, i) => rack.heightU - i) as unit (unit)}
|
||||||
<div class="rack-unit-row" style={`height: ${unitHeightPx}px;`}>
|
<div class="rack-unit-row" style={`height: ${unitHeightPx}px;`}>
|
||||||
@@ -76,7 +79,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 6.5rem 1rem 1rem;
|
padding: 10rem 1rem 1rem;
|
||||||
background: var(--app-bg, transparent);
|
background: var(--app-bg, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +110,12 @@
|
|||||||
color: var(--panel-contrast);
|
color: var(--panel-contrast);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rack-height-tag {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted-text);
|
||||||
|
}
|
||||||
|
|
||||||
.rack-frame {
|
.rack-frame {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: min(86vw, 340px);
|
width: min(86vw, 340px);
|
||||||
@@ -137,8 +146,6 @@
|
|||||||
|
|
||||||
.rack-slot {
|
.rack-slot {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 2.1rem;
|
|
||||||
right: 0.35rem;
|
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -344,6 +344,42 @@ export function deletePort(
|
|||||||
return cloned;
|
return cloned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renameRack(data: NetworkData, previousName: string, nextName: string): NetworkData {
|
||||||
|
const cloned = cloneNetworkData(data);
|
||||||
|
const trimmed = nextName.trim();
|
||||||
|
if (!trimmed || equalsIgnoreCase(previousName, trimmed)) {
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const machine of cloned.machines) {
|
||||||
|
if (machine.rack && equalsIgnoreCase(machine.rack.name, previousName)) {
|
||||||
|
machine.rack.name = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const device of cloned.devices) {
|
||||||
|
if (device.rack && equalsIgnoreCase(device.rack.name, previousName)) {
|
||||||
|
device.rack.name = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const definition = cloned.racks?.find((rack) => equalsIgnoreCase(rack.name, previousName));
|
||||||
|
if (definition) {
|
||||||
|
definition.name = trimmed;
|
||||||
|
}
|
||||||
|
// renaming onto an existing rack merges them; keep the first definition
|
||||||
|
if (cloned.racks) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
cloned.racks = cloned.racks.filter((rack) => {
|
||||||
|
const key = rack.name.toLowerCase();
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
export function setPortCable(
|
export function setPortCable(
|
||||||
data: NetworkData,
|
data: NetworkData,
|
||||||
kind: OwnerKind,
|
kind: OwnerKind,
|
||||||
|
|||||||
+152
-29
@@ -8,31 +8,143 @@ export interface RackSlot {
|
|||||||
heightU: number;
|
heightU: number;
|
||||||
iconKey?: string;
|
iconKey?: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
|
/** Horizontal lane when several items share the same U (e.g. on a shelf). */
|
||||||
|
laneIndex: number;
|
||||||
|
laneCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RackColumn {
|
export interface RackColumn {
|
||||||
name: string;
|
name: string;
|
||||||
heightU: number;
|
heightU: number;
|
||||||
|
declared: boolean;
|
||||||
|
declaredHeightU?: number;
|
||||||
slots: RackSlot[];
|
slots: RackSlot[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RackLayout {
|
export interface RackLayout {
|
||||||
racks: RackColumn[];
|
racks: RackColumn[];
|
||||||
overlaps: string[];
|
warnings: string[];
|
||||||
unrackedCount: number;
|
unrackedCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const minimumRackHeightU = 12;
|
const minimumRackHeightU = 12;
|
||||||
|
|
||||||
|
function slotsOverlap(a: RackSlot, b: RackSlot): boolean {
|
||||||
|
const aTop = a.unit + a.heightU - 1;
|
||||||
|
const bTop = b.unit + b.heightU - 1;
|
||||||
|
return a.unit <= bTop && b.unit <= aTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameRange(a: RackSlot, b: RackSlot): boolean {
|
||||||
|
return a.unit === b.unit && a.heightU === b.heightU;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns horizontal lanes so overlapping items render side by side.
|
||||||
|
* Items occupying the exact same U range are treated as sharing a shelf and
|
||||||
|
* do not warn; partially overlapping ranges are reported as conflicts.
|
||||||
|
*/
|
||||||
|
function assignLanes(slots: RackSlot[], rackName: string, warnings: string[]) {
|
||||||
|
// connected components of the overlap graph
|
||||||
|
const componentOf = new Map<RackSlot, number>();
|
||||||
|
let componentCount = 0;
|
||||||
|
for (const slot of slots) {
|
||||||
|
const touching = slots.filter((other) => componentOf.has(other) && slotsOverlap(slot, other));
|
||||||
|
const componentIds = new Set(touching.map((other) => componentOf.get(other) ?? 0));
|
||||||
|
if (componentIds.size === 0) {
|
||||||
|
componentOf.set(slot, componentCount);
|
||||||
|
componentCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [target] = [...componentIds].sort((a, b) => a - b);
|
||||||
|
componentOf.set(slot, target);
|
||||||
|
for (const [other, id] of componentOf.entries()) {
|
||||||
|
if (componentIds.has(id)) {
|
||||||
|
componentOf.set(other, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentSlots = new Map<number, RackSlot[]>();
|
||||||
|
for (const slot of slots) {
|
||||||
|
const id = componentOf.get(slot) ?? 0;
|
||||||
|
const members = componentSlots.get(id) ?? [];
|
||||||
|
members.push(slot);
|
||||||
|
componentSlots.set(id, members);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const members of componentSlots.values()) {
|
||||||
|
const ordered = [...members].sort((a, b) => a.unit - b.unit || a.name.localeCompare(b.name));
|
||||||
|
const lanes: RackSlot[][] = [];
|
||||||
|
for (const slot of ordered) {
|
||||||
|
let placed = false;
|
||||||
|
for (const [laneIndex, lane] of lanes.entries()) {
|
||||||
|
if (!lane.some((other) => slotsOverlap(slot, other))) {
|
||||||
|
lane.push(slot);
|
||||||
|
slot.laneIndex = laneIndex;
|
||||||
|
placed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!placed) {
|
||||||
|
slot.laneIndex = lanes.length;
|
||||||
|
lanes.push([slot]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const slot of members) {
|
||||||
|
slot.laneCount = lanes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < ordered.length; i += 1) {
|
||||||
|
for (let j = i + 1; j < ordered.length; j += 1) {
|
||||||
|
const a = ordered[i];
|
||||||
|
const b = ordered[j];
|
||||||
|
if (slotsOverlap(a, b) && !sameRange(a, b)) {
|
||||||
|
warnings.push(
|
||||||
|
`Rack "${rackName}": "${a.name}" (U${a.unit}–U${a.unit + a.heightU - 1}) partially overlaps "${b.name}" (U${b.unit}–U${b.unit + b.heightU - 1}).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildRackLayout(data: NetworkData): RackLayout {
|
export function buildRackLayout(data: NetworkData): RackLayout {
|
||||||
const slotsByRack = new Map<string, RackSlot[]>();
|
const slotsByRack = new Map<string, RackSlot[]>();
|
||||||
|
const canonicalNames = new Map<string, string>();
|
||||||
let unrackedCount = 0;
|
let unrackedCount = 0;
|
||||||
|
|
||||||
const place = (slot: Omit<RackSlot, 'heightU'> & { heightU?: number }, rackName: string) => {
|
const canonical = (rawName: string): string => {
|
||||||
const key = rackName.trim();
|
const trimmed = rawName.trim();
|
||||||
const slots = slotsByRack.get(key) ?? [];
|
const key = trimmed.toLowerCase();
|
||||||
slots.push({ ...slot, heightU: Math.max(1, Math.round(slot.heightU ?? 1)) });
|
const existing = canonicalNames.get(key);
|
||||||
slotsByRack.set(key, slots);
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
canonicalNames.set(key, trimmed);
|
||||||
|
return trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const definition of data.racks ?? []) {
|
||||||
|
const name = canonical(definition.name);
|
||||||
|
if (!slotsByRack.has(name)) {
|
||||||
|
slotsByRack.set(name, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const place = (
|
||||||
|
slot: Omit<RackSlot, 'heightU' | 'laneIndex' | 'laneCount'> & { heightU?: number },
|
||||||
|
rackName: string
|
||||||
|
) => {
|
||||||
|
const name = canonical(rackName);
|
||||||
|
const slots = slotsByRack.get(name) ?? [];
|
||||||
|
slots.push({
|
||||||
|
...slot,
|
||||||
|
heightU: Math.max(1, Math.round(slot.heightU ?? 1)),
|
||||||
|
laneIndex: 0,
|
||||||
|
laneCount: 1
|
||||||
|
});
|
||||||
|
slotsByRack.set(name, slots);
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [index, machine] of data.machines.entries()) {
|
for (const [index, machine] of data.machines.entries()) {
|
||||||
@@ -72,46 +184,57 @@ export function buildRackLayout(data: NetworkData): RackLayout {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const overlaps: string[] = [];
|
const declaredByName = new Map(
|
||||||
|
(data.racks ?? []).map((definition) => [definition.name.trim().toLowerCase(), definition])
|
||||||
|
);
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
const racks: RackColumn[] = [...slotsByRack.entries()]
|
const racks: RackColumn[] = [...slotsByRack.entries()]
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
.map(([name, slots]) => {
|
.map(([name, slots]) => {
|
||||||
const sorted = [...slots].sort((a, b) => b.unit - a.unit || a.name.localeCompare(b.name));
|
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));
|
assignLanes(sorted, name, warnings);
|
||||||
for (let i = 0; i < sorted.length; i += 1) {
|
const topUnit =
|
||||||
for (let j = i + 1; j < sorted.length; j += 1) {
|
sorted.length > 0 ? Math.max(...sorted.map((slot) => slot.unit + slot.heightU - 1)) : 0;
|
||||||
const a = sorted[i];
|
const definition = declaredByName.get(name.toLowerCase());
|
||||||
const b = sorted[j];
|
const declaredHeightU = definition?.heightU;
|
||||||
const aTop = a.unit + a.heightU - 1;
|
if (typeof declaredHeightU === 'number' && topUnit > declaredHeightU) {
|
||||||
const bTop = b.unit + b.heightU - 1;
|
warnings.push(
|
||||||
if (a.unit <= bTop && b.unit <= aTop) {
|
`Rack "${name}" is declared as ${declaredHeightU}U but items reach U${topUnit}.`
|
||||||
overlaps.push(
|
|
||||||
`Rack "${name}": "${a.name}" (U${a.unit}–U${aTop}) overlaps "${b.name}" (U${b.unit}–U${bTop}).`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
const heightU =
|
||||||
}
|
typeof declaredHeightU === 'number'
|
||||||
|
? Math.max(declaredHeightU, topUnit)
|
||||||
|
: Math.max(minimumRackHeightU, topUnit);
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
heightU: Math.max(minimumRackHeightU, topUnit),
|
heightU,
|
||||||
|
declared: Boolean(definition),
|
||||||
|
declaredHeightU,
|
||||||
slots: sorted
|
slots: sorted
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return { racks, overlaps, unrackedCount };
|
return { racks, warnings, unrackedCount };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function knownRackNames(data: NetworkData): string[] {
|
export function knownRackNames(data: NetworkData): string[] {
|
||||||
const names = new Set<string>();
|
const names = new Map<string, string>();
|
||||||
for (const machine of data.machines) {
|
const add = (name: string | undefined) => {
|
||||||
if (machine.rack?.name) {
|
const trimmed = name?.trim();
|
||||||
names.add(machine.rack.name);
|
if (trimmed && !names.has(trimmed.toLowerCase())) {
|
||||||
|
names.set(trimmed.toLowerCase(), trimmed);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
for (const definition of data.racks ?? []) {
|
||||||
|
add(definition.name);
|
||||||
|
}
|
||||||
|
for (const machine of data.machines) {
|
||||||
|
add(machine.rack?.name);
|
||||||
}
|
}
|
||||||
for (const device of data.devices) {
|
for (const device of data.devices) {
|
||||||
if (device.rack?.name) {
|
add(device.rack?.name);
|
||||||
names.add(device.rack.name);
|
|
||||||
}
|
}
|
||||||
}
|
return [...names.values()].sort((a, b) => a.localeCompare(b));
|
||||||
return [...names].sort((a, b) => a.localeCompare(b));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
* @typedef {{ machineName: string; ipAddress: string; role: string; operatingSystem: string; iconKey?: string; notes?: string; software: { vms: VM[] }; hardware: Hardware; ports?: Port[]; rack?: RackPlacement }} Machine
|
* @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 {{ 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 {{ name: string; heightU?: number }} RackDefinition
|
||||||
|
* @typedef {{ machines: Machine[]; devices: NetworkDevice[]; subnets?: Subnet[]; racks?: RackDefinition[] }} NetworkData
|
||||||
* @typedef {{ valid: boolean; errors: ValidationIssue[]; data?: NetworkData }} ValidationResult
|
* @typedef {{ valid: boolean; errors: ValidationIssue[]; data?: NetworkData }} ValidationResult
|
||||||
* @typedef {{ allowEmpty?: boolean; optional?: boolean }} ReadStringOptions
|
* @typedef {{ allowEmpty?: boolean; optional?: boolean }} ReadStringOptions
|
||||||
* @typedef {{ optional?: boolean; min?: number }} ReadNumberOptions
|
* @typedef {{ optional?: boolean; min?: number }} ReadNumberOptions
|
||||||
@@ -474,6 +475,28 @@ function normalizeSubnet(issues, path, value) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ValidationIssue[]} issues
|
||||||
|
* @param {string} path
|
||||||
|
* @param {unknown} value
|
||||||
|
* @returns {RackDefinition | null}
|
||||||
|
*/
|
||||||
|
function normalizeRackDefinition(issues, path, value) {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
issues.push({ path, message: 'must be an object (rack)' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const name = readString(issues, `${path}.name`, value.name);
|
||||||
|
const heightU = readNumber(issues, `${path}.heightU`, value.heightU, { optional: true, min: 1 });
|
||||||
|
if (!name) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
...(typeof heightU === 'number' ? { heightU } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {unknown} value
|
* @param {unknown} value
|
||||||
* @returns {ValidationResult}
|
* @returns {ValidationResult}
|
||||||
@@ -577,10 +600,38 @@ export function validateNetworkData(value) {
|
|||||||
seenCidrs.add(subnet.cidr);
|
seenCidrs.add(subnet.cidr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const racksRaw = value.racks;
|
||||||
|
const racksProvided = racksRaw !== undefined;
|
||||||
|
if (racksProvided && !Array.isArray(racksRaw)) {
|
||||||
|
issues.push({ path: '$.racks', message: 'must be an array when provided' });
|
||||||
|
}
|
||||||
|
const racks = [];
|
||||||
|
const rackEntries = [];
|
||||||
|
for (const [rackIndex, rackValue] of (Array.isArray(racksRaw) ? racksRaw : []).entries()) {
|
||||||
|
const rack = normalizeRackDefinition(issues, `$.racks[${rackIndex}]`, rackValue);
|
||||||
|
if (rack) {
|
||||||
|
racks.push(rack);
|
||||||
|
rackEntries.push({ rack, originalIndex: rackIndex });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seenRackNames = new Set();
|
||||||
|
for (const { rack, originalIndex } of rackEntries) {
|
||||||
|
const key = rack.name.toLowerCase();
|
||||||
|
if (seenRackNames.has(key)) {
|
||||||
|
issues.push({
|
||||||
|
path: `$.racks[${originalIndex}].name`,
|
||||||
|
message: `duplicate rack name "${rack.name}"`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seenRackNames.add(key);
|
||||||
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
machines,
|
machines,
|
||||||
devices,
|
devices,
|
||||||
...(subnetsProvided ? { subnets } : {})
|
...(subnetsProvided ? { subnets } : {}),
|
||||||
|
...(racksProvided ? { racks } : {})
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -72,8 +72,14 @@ export interface Subnet {
|
|||||||
vlanId?: number;
|
vlanId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RackDefinition {
|
||||||
|
name: string;
|
||||||
|
heightU?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NetworkData {
|
export interface NetworkData {
|
||||||
machines: Machine[];
|
machines: Machine[];
|
||||||
devices: NetworkDevice[];
|
devices: NetworkDevice[];
|
||||||
subnets?: Subnet[];
|
subnets?: Subnet[];
|
||||||
|
racks?: RackDefinition[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -556,7 +556,11 @@
|
|||||||
"port": "11"
|
"port": "11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"rack": {
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"unit": 3
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "3DS",
|
"name": "3DS",
|
||||||
@@ -598,7 +602,11 @@
|
|||||||
"port": "20"
|
"port": "20"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"rack": {
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"unit": 3
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "UPS Pi",
|
"name": "UPS Pi",
|
||||||
@@ -636,5 +644,11 @@
|
|||||||
"name": "IoT",
|
"name": "IoT",
|
||||||
"vlanId": 20
|
"vlanId": 20
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"racks": [
|
||||||
|
{
|
||||||
|
"name": "Lab Rack",
|
||||||
|
"heightU": 12
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user