Added PDU Component
This commit is contained in:
12
README.md
12
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
|
||||
|
||||
|
||||
569
src/App.tsx
569
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<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"
|
||||
|
||||
@@ -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
src/types.ts
20
src/types.ts
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user