Added PDU Component

This commit is contained in:
pucketm2016 committed 2026-05-14 02:38:32 -05:00
1 parent 1ec5ab4bf5
commit 71d631efbe
5 files changed
+545 -104

No files matched your search

+469 -100
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"
+1
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' },
+20
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;
}
+44 -3
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)) {