feat(*): Make racks first-class with dropdown selection and shelf support

This commit is contained in:
Josh Creek
2026-07-12 21:07:27 +01:00
parent b4f5f12731
commit ec7598e15c
8 changed files with 558 additions and 68 deletions
+6
View File
@@ -64,5 +64,11 @@
"name": "LAN", "name": "LAN",
"vlanId": 1 "vlanId": 1
} }
],
"racks": [
{
"name": "Example Rack",
"heightU": 24
}
] ]
} }
+276 -29
View File
@@ -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> </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> <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> </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> <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;
+12 -5
View File
@@ -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;
+36
View File
@@ -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,
+153 -30
View File
@@ -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].sort((a, b) => a.localeCompare(b)); return [...names.values()].sort((a, b) => a.localeCompare(b));
} }
+53 -2
View File
@@ -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 {
+6
View File
@@ -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[];
} }
+16 -2
View File
@@ -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
}
] ]
} }