Added fields for port mappings

This commit is contained in:
2026-05-14 18:17:17 -05:00
parent 67c82f61c7
commit dd8e1b2418
5 changed files with 266 additions and 21 deletions

View File

@@ -2,6 +2,9 @@
Datacenter Modeler is a React-based web application for designing datacenter rack and cabinet layouts with drag-and-drop equipment placement. Users can build rack diagrams, edit component metadata, export reports, and save/load layouts as JSON. Datacenter Modeler is a React-based web application for designing datacenter rack and cabinet layouts with drag-and-drop equipment placement. Users can build rack diagrams, edit component metadata, export reports, and save/load layouts as JSON.
# Written by Matthew Puckett
# https://www.theblindengineer.com
## Features ## Features
- Drag racks and cabinets onto the workspace - Drag racks and cabinets onto the workspace
@@ -30,7 +33,10 @@ Datacenter Modeler is a React-based web application for designing datacenter rac
- Edit patch panel-specific properties: - Edit patch panel-specific properties:
- Copper or fiber optic medium - Copper or fiber optic medium
- Number of ports - Number of ports
- Port identification - Dynamic port map with Port ID and Cable ID fields
- Edit component port-map properties for Switch, Firewall, Storage Switch, Router, Server, Blade Chassis, Storage Array, and KVM:
- Dynamic port map with Port ID, Cable ID, and VLAN fields
- VLAN defaults to 1 for new component ports
- Edit PDU-specific properties: - Edit PDU-specific properties:
- Horizontal rack mount or vertical side mount - Horizontal rack mount or vertical side mount
- Horizontal size from 1U to 12U - Horizontal size from 1U to 12U
@@ -143,7 +149,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. PDU records include mounting orientation, side placement for vertical PDUs, input voltage, output port definitions, and managed-device connection 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 and network-capable component records also include structured port map metadata. PDU records include mounting orientation, side placement for vertical PDUs, input voltage, output port definitions, and managed-device connection metadata.
## Project Structure ## Project Structure

View File

@@ -74,6 +74,16 @@ import {
const STORAGE_KEY = 'datacenter-modeler.diagram'; const STORAGE_KEY = 'datacenter-modeler.diagram';
const RACK_ROW_HEIGHT = 14; const RACK_ROW_HEIGHT = 14;
const PORT_MAPPED_COMPONENTS = new Set<ComponentType>([
'blade-chassis',
'firewall',
'kvm',
'router',
'server-rack',
'storage-array',
'storage-switch',
'switch',
]);
const icons: Record<ComponentType, JSX.Element> = { const icons: Record<ComponentType, JSX.Element> = {
rack: <Dns fontSize="small" />, rack: <Dns fontSize="small" />,
@@ -523,8 +533,9 @@ export default function App() {
.forEach((item) => { .forEach((item) => {
const patchPanelDetails = const patchPanelDetails =
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 | ${item.patchPanelPortMap.length} mapped`
: ''; : '';
const networkPortDetails = PORT_MAPPED_COMPONENTS.has(item.type) ? ` | ${item.networkPorts.length} mapped ports` : '';
const pduDetails = const pduDetails =
item.type === 'pdu' 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'}` : ''}` ? ` | ${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'}` : ''}`
@@ -533,7 +544,7 @@ export default function App() {
item.type === 'pdu' && item.pduMount === 'vertical' item.type === 'pdu' && item.pduMount === 'vertical'
? `${item.pduSide === 'left' ? 'Left' : 'Right'} side` ? `${item.pduSide === 'left' ? 'Left' : 'Right'} side`
: `U${item.uStart}-${item.uStart + item.sizeU - 1}`; : `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}`; const line = `${rackPosition}: ${item.name} | ${item.manufacturer || 'n/a'} ${item.model || ''} | ${item.assetTag || 'no asset tag'}${patchPanelDetails}${networkPortDetails}${pduDetails}`;
pdf.text(line.slice(0, 115), margin + 12, y); pdf.text(line.slice(0, 115), margin + 12, y);
y += 14; y += 14;
}); });
@@ -1256,19 +1267,180 @@ function PropertiesPanel({
} }
fullWidth fullWidth
/> />
<TextField <Stack spacing={1}>
label="Port identification" <Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
value={selectedEquipment.patchPanelPortIdentification} <Typography variant="body2" sx={{ fontWeight: 800 }}>
onChange={(event) => Port Map
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { </Typography>
patchPanelPortIdentification: event.target.value, <Button
}) size="small"
} variant="outlined"
multiline startIcon={<Add />}
minRows={3} onClick={() =>
placeholder="A01-A24, VLAN labels, fiber strands, or room/circuit references" onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
fullWidth patchPanelPortMap: [
/> ...selectedEquipment.patchPanelPortMap,
{
id: createId('patch-port'),
portId: `Port ${selectedEquipment.patchPanelPortMap.length + 1}`,
cableId: '',
},
],
})
}
>
Add port
</Button>
</Stack>
{selectedEquipment.patchPanelPortMap.length === 0 ? (
<Typography variant="caption" color="text.secondary">
No ports mapped.
</Typography>
) : (
selectedEquipment.patchPanelPortMap.map((port) => (
<Stack key={port.id} direction="row" spacing={1} alignItems="center">
<TextField
label="Port ID"
value={port.portId}
size="small"
onChange={(event) =>
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
patchPanelPortMap: selectedEquipment.patchPanelPortMap.map((item) =>
item.id === port.id ? { ...item, portId: event.target.value } : item,
),
})
}
sx={{ flex: 1 }}
/>
<TextField
label="Cable ID"
value={port.cableId}
size="small"
onChange={(event) =>
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
patchPanelPortMap: selectedEquipment.patchPanelPortMap.map((item) =>
item.id === port.id ? { ...item, cableId: event.target.value } : item,
),
})
}
sx={{ flex: 1 }}
/>
<Tooltip title="Delete port">
<IconButton
color="error"
size="small"
onClick={() =>
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
patchPanelPortMap: selectedEquipment.patchPanelPortMap.filter((item) => item.id !== port.id),
})
}
>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
))
)}
</Stack>
</>
)}
{PORT_MAPPED_COMPONENTS.has(selectedEquipment.type) && (
<>
<Divider />
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
Component Ports
</Typography>
<Stack spacing={1}>
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
<Typography variant="body2" sx={{ fontWeight: 800 }}>
Port Map
</Typography>
<Button
size="small"
variant="outlined"
startIcon={<Add />}
onClick={() =>
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
networkPorts: [
...selectedEquipment.networkPorts,
{
id: createId('network-port'),
portId: `Port ${selectedEquipment.networkPorts.length + 1}`,
cableId: '',
vlan: 1,
},
],
})
}
>
Add port
</Button>
</Stack>
{selectedEquipment.networkPorts.length === 0 ? (
<Typography variant="caption" color="text.secondary">
No component ports mapped.
</Typography>
) : (
selectedEquipment.networkPorts.map((port) => (
<Stack key={port.id} direction="row" spacing={1} alignItems="center">
<TextField
label="Port ID"
value={port.portId}
size="small"
onChange={(event) =>
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
networkPorts: selectedEquipment.networkPorts.map((item) =>
item.id === port.id ? { ...item, portId: event.target.value } : item,
),
})
}
sx={{ flex: 1 }}
/>
<TextField
label="Cable ID"
value={port.cableId}
size="small"
onChange={(event) =>
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
networkPorts: selectedEquipment.networkPorts.map((item) =>
item.id === port.id ? { ...item, cableId: event.target.value } : item,
),
})
}
sx={{ flex: 1 }}
/>
<TextField
label="VLAN"
type="number"
value={port.vlan}
size="small"
inputProps={{ min: 1, max: 4094 }}
onChange={(event) =>
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
networkPorts: selectedEquipment.networkPorts.map((item) =>
item.id === port.id ? { ...item, vlan: clampNumber(event.target.value, 1, 4094, port.vlan) } : item,
),
})
}
sx={{ flex: 0.8 }}
/>
<Tooltip title="Delete port">
<IconButton
color="error"
size="small"
onClick={() =>
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
networkPorts: selectedEquipment.networkPorts.filter((item) => item.id !== port.id),
})
}
>
<Delete fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
))
)}
</Stack>
</> </>
)} )}
{selectedEquipment.type === 'pdu' && ( {selectedEquipment.type === 'pdu' && (

View File

@@ -20,6 +20,19 @@ export type LibraryCategory = 'container' | 'equipment';
export type PatchPanelMedium = '' | 'copper' | 'fiberoptic'; export type PatchPanelMedium = '' | 'copper' | 'fiberoptic';
export interface PatchPanelPort {
id: string;
portId: string;
cableId: string;
}
export interface NetworkPort {
id: string;
portId: string;
cableId: string;
vlan: number;
}
export type PduMount = 'horizontal' | 'vertical'; export type PduMount = 'horizontal' | 'vertical';
export type PduSide = 'left' | 'right'; export type PduSide = 'left' | 'right';
@@ -54,7 +67,8 @@ export interface Equipment {
powerWatts: number; powerWatts: number;
patchPanelMedium: PatchPanelMedium; patchPanelMedium: PatchPanelMedium;
patchPanelPorts: number; patchPanelPorts: number;
patchPanelPortIdentification: string; patchPanelPortMap: PatchPanelPort[];
networkPorts: NetworkPort[];
pduMount: PduMount; pduMount: PduMount;
pduSide: PduSide; pduSide: PduSide;
pduInputVoltage: string; pduInputVoltage: string;

View File

@@ -1,4 +1,4 @@
import type { DiagramData, Equipment, PduOutputPort, RackContainer } from '../types'; import type { DiagramData, Equipment, NetworkPort, PatchPanelPort, PduOutputPort, RackContainer } from '../types';
import { getLibraryItem } from '../data/componentLibrary'; import { getLibraryItem } from '../data/componentLibrary';
export const MAX_CONTAINERS = 10; export const MAX_CONTAINERS = 10;
@@ -55,7 +55,8 @@ export const createEquipment = (type: string, rack: RackContainer, preferredU: n
powerWatts: 0, powerWatts: 0,
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: '', patchPanelPortMap: [],
networkPorts: [],
pduMount: 'horizontal', pduMount: 'horizontal',
pduSide: 'right', pduSide: 'right',
pduInputVoltage: '', pduInputVoltage: '',
@@ -155,6 +156,12 @@ export const normalizeDiagram = (value: unknown): DiagramData => {
notes: rack.notes || '', notes: rack.notes || '',
equipment: Array.isArray(rack.equipment) equipment: Array.isArray(rack.equipment)
? rack.equipment.map((item, itemIndex) => { ? rack.equipment.map((item, itemIndex) => {
const legacyItem = item as Equipment & {
networkPorts?: unknown;
patchPanelPortIdentification?: unknown;
patchPanelPortMap?: unknown;
switchPorts?: unknown;
};
const libraryItem = getLibraryItem(item.type) || getLibraryItem('server-rack')!; const libraryItem = getLibraryItem(item.type) || getLibraryItem('server-rack')!;
const pduMount = item.pduMount === 'vertical' ? 'vertical' : 'horizontal'; const pduMount = item.pduMount === 'vertical' ? 'vertical' : 'horizontal';
const maxSizeU = libraryItem.type === 'pdu' ? (pduMount === 'vertical' ? 3 : 12) : 24; const maxSizeU = libraryItem.type === 'pdu' ? (pduMount === 'vertical' ? 3 : 12) : 24;
@@ -177,7 +184,8 @@ export const normalizeDiagram = (value: unknown): DiagramData => {
? 'copper' ? 'copper'
: '', : '',
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 || '', patchPanelPortMap: normalizePatchPanelPorts(legacyItem.patchPanelPortMap, legacyItem.patchPanelPortIdentification),
networkPorts: normalizeNetworkPorts(legacyItem.networkPorts ?? legacyItem.switchPorts),
pduMount, pduMount,
pduSide: item.pduSide === 'left' ? 'left' : 'right', pduSide: item.pduSide === 'left' ? 'left' : 'right',
pduInputVoltage: item.pduInputVoltage || '', pduInputVoltage: item.pduInputVoltage || '',
@@ -195,6 +203,47 @@ export const normalizeDiagram = (value: unknown): DiagramData => {
}; };
}; };
const normalizeNetworkPorts = (ports: unknown): NetworkPort[] => {
if (!Array.isArray(ports)) {
return [];
}
return ports.map((port, index) => {
const candidate = port as Partial<NetworkPort>;
return {
id: candidate.id || createId('network-port'),
portId: candidate.portId || `Port ${index + 1}`,
cableId: candidate.cableId || '',
vlan: clampNumber(candidate.vlan, 1, 4094, 1),
};
});
};
const normalizePatchPanelPorts = (ports: unknown, legacyIdentification: unknown): PatchPanelPort[] => {
if (Array.isArray(ports)) {
return ports.map((port, index) => {
const candidate = port as Partial<PatchPanelPort>;
return {
id: candidate.id || createId('patch-port'),
portId: candidate.portId || `Port ${index + 1}`,
cableId: candidate.cableId || '',
};
});
}
if (typeof legacyIdentification === 'string' && legacyIdentification.trim()) {
return [
{
id: createId('patch-port'),
portId: legacyIdentification,
cableId: '',
},
];
}
return [];
};
const normalizePduOutputPorts = (ports: unknown): PduOutputPort[] => { const normalizePduOutputPorts = (ports: unknown): PduOutputPort[] => {
if (!Array.isArray(ports)) { if (!Array.isArray(ports)) {
return []; return [];

View File

@@ -3,4 +3,8 @@ import react from '@vitejs/plugin-react';
export default defineConfig({ export default defineConfig({
plugins: [react], plugins: [react],
server: {
host: '0.0.0.0',
allowedHosts: ['datacentermodeler.com', 'www.datacentermodeler.com', 'localhost', '127.0.0.1']
}
}); });