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
+276 -29
View File
@@ -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;
+12 -5
View File
@@ -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;