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
- 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

View File

@@ -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<ComponentType, JSX.Element> = {
'storage-array': <Storage fontSize="small" />,
'storage-switch': <Storage fontSize="small" />,
'patch-panel': <Cable fontSize="small" />,
pdu: <Power fontSize="small" />,
'cable-management': <Cable fontSize="small" />,
kvm: <Terminal fontSize="small" />,
'kvm-console': <Inventory2 fontSize="small" />,
@@ -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 (
<Paper
@@ -739,80 +799,141 @@ function RackView({
sx={{
position: 'relative',
height: rackHeight,
bgcolor: (theme) => (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) => (
<Typography
key={u}
variant="caption"
sx={{
position: 'absolute',
left: 3,
top: (rack.sizeU - u) * RACK_ROW_HEIGHT - 1,
color: 'text.disabled',
fontSize: 9,
lineHeight: `${RACK_ROW_HEIGHT}px`,
pointerEvents: 'none',
}}
>
{u}
</Typography>
))}
<Box
sx={{
position: 'absolute',
inset: 0,
left: 34,
right: 34,
bgcolor: (theme) => (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) => (
<Typography
key={u}
variant="caption"
sx={{
position: 'absolute',
left: 3,
top: (rack.sizeU - u) * RACK_ROW_HEIGHT - 1,
color: 'text.disabled',
fontSize: 9,
lineHeight: `${RACK_ROW_HEIGHT}px`,
pointerEvents: 'none',
}}
>
{u}
</Typography>
))}
{[...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 (
<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);
}}
{[...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 (
<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',
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,
}}
>
<Typography variant="caption" noWrap sx={{ fontWeight: 900, minWidth: 0 }}>
{item.name}
</Typography>
<Typography variant="caption" sx={{ flex: '0 0 auto', fontWeight: 800 }}>
U{item.uStart}-{item.uStart + item.sizeU - 1}
</Typography>
</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={{
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,
writingMode: 'vertical-rl',
transform: 'rotate(180deg)',
fontWeight: 900,
lineHeight: 1,
whiteSpace: 'nowrap',
}}
>
<Typography variant="caption" noWrap sx={{ fontWeight: 900, minWidth: 0 }}>
{item.name}
</Typography>
<Typography variant="caption" sx={{ flex: '0 0 auto', fontWeight: 800 }}>
U{item.uStart}-{item.uStart + item.sizeU - 1}
</Typography>
</Box>
);
})}
{item.name}
</Typography>
</Box>
);
})}
</Box>
</Paper>
);
@@ -903,32 +1024,114 @@ function PropertiesPanel({
fullWidth
/>
<TextField label="Component" value={getLibraryItem(selectedEquipment.type)?.label || selectedEquipment.type} InputProps={{ readOnly: true }} fullWidth />
<Stack direction="row" spacing={1.5}>
<TextField
label="Size U"
type="number"
value={selectedEquipment.sizeU}
inputProps={{ min: 1, max: 24 }}
onChange={(event) =>
onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, {
sizeU: clampNumber(event.target.value, 1, 24, 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>
{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}>
<TextField
label="Size U"
type="number"
value={selectedEquipment.sizeU}
inputProps={{ min: 1, max: 24 }}
onChange={(event) =>
onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, {
sizeU: clampNumber(event.target.value, 1, 24, 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>
)}
<TextField
label="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
label="Power watts"
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-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: '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: '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' },

View File

@@ -11,6 +11,7 @@ export type ComponentType =
| 'storage-array'
| 'storage-switch'
| 'patch-panel'
| 'pdu'
| 'cable-management'
| 'kvm'
| 'kvm-console';
@@ -19,6 +20,17 @@ export type LibraryCategory = 'container' | 'equipment';
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 {
type: ComponentType;
category: LibraryCategory;
@@ -43,6 +55,14 @@ export interface Equipment {
patchPanelMedium: PatchPanelMedium;
patchPanelPorts: number;
patchPanelPortIdentification: string;
pduMount: PduMount;
pduSide: PduSide;
pduInputVoltage: string;
pduOutputPorts: PduOutputPort[];
pduManaged: boolean;
pduHostname: string;
pduUrl: string;
pduUser: string;
notes: 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';
export const MAX_CONTAINERS = 10;
@@ -56,6 +56,14 @@ export const createEquipment = (type: string, rack: RackContainer, preferredU: n
patchPanelMedium: libraryItem.type === 'patch-panel' ? 'copper' : '',
patchPanelPorts: libraryItem.type === 'patch-panel' ? 24 : 0,
patchPanelPortIdentification: '',
pduMount: 'horizontal',
pduSide: 'right',
pduInputVoltage: '',
pduOutputPorts: [],
pduManaged: false,
pduHostname: '',
pduUrl: '',
pduUser: '',
notes: '',
color: libraryItem.color,
};
@@ -93,6 +101,10 @@ export const isRangeAvailable = (
}
return rack.equipment.every((item) => {
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<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) => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {