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",
|
||||
"vlanId": 1
|
||||
}
|
||||
],
|
||||
"racks": [
|
||||
{
|
||||
"name": "Example Rack",
|
||||
"heightU": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
deletePort,
|
||||
renameOwner,
|
||||
renamePort,
|
||||
renameRack,
|
||||
setPortCable,
|
||||
setPortConnection,
|
||||
type OwnerKind,
|
||||
@@ -166,6 +167,108 @@
|
||||
$: rackLayout = buildRackLayout(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);
|
||||
$: ipamWarnings = ipamReport.duplicates.map(
|
||||
(duplicate) => `Duplicate IP ${duplicate.ip}: assigned to ${duplicate.owners.join(' and ')}.`
|
||||
@@ -1620,6 +1723,7 @@
|
||||
|
||||
function closeSelectedTargetModal() {
|
||||
selectedTarget = null;
|
||||
rackNewNameTarget = null;
|
||||
clearIconSearch();
|
||||
}
|
||||
|
||||
@@ -2011,7 +2115,50 @@
|
||||
{/if}
|
||||
</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">
|
||||
{#each vlanLegend as entry (entry.vlanId)}
|
||||
<button
|
||||
@@ -2206,11 +2353,11 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if warnings.length + ipamWarnings.length + rackLayout.overlaps.length > 0}
|
||||
{#if warnings.length + ipamWarnings.length + rackLayout.warnings.length > 0}
|
||||
<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>
|
||||
{#each [...warnings, ...ipamWarnings, ...rackLayout.overlaps] as warning (warning)}
|
||||
{#each [...warnings, ...ipamWarnings, ...rackLayout.warnings] as warning (warning)}
|
||||
<li>{warning}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -2279,11 +2426,6 @@
|
||||
<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>
|
||||
@@ -2454,21 +2596,37 @@
|
||||
<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 ?? ''}
|
||||
Rack
|
||||
<select
|
||||
value={rackNewNameTarget?.kind === 'machine' &&
|
||||
rackNewNameTarget?.index === selectedTarget.index
|
||||
? '__new__'
|
||||
: (selectedMachine.rack?.name ?? '')}
|
||||
on:change={(event) =>
|
||||
updateRackField(
|
||||
onRackSelectChange(
|
||||
'machine',
|
||||
selectedTarget.index,
|
||||
'name',
|
||||
(event.currentTarget as HTMLInputElement).value
|
||||
(event.currentTarget as HTMLSelectElement).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>
|
||||
{/if}
|
||||
<label>
|
||||
Bottom U
|
||||
<input
|
||||
@@ -2810,21 +2968,37 @@
|
||||
<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 ?? ''}
|
||||
Rack
|
||||
<select
|
||||
value={rackNewNameTarget?.kind === 'device' &&
|
||||
rackNewNameTarget?.index === selectedTarget.index
|
||||
? '__new__'
|
||||
: (selectedDevice.rack?.name ?? '')}
|
||||
on:change={(event) =>
|
||||
updateRackField(
|
||||
onRackSelectChange(
|
||||
'device',
|
||||
selectedTarget.index,
|
||||
'name',
|
||||
(event.currentTarget as HTMLInputElement).value
|
||||
(event.currentTarget as HTMLSelectElement).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>
|
||||
{/if}
|
||||
<label>
|
||||
Bottom U
|
||||
<input
|
||||
@@ -3411,6 +3585,79 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
|
||||
@@ -14,7 +14,10 @@
|
||||
|
||||
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;`;
|
||||
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>
|
||||
|
||||
@@ -31,7 +34,7 @@
|
||||
<div class="rack-columns">
|
||||
{#each layout.racks as rack (rack.name)}
|
||||
<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;`}>
|
||||
{#each Array.from({ length: rack.heightU }, (_, i) => rack.heightU - i) as unit (unit)}
|
||||
<div class="rack-unit-row" style={`height: ${unitHeightPx}px;`}>
|
||||
@@ -76,7 +79,7 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: auto;
|
||||
padding: 6.5rem 1rem 1rem;
|
||||
padding: 10rem 1rem 1rem;
|
||||
background: var(--app-bg, transparent);
|
||||
}
|
||||
|
||||
@@ -107,6 +110,12 @@
|
||||
color: var(--panel-contrast);
|
||||
}
|
||||
|
||||
.rack-height-tag {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: var(--muted-text);
|
||||
}
|
||||
|
||||
.rack-frame {
|
||||
position: relative;
|
||||
width: min(86vw, 340px);
|
||||
@@ -137,8 +146,6 @@
|
||||
|
||||
.rack-slot {
|
||||
position: absolute;
|
||||
left: 2.1rem;
|
||||
right: 0.35rem;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -344,6 +344,42 @@ export function deletePort(
|
||||
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(
|
||||
data: NetworkData,
|
||||
kind: OwnerKind,
|
||||
|
||||
+153
-30
@@ -8,31 +8,143 @@ export interface RackSlot {
|
||||
heightU: number;
|
||||
iconKey?: string;
|
||||
subtitle: string;
|
||||
/** Horizontal lane when several items share the same U (e.g. on a shelf). */
|
||||
laneIndex: number;
|
||||
laneCount: number;
|
||||
}
|
||||
|
||||
export interface RackColumn {
|
||||
name: string;
|
||||
heightU: number;
|
||||
declared: boolean;
|
||||
declaredHeightU?: number;
|
||||
slots: RackSlot[];
|
||||
}
|
||||
|
||||
export interface RackLayout {
|
||||
racks: RackColumn[];
|
||||
overlaps: string[];
|
||||
warnings: string[];
|
||||
unrackedCount: number;
|
||||
}
|
||||
|
||||
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 {
|
||||
const slotsByRack = new Map<string, RackSlot[]>();
|
||||
const canonicalNames = new Map<string, string>();
|
||||
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);
|
||||
const canonical = (rawName: string): string => {
|
||||
const trimmed = rawName.trim();
|
||||
const key = trimmed.toLowerCase();
|
||||
const existing = canonicalNames.get(key);
|
||||
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()) {
|
||||
@@ -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()]
|
||||
.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}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
assignLanes(sorted, name, warnings);
|
||||
const topUnit =
|
||||
sorted.length > 0 ? Math.max(...sorted.map((slot) => slot.unit + slot.heightU - 1)) : 0;
|
||||
const definition = declaredByName.get(name.toLowerCase());
|
||||
const declaredHeightU = definition?.heightU;
|
||||
if (typeof declaredHeightU === 'number' && topUnit > declaredHeightU) {
|
||||
warnings.push(
|
||||
`Rack "${name}" is declared as ${declaredHeightU}U but items reach U${topUnit}.`
|
||||
);
|
||||
}
|
||||
const heightU =
|
||||
typeof declaredHeightU === 'number'
|
||||
? Math.max(declaredHeightU, topUnit)
|
||||
: Math.max(minimumRackHeightU, topUnit);
|
||||
return {
|
||||
name,
|
||||
heightU: Math.max(minimumRackHeightU, topUnit),
|
||||
heightU,
|
||||
declared: Boolean(definition),
|
||||
declaredHeightU,
|
||||
slots: sorted
|
||||
};
|
||||
});
|
||||
|
||||
return { racks, overlaps, unrackedCount };
|
||||
return { racks, warnings, 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);
|
||||
const names = new Map<string, string>();
|
||||
const add = (name: string | undefined) => {
|
||||
const trimmed = name?.trim();
|
||||
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) {
|
||||
if (device.rack?.name) {
|
||||
names.add(device.rack.name);
|
||||
}
|
||||
add(device.rack?.name);
|
||||
}
|
||||
return [...names].sort((a, b) => a.localeCompare(b));
|
||||
return [...names.values()].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 {{ name: string; ipAddress: string; type: string; iconKey?: string; notes?: string; ports?: Port[]; rack?: RackPlacement }} NetworkDevice
|
||||
* @typedef {{ cidr: string; name?: string; vlanId?: number }} Subnet
|
||||
* @typedef {{ machines: Machine[]; devices: NetworkDevice[]; subnets?: Subnet[] }} NetworkData
|
||||
* @typedef {{ name: string; heightU?: number }} RackDefinition
|
||||
* @typedef {{ machines: Machine[]; devices: NetworkDevice[]; subnets?: Subnet[]; racks?: RackDefinition[] }} NetworkData
|
||||
* @typedef {{ valid: boolean; errors: ValidationIssue[]; data?: NetworkData }} ValidationResult
|
||||
* @typedef {{ allowEmpty?: boolean; optional?: boolean }} ReadStringOptions
|
||||
* @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
|
||||
* @returns {ValidationResult}
|
||||
@@ -577,10 +600,38 @@ export function validateNetworkData(value) {
|
||||
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 = {
|
||||
machines,
|
||||
devices,
|
||||
...(subnetsProvided ? { subnets } : {})
|
||||
...(subnetsProvided ? { subnets } : {}),
|
||||
...(racksProvided ? { racks } : {})
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -72,8 +72,14 @@ export interface Subnet {
|
||||
vlanId?: number;
|
||||
}
|
||||
|
||||
export interface RackDefinition {
|
||||
name: string;
|
||||
heightU?: number;
|
||||
}
|
||||
|
||||
export interface NetworkData {
|
||||
machines: Machine[];
|
||||
devices: NetworkDevice[];
|
||||
subnets?: Subnet[];
|
||||
racks?: RackDefinition[];
|
||||
}
|
||||
|
||||
@@ -556,7 +556,11 @@
|
||||
"port": "11"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"rack": {
|
||||
"name": "Lab Rack",
|
||||
"unit": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "3DS",
|
||||
@@ -598,7 +602,11 @@
|
||||
"port": "20"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"rack": {
|
||||
"name": "Lab Rack",
|
||||
"unit": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "UPS Pi",
|
||||
@@ -636,5 +644,11 @@
|
||||
"name": "IoT",
|
||||
"vlanId": 20
|
||||
}
|
||||
],
|
||||
"racks": [
|
||||
{
|
||||
"name": "Lab Rack",
|
||||
"heightU": 12
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user