mirror of
https://github.com/jcreek/OpenNetworkDiagram.git
synced 2026-07-14 19:43:44 +00:00
feat(*): Make racks first-class with dropdown selection and shelf support
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user