Added PDU Component

This commit is contained in:
2026-05-14 02:38:32 -05:00
parent 1ec5ab4bf5
commit 71d631efbe
5 changed files with 545 additions and 104 deletions

View File

@@ -31,6 +31,15 @@ Datacenter Modeler is a React-based web application for designing datacenter rac
- Copper or fiber optic medium - Copper or fiber optic medium
- Number of ports - Number of ports
- Port identification - 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 - Save diagrams as JSON
- Load previously saved JSON diagrams - Load previously saved JSON diagrams
- Browser autosave with `localStorage` - Browser autosave with `localStorage`
@@ -53,6 +62,7 @@ The left-side palette includes:
- Storage Array - Storage Array
- Storage Switch - Storage Switch
- Patch Panel - Patch Panel
- PDU
- Cable Management - Cable Management
- KVM - KVM
- KVM (Console) - 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 ## Project Structure

View File

@@ -31,6 +31,7 @@ import {
} from '@mui/material'; } from '@mui/material';
import type { SelectChangeEvent } from '@mui/material'; import type { SelectChangeEvent } from '@mui/material';
import { import {
Add,
Cable, Cable,
DarkMode, DarkMode,
Delete, Delete,
@@ -42,6 +43,7 @@ import {
Inventory2, Inventory2,
LightMode, LightMode,
PictureAsPdf, PictureAsPdf,
Power,
Router, Router,
Save, Save,
Security, Security,
@@ -60,6 +62,7 @@ import {
clampNumber, clampNumber,
createDiagram, createDiagram,
createEquipment, createEquipment,
createId,
createRack, createRack,
findAvailableU, findAvailableU,
getOccupancy, getOccupancy,
@@ -81,6 +84,7 @@ const icons: Record<ComponentType, JSX.Element> = {
'storage-array': <Storage fontSize="small" />, 'storage-array': <Storage fontSize="small" />,
'storage-switch': <Storage fontSize="small" />, 'storage-switch': <Storage fontSize="small" />,
'patch-panel': <Cable fontSize="small" />, 'patch-panel': <Cable fontSize="small" />,
pdu: <Power fontSize="small" />,
'cable-management': <Cable fontSize="small" />, 'cable-management': <Cable fontSize="small" />,
kvm: <Terminal fontSize="small" />, kvm: <Terminal fontSize="small" />,
'kvm-console': <Inventory2 fontSize="small" />, 'kvm-console': <Inventory2 fontSize="small" />,
@@ -260,6 +264,29 @@ export default function App() {
return; 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( const nextU = findAvailableU(
destinationRack, destinationRack,
equipment.sizeU, equipment.sizeU,
@@ -313,7 +340,12 @@ export default function App() {
return; 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) { if (highestOccupiedU > sizeU) {
setNotice(`Rack size must be at least ${highestOccupiedU}U for the installed equipment.`); setNotice(`Rack size must be at least ${highestOccupiedU}U for the installed equipment.`);
return; return;
@@ -343,15 +375,33 @@ export default function App() {
return; return;
} }
const nextSize = clampNumber(patch.sizeU ?? equipment.sizeU, 1, 24, equipment.sizeU); const nextMount = patch.pduMount ?? equipment.pduMount;
const nextU = clampNumber(patch.uStart ?? equipment.uStart, 1, rack.sizeU - nextSize + 1, equipment.uStart);
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.'); setNotice('That size or position overlaps another component.');
return; return;
} }
updateEquipment(rackId, equipmentId, { ...patch, sizeU: nextSize, uStart: nextU }); updateEquipment(rackId, equipmentId, { ...patch, pduMount: nextMount, sizeU: nextSize, uStart: nextU });
}; };
const deleteSelection = () => { const deleteSelection = () => {
@@ -466,7 +516,15 @@ export default function App() {
item.type === 'patch-panel' item.type === 'patch-panel'
? ` | ${item.patchPanelMedium === 'fiberoptic' ? 'Fiber optic' : 'Copper'} | ${item.patchPanelPorts} ports` ? ` | ${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); pdf.text(line.slice(0, 115), margin + 12, y);
y += 14; y += 14;
}); });
@@ -706,6 +764,8 @@ function RackView({
}) { }) {
const occupancy = getOccupancy(rack); const occupancy = getOccupancy(rack);
const rackHeight = rack.sizeU * RACK_ROW_HEIGHT; 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 ( return (
<Paper <Paper
@@ -739,6 +799,15 @@ function RackView({
sx={{ sx={{
position: 'relative', position: 'relative',
height: rackHeight, height: rackHeight,
bgcolor: (theme) => (theme.palette.mode === 'dark' ? '#0b1118' : '#edf2f7'),
}}
>
<Box
sx={{
position: 'absolute',
inset: 0,
left: 34,
right: 34,
bgcolor: (theme) => (theme.palette.mode === 'dark' ? '#101820' : '#f8fafc'), bgcolor: (theme) => (theme.palette.mode === 'dark' ? '#101820' : '#f8fafc'),
backgroundImage: (theme) => backgroundImage: (theme) =>
`linear-gradient(${alpha(theme.palette.text.primary, 0.08)} 1px, transparent 1px)`, `linear-gradient(${alpha(theme.palette.text.primary, 0.08)} 1px, transparent 1px)`,
@@ -766,7 +835,7 @@ function RackView({
</Typography> </Typography>
))} ))}
{[...rack.equipment] {[...horizontalEquipment]
.sort((a, b) => b.uStart - a.uStart) .sort((a, b) => b.uStart - a.uStart)
.map((item) => { .map((item) => {
const top = (rack.sizeU - (item.uStart + item.sizeU - 1)) * RACK_ROW_HEIGHT; const top = (rack.sizeU - (item.uStart + item.sizeU - 1)) * RACK_ROW_HEIGHT;
@@ -814,6 +883,58 @@ function RackView({
); );
})} })}
</Box> </Box>
{verticalPdus.map((item) => {
const selectedItem = selected?.kind === 'equipment' && selected.equipmentId === item.id;
const width = 12 + item.sizeU * 7;
return (
<Box
key={item.id}
draggable
onDragStart={(event) => {
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,
}}
>
<Typography
variant="caption"
sx={{
writingMode: 'vertical-rl',
transform: 'rotate(180deg)',
fontWeight: 900,
lineHeight: 1,
whiteSpace: 'nowrap',
}}
>
{item.name}
</Typography>
</Box>
);
})}
</Box>
</Paper> </Paper>
); );
} }
@@ -903,6 +1024,87 @@ function PropertiesPanel({
fullWidth fullWidth
/> />
<TextField label="Component" value={getLibraryItem(selectedEquipment.type)?.label || selectedEquipment.type} InputProps={{ readOnly: true }} fullWidth /> <TextField label="Component" value={getLibraryItem(selectedEquipment.type)?.label || selectedEquipment.type} InputProps={{ readOnly: true }} fullWidth />
{selectedEquipment.type === 'pdu' ? (
<>
<FormControl fullWidth>
<InputLabel>Mounting</InputLabel>
<Select
value={selectedEquipment.pduMount}
label="Mounting"
onChange={(event: SelectChangeEvent) =>
onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, {
pduMount: event.target.value as Equipment['pduMount'],
sizeU:
event.target.value === 'vertical'
? clampNumber(selectedEquipment.sizeU, 1, 3, 1)
: clampNumber(selectedEquipment.sizeU, 1, 12, 1),
})
}
>
<MenuItem value="horizontal">Horizontal rack mount</MenuItem>
<MenuItem value="vertical">Vertical side mount</MenuItem>
</Select>
</FormControl>
{selectedEquipment.pduMount === 'vertical' ? (
<Stack direction="row" spacing={1.5}>
<TextField
label="Width U"
type="number"
value={selectedEquipment.sizeU}
inputProps={{ min: 1, max: 3 }}
onChange={(event) =>
onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, {
sizeU: clampNumber(event.target.value, 1, 3, selectedEquipment.sizeU),
})
}
fullWidth
/>
<FormControl fullWidth>
<InputLabel>Side</InputLabel>
<Select
value={selectedEquipment.pduSide}
label="Side"
onChange={(event: SelectChangeEvent) =>
onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, {
pduSide: event.target.value as Equipment['pduSide'],
})
}
>
<MenuItem value="left">Left</MenuItem>
<MenuItem value="right">Right</MenuItem>
</Select>
</FormControl>
</Stack>
) : (
<Stack direction="row" spacing={1.5}>
<TextField
label="Size U"
type="number"
value={selectedEquipment.sizeU}
inputProps={{ min: 1, max: 12 }}
onChange={(event) =>
onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, {
sizeU: clampNumber(event.target.value, 1, 12, selectedEquipment.sizeU),
})
}
fullWidth
/>
<TextField
label="Start U"
type="number"
value={selectedEquipment.uStart}
inputProps={{ min: 1, max: selectedEquipmentRack.sizeU }}
onChange={(event) =>
onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, {
uStart: clampNumber(event.target.value, 1, selectedEquipmentRack.sizeU, selectedEquipment.uStart),
})
}
fullWidth
/>
</Stack>
)}
</>
) : (
<Stack direction="row" spacing={1.5}> <Stack direction="row" spacing={1.5}>
<TextField <TextField
label="Size U" label="Size U"
@@ -929,6 +1131,7 @@ function PropertiesPanel({
fullWidth fullWidth
/> />
</Stack> </Stack>
)}
<TextField <TextField
label="Manufacturer" label="Manufacturer"
value={selectedEquipment.manufacturer} value={selectedEquipment.manufacturer}
@@ -1001,6 +1204,172 @@ function PropertiesPanel({
/> />
</> </>
)} )}
{selectedEquipment.type === 'pdu' && (
<>
<Divider />
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
Power Distribution
</Typography>
<TextField
label="Input voltage"
value={selectedEquipment.pduInputVoltage}
onChange={(event) =>
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
pduInputVoltage: event.target.value,
})
}
placeholder="208V 3-phase, 220V, 120V"
fullWidth
/>
<Stack spacing={1}>
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
<Typography variant="body2" sx={{ fontWeight: 800 }}>
Output Ports
</Typography>
<Button
size="small"
variant="outlined"
startIcon={<Add />}
onClick={() =>
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
pduOutputPorts: [
...selectedEquipment.pduOutputPorts,
{
id: createId('pdu-port'),
label: `Port ${selectedEquipment.pduOutputPorts.length + 1}`,
voltage: 120,
amps: 10,
},
],
})
}
>
Add port
</Button>
</Stack>
{selectedEquipment.pduOutputPorts.length === 0 ? (
<Typography variant="caption" color="text.secondary">
No output ports defined.
</Typography>
) : (
selectedEquipment.pduOutputPorts.map((port) => (
<Stack key={port.id} direction="row" spacing={1} alignItems="center">
<TextField
label="Port"
value={port.label}
size="small"
onChange={(event) =>
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
pduOutputPorts: selectedEquipment.pduOutputPorts.map((item) =>
item.id === port.id ? { ...item, label: event.target.value } : item,
),
})
}
sx={{ flex: 1.3 }}
/>
<TextField
label="Volts"
type="number"
value={port.voltage}
size="small"
inputProps={{ min: 0, max: 1000 }}
onChange={(event) =>
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 }}
/>
<TextField
label="Amps"
type="number"
value={port.amps}
size="small"
inputProps={{ min: 0, max: 1000 }}
onChange={(event) =>
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 }}
/>
<Tooltip title="Delete port">
<IconButton
color="error"
size="small"
onClick={() =>
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
pduOutputPorts: selectedEquipment.pduOutputPorts.filter((item) => item.id !== port.id),
})
}
>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
))
)}
</Stack>
<Box>
<Typography variant="body2" sx={{ fontWeight: 800 }}>
Managed PDU
</Typography>
<Slider
value={selectedEquipment.pduManaged ? 1 : 0}
min={0}
max={1}
step={1}
marks={[
{ value: 0, label: 'No' },
{ value: 1, label: 'Yes' },
]}
onChange={(_, value) =>
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
pduManaged: (value as number) === 1,
})
}
/>
</Box>
{selectedEquipment.pduManaged && (
<>
<TextField
label="IP address / hostname"
value={selectedEquipment.pduHostname}
onChange={(event) =>
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
pduHostname: event.target.value,
})
}
fullWidth
/>
<TextField
label="URL"
value={selectedEquipment.pduUrl}
onChange={(event) =>
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
pduUrl: event.target.value,
})
}
fullWidth
/>
<TextField
label="User"
value={selectedEquipment.pduUser}
onChange={(event) =>
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
pduUser: event.target.value,
})
}
fullWidth
/>
</>
)}
</>
)}
<TextField <TextField
label="Power watts" label="Power watts"
type="number" type="number"

View File

@@ -11,6 +11,7 @@ export const componentLibrary: LibraryItem[] = [
{ type: 'storage-array', category: 'equipment', label: 'Storage Array', defaultU: 4, minU: 1, maxU: 24, color: '#ffb300' }, { type: 'storage-array', category: 'equipment', label: 'Storage Array', defaultU: 4, minU: 1, maxU: 24, color: '#ffb300' },
{ type: 'storage-switch', category: 'equipment', label: 'Storage Switch', defaultU: 1, minU: 1, maxU: 24, color: '#42a5f5' }, { type: 'storage-switch', category: 'equipment', label: 'Storage Switch', defaultU: 1, minU: 1, maxU: 24, color: '#42a5f5' },
{ type: 'patch-panel', category: 'equipment', label: 'Patch Panel', defaultU: 1, minU: 1, maxU: 24, color: '#8d6e63' }, { type: 'patch-panel', category: 'equipment', label: 'Patch Panel', defaultU: 1, minU: 1, maxU: 24, color: '#8d6e63' },
{ type: 'pdu', category: 'equipment', label: 'PDU', defaultU: 1, minU: 1, maxU: 12, color: '#f97316' },
{ type: 'cable-management', category: 'equipment', label: 'Cable Management', defaultU: 1, minU: 1, maxU: 24, color: '#78909c' }, { type: 'cable-management', category: 'equipment', label: 'Cable Management', defaultU: 1, minU: 1, maxU: 24, color: '#78909c' },
{ type: 'kvm', category: 'equipment', label: 'KVM', defaultU: 1, minU: 1, maxU: 24, color: '#66bb6a' }, { type: 'kvm', category: 'equipment', label: 'KVM', defaultU: 1, minU: 1, maxU: 24, color: '#66bb6a' },
{ type: 'kvm-console', category: 'equipment', label: 'KVM (Console)', defaultU: 1, minU: 1, maxU: 24, color: '#ec407a' }, { type: 'kvm-console', category: 'equipment', label: 'KVM (Console)', defaultU: 1, minU: 1, maxU: 24, color: '#ec407a' },

View File

@@ -11,6 +11,7 @@ export type ComponentType =
| 'storage-array' | 'storage-array'
| 'storage-switch' | 'storage-switch'
| 'patch-panel' | 'patch-panel'
| 'pdu'
| 'cable-management' | 'cable-management'
| 'kvm' | 'kvm'
| 'kvm-console'; | 'kvm-console';
@@ -19,6 +20,17 @@ export type LibraryCategory = 'container' | 'equipment';
export type PatchPanelMedium = '' | 'copper' | 'fiberoptic'; export type PatchPanelMedium = '' | 'copper' | 'fiberoptic';
export type PduMount = 'horizontal' | 'vertical';
export type PduSide = 'left' | 'right';
export interface PduOutputPort {
id: string;
label: string;
voltage: number;
amps: number;
}
export interface LibraryItem { export interface LibraryItem {
type: ComponentType; type: ComponentType;
category: LibraryCategory; category: LibraryCategory;
@@ -43,6 +55,14 @@ export interface Equipment {
patchPanelMedium: PatchPanelMedium; patchPanelMedium: PatchPanelMedium;
patchPanelPorts: number; patchPanelPorts: number;
patchPanelPortIdentification: string; patchPanelPortIdentification: string;
pduMount: PduMount;
pduSide: PduSide;
pduInputVoltage: string;
pduOutputPorts: PduOutputPort[];
pduManaged: boolean;
pduHostname: string;
pduUrl: string;
pduUser: string;
notes: string; notes: string;
color: string; color: string;
} }

View File

@@ -1,4 +1,4 @@
import type { DiagramData, Equipment, RackContainer } from '../types'; import type { DiagramData, Equipment, PduOutputPort, RackContainer } from '../types';
import { getLibraryItem } from '../data/componentLibrary'; import { getLibraryItem } from '../data/componentLibrary';
export const MAX_CONTAINERS = 10; export const MAX_CONTAINERS = 10;
@@ -56,6 +56,14 @@ export const createEquipment = (type: string, rack: RackContainer, preferredU: n
patchPanelMedium: libraryItem.type === 'patch-panel' ? 'copper' : '', patchPanelMedium: libraryItem.type === 'patch-panel' ? 'copper' : '',
patchPanelPorts: libraryItem.type === 'patch-panel' ? 24 : 0, patchPanelPorts: libraryItem.type === 'patch-panel' ? 24 : 0,
patchPanelPortIdentification: '', patchPanelPortIdentification: '',
pduMount: 'horizontal',
pduSide: 'right',
pduInputVoltage: '',
pduOutputPorts: [],
pduManaged: false,
pduHostname: '',
pduUrl: '',
pduUser: '',
notes: '', notes: '',
color: libraryItem.color, color: libraryItem.color,
}; };
@@ -93,6 +101,10 @@ export const isRangeAvailable = (
} }
return rack.equipment.every((item) => { return rack.equipment.every((item) => {
if (item.type === 'pdu' && item.pduMount === 'vertical') {
return true;
}
if (item.id === ignoreEquipmentId) { if (item.id === ignoreEquipmentId) {
return true; return true;
} }
@@ -104,7 +116,10 @@ export const isRangeAvailable = (
}; };
export const getOccupancy = (rack: RackContainer) => { 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 { return {
used, used,
total: rack.sizeU, total: rack.sizeU,
@@ -141,7 +156,9 @@ export const normalizeDiagram = (value: unknown): DiagramData => {
equipment: Array.isArray(rack.equipment) equipment: Array.isArray(rack.equipment)
? rack.equipment.map((item, itemIndex) => { ? rack.equipment.map((item, itemIndex) => {
const libraryItem = getLibraryItem(item.type) || getLibraryItem('server-rack')!; 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 { return {
id: item.id || createId('equipment'), id: item.id || createId('equipment'),
type: libraryItem.type, 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), patchPanelPorts: clampNumber(item.patchPanelPorts, 0, 10000, libraryItem.type === 'patch-panel' ? 24 : 0),
patchPanelPortIdentification: item.patchPanelPortIdentification || '', 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 || '', notes: item.notes || '',
color: item.color || libraryItem.color, 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<PduOutputPort>;
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) => { export const clampNumber = (value: unknown, min: number, max: number, fallback: number) => {
const parsed = Number(value); const parsed = Number(value);
if (!Number.isFinite(parsed)) { if (!Number.isFinite(parsed)) {