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
|
- 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
|
||||||
|
|
||||||
|
|||||||
383
src/App.tsx
383
src/App.tsx
@@ -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"
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
20
src/types.ts
20
src/types.ts
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user