From 71d631efbeb30df95fa09ae656f5b9afc83407d7 Mon Sep 17 00:00:00 2001 From: mpuckett Date: Thu, 14 May 2026 02:38:32 -0500 Subject: [PATCH] Added PDU Component --- README.md | 12 +- src/App.tsx | 569 +++++++++++++++++++++++++++++------ src/data/componentLibrary.ts | 1 + src/types.ts | 20 ++ src/utils/model.ts | 47 ++- 5 files changed, 545 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 49a331d..9977a04 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,15 @@ Datacenter Modeler is a React-based web application for designing datacenter rac - Copper or fiber optic medium - Number of ports - Port identification +- Edit PDU-specific properties: + - Horizontal rack mount or vertical side mount + - Horizontal size from 1U to 12U + - Vertical width from 1U to 3U + - Left or right side placement for vertical PDUs + - Input voltage + - Dynamic output ports with per-port voltage and amperage + - Managed PDU status + - IP address/hostname, URL, and user for managed PDUs - Save diagrams as JSON - Load previously saved JSON diagrams - Browser autosave with `localStorage` @@ -53,6 +62,7 @@ The left-side palette includes: - Storage Array - Storage Switch - Patch Panel +- PDU - Cable Management - KVM - KVM (Console) @@ -132,7 +142,7 @@ Diagram data is stored as JSON with this top-level shape: } ``` -Each rack/cabinet contains its own metadata and an array of installed equipment. Equipment records include type, name, size, U position, manufacturer/model details, asset information, power, notes, and display color. Patch panel records also include medium, port count, and port identification metadata. +Each rack/cabinet contains its own metadata and an array of installed equipment. Equipment records include type, name, size, U position, manufacturer/model details, asset information, power, notes, and display color. Patch panel records also include medium, port count, and port identification metadata. PDU records include mounting orientation, side placement for vertical PDUs, input voltage, output port definitions, and managed-device connection metadata. ## Project Structure diff --git a/src/App.tsx b/src/App.tsx index c66d59b..937b939 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ import { } from '@mui/material'; import type { SelectChangeEvent } from '@mui/material'; import { + Add, Cable, DarkMode, Delete, @@ -42,6 +43,7 @@ import { Inventory2, LightMode, PictureAsPdf, + Power, Router, Save, Security, @@ -60,6 +62,7 @@ import { clampNumber, createDiagram, createEquipment, + createId, createRack, findAvailableU, getOccupancy, @@ -81,6 +84,7 @@ const icons: Record = { 'storage-array': , 'storage-switch': , 'patch-panel': , + pdu: , 'cable-management': , kvm: , 'kvm-console': , @@ -260,6 +264,29 @@ export default function App() { return; } + if (equipment.type === 'pdu' && equipment.pduMount === 'vertical') { + commitDiagram((current) => ({ + ...current, + racks: current.racks.map((rack) => { + if (rack.id === sourceRack.id && rack.id === destinationRack.id) { + return rack; + } + + if (rack.id === sourceRack.id) { + return { ...rack, equipment: rack.equipment.filter((item) => item.id !== equipment.id) }; + } + + if (rack.id === destinationRack.id) { + return { ...rack, equipment: [...rack.equipment, { ...equipment, uStart: 1 }] }; + } + + return rack; + }), + })); + setSelection({ kind: 'equipment', rackId, equipmentId: equipment.id }); + return; + } + const nextU = findAvailableU( destinationRack, equipment.sizeU, @@ -313,7 +340,12 @@ export default function App() { return; } - const highestOccupiedU = Math.max(0, ...rack.equipment.map((item) => item.uStart + item.sizeU - 1)); + const highestOccupiedU = Math.max( + 0, + ...rack.equipment + .filter((item) => !(item.type === 'pdu' && item.pduMount === 'vertical')) + .map((item) => item.uStart + item.sizeU - 1), + ); if (highestOccupiedU > sizeU) { setNotice(`Rack size must be at least ${highestOccupiedU}U for the installed equipment.`); return; @@ -343,15 +375,33 @@ export default function App() { return; } - const nextSize = clampNumber(patch.sizeU ?? equipment.sizeU, 1, 24, equipment.sizeU); - const nextU = clampNumber(patch.uStart ?? equipment.uStart, 1, rack.sizeU - nextSize + 1, equipment.uStart); + const nextMount = patch.pduMount ?? equipment.pduMount; - if (!isRangeAvailable(rack, nextU, nextSize, equipmentId)) { + if (equipment.type === 'pdu' && nextMount === 'vertical') { + updateEquipment(rackId, equipmentId, { + ...patch, + pduMount: 'vertical', + sizeU: clampNumber(patch.sizeU ?? equipment.sizeU, 1, 3, 1), + uStart: 1, + pduSide: patch.pduSide ?? equipment.pduSide ?? 'right', + }); + return; + } + + const maxSize = equipment.type === 'pdu' ? 12 : 24; + const nextSize = clampNumber(patch.sizeU ?? equipment.sizeU, 1, maxSize, equipment.sizeU); + const maxStart = Math.max(1, rack.sizeU - nextSize + 1); + const requestedU = clampNumber(patch.uStart ?? equipment.uStart, 1, maxStart, equipment.uStart); + const nextU = isRangeAvailable(rack, requestedU, nextSize, equipmentId) + ? requestedU + : findAvailableU(rack, nextSize, requestedU + nextSize - 1, equipmentId); + + if (nextU === null) { setNotice('That size or position overlaps another component.'); return; } - updateEquipment(rackId, equipmentId, { ...patch, sizeU: nextSize, uStart: nextU }); + updateEquipment(rackId, equipmentId, { ...patch, pduMount: nextMount, sizeU: nextSize, uStart: nextU }); }; const deleteSelection = () => { @@ -466,7 +516,15 @@ export default function App() { item.type === 'patch-panel' ? ` | ${item.patchPanelMedium === 'fiberoptic' ? 'Fiber optic' : 'Copper'} | ${item.patchPanelPorts} ports` : ''; - const line = `U${item.uStart}-${item.uStart + item.sizeU - 1}: ${item.name} | ${item.manufacturer || 'n/a'} ${item.model || ''} | ${item.assetTag || 'no asset tag'}${patchPanelDetails}`; + const pduDetails = + item.type === 'pdu' + ? ` | ${item.pduMount === 'vertical' ? `Vertical ${item.pduSide}, ${item.sizeU}U wide` : `Horizontal ${item.sizeU}U`} | In ${item.pduInputVoltage || 'n/a'} | ${item.pduOutputPorts.length} outputs${item.pduManaged ? ` | Managed ${item.pduHostname || 'no host'}` : ''}` + : ''; + const rackPosition = + item.type === 'pdu' && item.pduMount === 'vertical' + ? `${item.pduSide === 'left' ? 'Left' : 'Right'} side` + : `U${item.uStart}-${item.uStart + item.sizeU - 1}`; + const line = `${rackPosition}: ${item.name} | ${item.manufacturer || 'n/a'} ${item.model || ''} | ${item.assetTag || 'no asset tag'}${patchPanelDetails}${pduDetails}`; pdf.text(line.slice(0, 115), margin + 12, y); y += 14; }); @@ -706,6 +764,8 @@ function RackView({ }) { const occupancy = getOccupancy(rack); const rackHeight = rack.sizeU * RACK_ROW_HEIGHT; + const horizontalEquipment = rack.equipment.filter((item) => !(item.type === 'pdu' && item.pduMount === 'vertical')); + const verticalPdus = rack.equipment.filter((item) => item.type === 'pdu' && item.pduMount === 'vertical'); return ( (theme.palette.mode === 'dark' ? '#101820' : '#f8fafc'), - backgroundImage: (theme) => - `linear-gradient(${alpha(theme.palette.text.primary, 0.08)} 1px, transparent 1px)`, - backgroundSize: `100% ${RACK_ROW_HEIGHT}px`, - borderLeft: 10, - borderRight: 10, - borderColor: (theme) => (theme.palette.mode === 'dark' ? '#263442' : '#cbd5e1'), + bgcolor: (theme) => (theme.palette.mode === 'dark' ? '#0b1118' : '#edf2f7'), }} > - {Array.from({ length: rack.sizeU }, (_, index) => rack.sizeU - index).map((u) => ( - - {u} - - ))} + (theme.palette.mode === 'dark' ? '#101820' : '#f8fafc'), + backgroundImage: (theme) => + `linear-gradient(${alpha(theme.palette.text.primary, 0.08)} 1px, transparent 1px)`, + backgroundSize: `100% ${RACK_ROW_HEIGHT}px`, + borderLeft: 10, + borderRight: 10, + borderColor: (theme) => (theme.palette.mode === 'dark' ? '#263442' : '#cbd5e1'), + }} + > + {Array.from({ length: rack.sizeU }, (_, index) => rack.sizeU - index).map((u) => ( + + {u} + + ))} - {[...rack.equipment] - .sort((a, b) => b.uStart - a.uStart) - .map((item) => { - const top = (rack.sizeU - (item.uStart + item.sizeU - 1)) * RACK_ROW_HEIGHT; - const selectedItem = selected?.kind === 'equipment' && selected.equipmentId === item.id; - return ( - { - event.stopPropagation(); - setDragPayload(event, { source: 'equipment', rackId: rack.id, equipmentId: item.id }); - }} - onClick={(event) => { - event.stopPropagation(); - onSelectEquipment(item.id); - }} + {[...horizontalEquipment] + .sort((a, b) => b.uStart - a.uStart) + .map((item) => { + const top = (rack.sizeU - (item.uStart + item.sizeU - 1)) * RACK_ROW_HEIGHT; + const selectedItem = selected?.kind === 'equipment' && selected.equipmentId === item.id; + return ( + { + event.stopPropagation(); + setDragPayload(event, { source: 'equipment', rackId: rack.id, equipmentId: item.id }); + }} + onClick={(event) => { + event.stopPropagation(); + onSelectEquipment(item.id); + }} + sx={{ + position: 'absolute', + left: 24, + right: 8, + top, + height: item.sizeU * RACK_ROW_HEIGHT, + minHeight: RACK_ROW_HEIGHT, + border: 2, + borderColor: selectedItem ? 'secondary.main' : alpha('#000000', 0.16), + bgcolor: item.color, + color: '#071018', + px: 0.75, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 1, + cursor: 'grab', + overflow: 'hidden', + boxShadow: selectedItem ? 4 : 1, + }} + > + + {item.name} + + + U{item.uStart}-{item.uStart + item.sizeU - 1} + + + ); + })} + + + {verticalPdus.map((item) => { + const selectedItem = selected?.kind === 'equipment' && selected.equipmentId === item.id; + const width = 12 + item.sizeU * 7; + return ( + { + event.stopPropagation(); + setDragPayload(event, { source: 'equipment', rackId: rack.id, equipmentId: item.id }); + }} + onClick={(event) => { + event.stopPropagation(); + onSelectEquipment(item.id); + }} + sx={{ + position: 'absolute', + top: 6, + bottom: 6, + width, + left: item.pduSide === 'left' ? 3 : 'auto', + right: item.pduSide === 'right' ? 3 : 'auto', + border: 2, + borderColor: selectedItem ? 'secondary.main' : alpha('#000000', 0.24), + bgcolor: item.color, + color: '#111827', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'grab', + boxShadow: selectedItem ? 4 : 1, + overflow: 'hidden', + zIndex: 2, + }} + > + - - {item.name} - - - U{item.uStart}-{item.uStart + item.sizeU - 1} - - - ); - })} + {item.name} + + + ); + })} ); @@ -903,32 +1024,114 @@ function PropertiesPanel({ fullWidth /> - - - onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, { - sizeU: clampNumber(event.target.value, 1, 24, selectedEquipment.sizeU), - }) - } - fullWidth - /> - - onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, { - uStart: clampNumber(event.target.value, 1, selectedEquipmentRack.sizeU, selectedEquipment.uStart), - }) - } - fullWidth - /> - + {selectedEquipment.type === 'pdu' ? ( + <> + + Mounting + + + {selectedEquipment.pduMount === 'vertical' ? ( + + + onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, { + sizeU: clampNumber(event.target.value, 1, 3, selectedEquipment.sizeU), + }) + } + fullWidth + /> + + Side + + + + ) : ( + + + onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, { + sizeU: clampNumber(event.target.value, 1, 12, selectedEquipment.sizeU), + }) + } + fullWidth + /> + + onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, { + uStart: clampNumber(event.target.value, 1, selectedEquipmentRack.sizeU, selectedEquipment.uStart), + }) + } + fullWidth + /> + + )} + + ) : ( + + + onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, { + sizeU: clampNumber(event.target.value, 1, 24, selectedEquipment.sizeU), + }) + } + fullWidth + /> + + onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, { + uStart: clampNumber(event.target.value, 1, selectedEquipmentRack.sizeU, selectedEquipment.uStart), + }) + } + fullWidth + /> + + )} )} + {selectedEquipment.type === 'pdu' && ( + <> + + + Power Distribution + + + onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { + pduInputVoltage: event.target.value, + }) + } + placeholder="208V 3-phase, 220V, 120V" + fullWidth + /> + + + + Output Ports + + + + {selectedEquipment.pduOutputPorts.length === 0 ? ( + + No output ports defined. + + ) : ( + selectedEquipment.pduOutputPorts.map((port) => ( + + + onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { + pduOutputPorts: selectedEquipment.pduOutputPorts.map((item) => + item.id === port.id ? { ...item, label: event.target.value } : item, + ), + }) + } + sx={{ flex: 1.3 }} + /> + + onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { + pduOutputPorts: selectedEquipment.pduOutputPorts.map((item) => + item.id === port.id ? { ...item, voltage: clampNumber(event.target.value, 0, 1000, port.voltage) } : item, + ), + }) + } + sx={{ flex: 1 }} + /> + + onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { + pduOutputPorts: selectedEquipment.pduOutputPorts.map((item) => + item.id === port.id ? { ...item, amps: clampNumber(event.target.value, 0, 1000, port.amps) } : item, + ), + }) + } + sx={{ flex: 1 }} + /> + + + onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { + pduOutputPorts: selectedEquipment.pduOutputPorts.filter((item) => item.id !== port.id), + }) + } + > + + + + + )) + )} + + + + Managed PDU + + + onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { + pduManaged: (value as number) === 1, + }) + } + /> + + {selectedEquipment.pduManaged && ( + <> + + onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { + pduHostname: event.target.value, + }) + } + fullWidth + /> + + onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { + pduUrl: event.target.value, + }) + } + fullWidth + /> + + onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { + pduUser: event.target.value, + }) + } + fullWidth + /> + + )} + + )} { + if (item.type === 'pdu' && item.pduMount === 'vertical') { + return true; + } + if (item.id === ignoreEquipmentId) { return true; } @@ -104,7 +116,10 @@ export const isRangeAvailable = ( }; export const getOccupancy = (rack: RackContainer) => { - const used = rack.equipment.reduce((sum, item) => sum + item.sizeU, 0); + const used = rack.equipment.reduce( + (sum, item) => sum + (item.type === 'pdu' && item.pduMount === 'vertical' ? 0 : item.sizeU), + 0, + ); return { used, total: rack.sizeU, @@ -141,7 +156,9 @@ export const normalizeDiagram = (value: unknown): DiagramData => { equipment: Array.isArray(rack.equipment) ? rack.equipment.map((item, itemIndex) => { const libraryItem = getLibraryItem(item.type) || getLibraryItem('server-rack')!; - const sizeU = clampNumber(item.sizeU, 1, 24, libraryItem.defaultU); + const pduMount = item.pduMount === 'vertical' ? 'vertical' : 'horizontal'; + const maxSizeU = libraryItem.type === 'pdu' ? (pduMount === 'vertical' ? 3 : 12) : 24; + const sizeU = clampNumber(item.sizeU, 1, maxSizeU, libraryItem.defaultU); return { id: item.id || createId('equipment'), type: libraryItem.type, @@ -161,6 +178,14 @@ export const normalizeDiagram = (value: unknown): DiagramData => { : '', patchPanelPorts: clampNumber(item.patchPanelPorts, 0, 10000, libraryItem.type === 'patch-panel' ? 24 : 0), patchPanelPortIdentification: item.patchPanelPortIdentification || '', + pduMount, + pduSide: item.pduSide === 'left' ? 'left' : 'right', + pduInputVoltage: item.pduInputVoltage || '', + pduOutputPorts: normalizePduOutputPorts(item.pduOutputPorts), + pduManaged: Boolean(item.pduManaged), + pduHostname: item.pduHostname || '', + pduUrl: item.pduUrl || '', + pduUser: item.pduUser || '', notes: item.notes || '', color: item.color || libraryItem.color, }; @@ -170,6 +195,22 @@ export const normalizeDiagram = (value: unknown): DiagramData => { }; }; +const normalizePduOutputPorts = (ports: unknown): PduOutputPort[] => { + if (!Array.isArray(ports)) { + return []; + } + + return ports.map((port, index) => { + const candidate = port as Partial; + return { + id: candidate.id || createId('pdu-port'), + label: candidate.label || `Port ${index + 1}`, + voltage: clampNumber(candidate.voltage, 0, 1000, 120), + amps: clampNumber(candidate.amps, 0, 1000, 10), + }; + }); +}; + export const clampNumber = (value: unknown, min: number, max: number, fallback: number) => { const parsed = Number(value); if (!Number.isFinite(parsed)) {