Initial Commit

This commit is contained in:
2026-05-14 02:09:46 -05:00
commit a815d19e52
13 changed files with 1535 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
/node_modules
/dist
/.env
/.env.local
/.env.development
/.env.test
/.env.production
/.DS_Store
/.vscode
/.idea
/.eslintcache
/.prettiercache
/logs
*-lock.json

159
README.md Normal file
View File

@@ -0,0 +1,159 @@
# Datacenter Modeler
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.
## Features
- Drag racks and cabinets onto the workspace
- Limit each diagram to 10 racks/cabinets
- Choose rack/cabinet height from 2U to 42U
- Drag equipment into racks with U-position snapping
- Prevent overlapping equipment placements
- Edit rack/cabinet properties:
- Name
- Type
- Rack units
- Manufacturer
- Model
- Location
- Notes
- Edit equipment properties:
- Name
- Size in rack units
- Start U position
- Manufacturer
- Model
- Serial number
- Asset tag
- Power watts
- Notes
- Save diagrams as JSON
- Load previously saved JSON diagrams
- Browser autosave with `localStorage`
- Export rack layouts as PNG or JPG
- Export a PDF report with the diagram and rack inventory
- Dark/light theme selector
- Material UI-based interface
## Component Library
The left-side palette includes:
- Rack
- Cabinet
- Switch
- Router
- Firewall
- Server (Rack)
- Blade Chassis
- Storage Array
- Storage Switch
- Patch Panel
- Cable Management
- KVM
- KVM (Console)
## Tech Stack
- Node.js
- React
- TypeScript
- Vite
- Material UI
- html2canvas
- jsPDF
## Getting Started
Install dependencies:
```bash
npm install
```
Start the development server:
```bash
npm run dev
```
Vite will print the local URL, usually:
```text
http://localhost:5173/
```
If that port is already in use, run:
```bash
npm run dev -- --port 5174
```
## Build
Create a production build:
```bash
npm run build
```
Preview the production build:
```bash
npm run preview
```
## Usage
1. Drag a Rack or Cabinet from the component library into the workspace.
2. Choose the rack/cabinet size in the dialog.
3. Drag equipment from the library into a rack/cabinet.
4. Select racks or equipment to edit properties in the right panel.
5. Use Save JSON to download the diagram data.
6. Use Load JSON to continue editing a previously saved diagram.
7. Use Export to generate a PDF report, PNG, or JPG.
## Data Model
Diagram data is stored as JSON with this top-level shape:
```json
{
"appName": "Datacenter Modeler",
"schemaVersion": 1,
"name": "Untitled Datacenter Layout",
"createdAt": "2026-05-14T00:00:00.000Z",
"updatedAt": "2026-05-14T00:00:00.000Z",
"racks": []
}
```
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.
## Project Structure
```text
.
├── index.html
├── package.json
├── src
│ ├── App.tsx
│ ├── data
│ │ └── componentLibrary.ts
│ ├── main.tsx
│ ├── styles.css
│ ├── types.ts
│ └── utils
│ └── model.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
```
## Notes
- Layouts are automatically persisted in the browser using `localStorage`.
- JSON export is the portable save format for moving diagrams between browsers or computers.
- PDF and image exports are generated from the rendered workspace.
- The current implementation is client-side only; no backend server or database is required.

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Datacenter Modeler</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "datacenter-modeler",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@mui/icons-material": "^5.15.20",
"@mui/material": "^5.15.20",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.4.5",
"vite": "^5.2.0"
}
}

990
src/App.tsx Normal file
View File

@@ -0,0 +1,990 @@
import {
AppBar,
Box,
Button,
Chip,
CssBaseline,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
FormControl,
IconButton,
InputLabel,
LinearProgress,
Menu,
MenuItem,
Paper,
Select,
Slider,
Snackbar,
Stack,
Switch,
TextField,
ThemeProvider,
Toolbar,
Tooltip,
Typography,
createTheme,
alpha,
} from '@mui/material';
import type { SelectChangeEvent } from '@mui/material';
import {
Cable,
DarkMode,
Delete,
Dns,
Domain,
Download,
FileOpen,
Image,
Inventory2,
LightMode,
PictureAsPdf,
Router,
Save,
Security,
SettingsInputComponent,
Storage,
Terminal,
ViewColumn,
} from '@mui/icons-material';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import { useEffect, useMemo, useRef, useState } from 'react';
import { componentLibrary, getLibraryItem } from './data/componentLibrary';
import type { ComponentType, DiagramData, DragPayload, Equipment, RackContainer, Selection, ThemeMode } from './types';
import {
MAX_CONTAINERS,
clampNumber,
createDiagram,
createEquipment,
createRack,
findAvailableU,
getOccupancy,
isRangeAvailable,
normalizeDiagram,
} from './utils/model';
const STORAGE_KEY = 'datacenter-modeler.diagram';
const RACK_ROW_HEIGHT = 14;
const icons: Record<ComponentType, JSX.Element> = {
rack: <Dns fontSize="small" />,
cabinet: <Domain fontSize="small" />,
switch: <SettingsInputComponent fontSize="small" />,
router: <Router fontSize="small" />,
firewall: <Security fontSize="small" />,
'server-rack': <Dns fontSize="small" />,
'blade-chassis': <ViewColumn fontSize="small" />,
'storage-array': <Storage fontSize="small" />,
'storage-switch': <Storage fontSize="small" />,
'patch-panel': <Cable fontSize="small" />,
'cable-management': <Cable fontSize="small" />,
kvm: <Terminal fontSize="small" />,
'kvm-console': <Inventory2 fontSize="small" />,
};
const setDragPayload = (event: React.DragEvent, payload: DragPayload) => {
event.dataTransfer.setData('application/json', JSON.stringify(payload));
event.dataTransfer.effectAllowed = payload.source === 'library' ? 'copy' : 'move';
};
const getDragPayload = (event: React.DragEvent): DragPayload | null => {
try {
return JSON.parse(event.dataTransfer.getData('application/json')) as DragPayload;
} catch {
return null;
}
};
const downloadDataUrl = (dataUrl: string, fileName: string) => {
const link = document.createElement('a');
link.href = dataUrl;
link.download = fileName;
link.click();
};
const downloadText = (text: string, fileName: string, mimeType: string) => {
const blob = new Blob([text], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
URL.revokeObjectURL(url);
};
const createFileSlug = (name: string) =>
name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '') || 'datacenter-modeler';
const loadInitialDiagram = () => {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) {
return createDiagram();
}
try {
return normalizeDiagram(JSON.parse(saved));
} catch {
return createDiagram();
}
};
export default function App() {
const [themeMode, setThemeMode] = useState<ThemeMode>(() => (localStorage.getItem('datacenter-modeler.theme') as ThemeMode) || 'dark');
const [diagram, setDiagram] = useState<DiagramData>(loadInitialDiagram);
const [selection, setSelection] = useState<Selection>(null);
const [exportAnchor, setExportAnchor] = useState<HTMLElement | null>(null);
const [pendingContainer, setPendingContainer] = useState<'rack' | 'cabinet' | null>(null);
const [pendingSize, setPendingSize] = useState(42);
const [notice, setNotice] = useState('');
const workspaceRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const theme = useMemo(
() =>
createTheme({
palette: {
mode: themeMode,
primary: { main: themeMode === 'dark' ? '#7dd3fc' : '#006c9c' },
secondary: { main: '#f59e0b' },
background:
themeMode === 'dark'
? { default: '#0f1720', paper: '#16212c' }
: { default: '#eef3f7', paper: '#ffffff' },
},
shape: { borderRadius: 8 },
typography: {
fontFamily: 'Roboto, Arial, sans-serif',
button: { textTransform: 'none', fontWeight: 700 },
},
}),
[themeMode],
);
const selectedRack = selection?.kind === 'rack' ? diagram.racks.find((rack) => rack.id === selection.rackId) : null;
const selectedEquipment =
selection?.kind === 'equipment'
? diagram.racks.find((rack) => rack.id === selection.rackId)?.equipment.find((item) => item.id === selection.equipmentId)
: null;
const selectedEquipmentRack =
selection?.kind === 'equipment' ? diagram.racks.find((rack) => rack.id === selection.rackId) || null : null;
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(diagram));
}, [diagram]);
useEffect(() => {
localStorage.setItem('datacenter-modeler.theme', themeMode);
}, [themeMode]);
const commitDiagram = (updater: (current: DiagramData) => DiagramData) => {
setDiagram((current) => ({ ...updater(current), updatedAt: new Date().toISOString() }));
};
const handleCanvasDrop = (event: React.DragEvent) => {
event.preventDefault();
const payload = getDragPayload(event);
const libraryItem = getLibraryItem(payload?.type);
if (!payload || payload.source !== 'library' || libraryItem?.category !== 'container') {
return;
}
if (diagram.racks.length >= MAX_CONTAINERS) {
setNotice(`Each diagram supports up to ${MAX_CONTAINERS} racks or cabinets.`);
return;
}
setPendingContainer(libraryItem.type as 'rack' | 'cabinet');
setPendingSize(libraryItem.defaultU);
};
const addPendingContainer = () => {
if (!pendingContainer) {
return;
}
const nextRack = createRack(pendingContainer, diagram.racks.length + 1, pendingSize);
commitDiagram((current) => ({ ...current, racks: [...current.racks, nextRack] }));
setSelection({ kind: 'rack', rackId: nextRack.id });
setPendingContainer(null);
};
const getPreferredTopU = (event: React.DragEvent, rack: RackContainer) => {
const bounds = (event.currentTarget as HTMLElement).getBoundingClientRect();
const y = Math.max(0, Math.min(bounds.height, event.clientY - bounds.top));
const rowFromTop = Math.floor(y / RACK_ROW_HEIGHT);
return Math.max(1, Math.min(rack.sizeU, rack.sizeU - rowFromTop));
};
const handleRackDrop = (event: React.DragEvent, rackId: string) => {
event.preventDefault();
event.stopPropagation();
const payload = getDragPayload(event);
const destinationRack = diagram.racks.find((rack) => rack.id === rackId);
if (!payload || !destinationRack) {
return;
}
const preferredTopU = getPreferredTopU(event, destinationRack);
if (payload.source === 'library') {
const equipment = createEquipment(payload.type || '', destinationRack, preferredTopU);
if (!equipment) {
setNotice('No open rack units are available for that component.');
return;
}
commitDiagram((current) => ({
...current,
racks: current.racks.map((rack) =>
rack.id === rackId ? { ...rack, equipment: [...rack.equipment, equipment] } : rack,
),
}));
setSelection({ kind: 'equipment', rackId, equipmentId: equipment.id });
return;
}
if (payload.source === 'equipment' && payload.rackId && payload.equipmentId) {
const sourceRack = diagram.racks.find((rack) => rack.id === payload.rackId);
const equipment = sourceRack?.equipment.find((item) => item.id === payload.equipmentId);
if (!sourceRack || !equipment) {
return;
}
const nextU = findAvailableU(
destinationRack,
equipment.sizeU,
preferredTopU,
sourceRack.id === destinationRack.id ? equipment.id : undefined,
);
if (nextU === null) {
setNotice('That position does not have enough open U space.');
return;
}
commitDiagram((current) => ({
...current,
racks: current.racks.map((rack) => {
if (rack.id === sourceRack.id && rack.id === destinationRack.id) {
return {
...rack,
equipment: rack.equipment.map((item) => (item.id === equipment.id ? { ...item, uStart: nextU } : item)),
};
}
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: nextU }] };
}
return rack;
}),
}));
setSelection({ kind: 'equipment', rackId, equipmentId: equipment.id });
}
};
const updateDiagramName = (name: string) => {
commitDiagram((current) => ({ ...current, name }));
};
const updateRack = (rackId: string, patch: Partial<RackContainer>) => {
commitDiagram((current) => ({
...current,
racks: current.racks.map((rack) => (rack.id === rackId ? { ...rack, ...patch } : rack)),
}));
};
const updateRackSize = (rackId: string, sizeU: number) => {
const rack = diagram.racks.find((item) => item.id === rackId);
if (!rack) {
return;
}
const highestOccupiedU = Math.max(0, ...rack.equipment.map((item) => item.uStart + item.sizeU - 1));
if (highestOccupiedU > sizeU) {
setNotice(`Rack size must be at least ${highestOccupiedU}U for the installed equipment.`);
return;
}
updateRack(rackId, { sizeU });
};
const updateEquipment = (rackId: string, equipmentId: string, patch: Partial<Equipment>) => {
commitDiagram((current) => ({
...current,
racks: current.racks.map((rack) =>
rack.id === rackId
? {
...rack,
equipment: rack.equipment.map((item) => (item.id === equipmentId ? { ...item, ...patch } : item)),
}
: rack,
),
}));
};
const updateEquipmentPlacement = (rackId: string, equipmentId: string, patch: Partial<Equipment>) => {
const rack = diagram.racks.find((item) => item.id === rackId);
const equipment = rack?.equipment.find((item) => item.id === equipmentId);
if (!rack || !equipment) {
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);
if (!isRangeAvailable(rack, nextU, nextSize, equipmentId)) {
setNotice('That size or position overlaps another component.');
return;
}
updateEquipment(rackId, equipmentId, { ...patch, sizeU: nextSize, uStart: nextU });
};
const deleteSelection = () => {
if (selection?.kind === 'rack') {
commitDiagram((current) => ({ ...current, racks: current.racks.filter((rack) => rack.id !== selection.rackId) }));
setSelection(null);
}
if (selection?.kind === 'equipment') {
commitDiagram((current) => ({
...current,
racks: current.racks.map((rack) =>
rack.id === selection.rackId
? { ...rack, equipment: rack.equipment.filter((item) => item.id !== selection.equipmentId) }
: rack,
),
}));
setSelection({ kind: 'rack', rackId: selection.rackId });
}
};
const saveJson = () => {
downloadText(JSON.stringify(diagram, null, 2), `${createFileSlug(diagram.name)}.json`, 'application/json');
};
const handleJsonFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) {
return;
}
try {
const imported = normalizeDiagram(JSON.parse(await file.text()));
setDiagram(imported);
setSelection(null);
setNotice('Diagram loaded.');
} catch (error) {
setNotice(error instanceof Error ? error.message : 'Unable to load that JSON file.');
}
};
const captureWorkspace = async () => {
if (!workspaceRef.current) {
throw new Error('Workspace is not ready.');
}
return html2canvas(workspaceRef.current, {
backgroundColor: theme.palette.background.default,
scale: 2,
useCORS: true,
});
};
const exportImage = async (format: 'png' | 'jpeg') => {
try {
const canvas = await captureWorkspace();
downloadDataUrl(canvas.toDataURL(`image/${format}`, 0.94), `${createFileSlug(diagram.name)}.${format === 'jpeg' ? 'jpg' : 'png'}`);
setExportAnchor(null);
} catch {
setNotice('Unable to export the workspace image.');
}
};
const exportPdf = async () => {
try {
const canvas = await captureWorkspace();
const pdf = new jsPDF('landscape', 'pt', 'a4');
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const margin = 32;
const imageWidth = pageWidth - margin * 2;
const imageHeight = Math.min(pageHeight - 120, (canvas.height * imageWidth) / canvas.width);
pdf.setFont('helvetica', 'bold');
pdf.setFontSize(18);
pdf.text(diagram.name, margin, 36);
pdf.setFont('helvetica', 'normal');
pdf.setFontSize(10);
pdf.text(`Generated ${new Date().toLocaleString()} by Datacenter Modeler`, margin, 54);
pdf.addImage(canvas.toDataURL('image/png'), 'PNG', margin, 76, imageWidth, imageHeight);
pdf.addPage('a4', 'portrait');
pdf.setFont('helvetica', 'bold');
pdf.setFontSize(16);
pdf.text('Rack Inventory', margin, 42);
pdf.setFont('helvetica', 'normal');
pdf.setFontSize(10);
let y = 68;
diagram.racks.forEach((rack) => {
const occupancy = getOccupancy(rack);
if (y > 730) {
pdf.addPage('a4', 'portrait');
y = 42;
}
pdf.setFont('helvetica', 'bold');
pdf.text(`${rack.name} (${rack.sizeU}U, ${occupancy.used}/${occupancy.total}U used)`, margin, y);
y += 16;
pdf.setFont('helvetica', 'normal');
if (!rack.equipment.length) {
pdf.text('No equipment installed', margin + 12, y);
y += 18;
return;
}
[...rack.equipment]
.sort((a, b) => b.uStart - a.uStart)
.forEach((item) => {
const line = `U${item.uStart}-${item.uStart + item.sizeU - 1}: ${item.name} | ${item.manufacturer || 'n/a'} ${item.model || ''} | ${item.assetTag || 'no asset tag'}`;
pdf.text(line.slice(0, 115), margin + 12, y);
y += 14;
});
y += 8;
});
pdf.save(`${createFileSlug(diagram.name)}.pdf`);
setExportAnchor(null);
} catch {
setNotice('Unable to export the PDF report.');
}
};
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{ height: '100vh', display: 'flex', flexDirection: 'column', bgcolor: 'background.default' }}>
<AppBar position="static" color="default" elevation={0} sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Toolbar sx={{ minHeight: 64, gap: 1.5 }}>
<Dns color="primary" />
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 800, lineHeight: 1.1 }}>
Datacenter Modeler
</Typography>
<Typography variant="caption" color="text.secondary">
{diagram.racks.length}/{MAX_CONTAINERS} racks and cabinets
</Typography>
</Box>
<TextField
value={diagram.name}
onChange={(event) => updateDiagramName(event.target.value)}
size="small"
label="Diagram"
sx={{ width: { xs: 180, md: 320 }, display: { xs: 'none', sm: 'block' } }}
/>
<Tooltip title="Save JSON">
<IconButton color="primary" onClick={saveJson}>
<Save />
</IconButton>
</Tooltip>
<Tooltip title="Load JSON">
<IconButton color="primary" onClick={() => fileInputRef.current?.click()}>
<FileOpen />
</IconButton>
</Tooltip>
<Button variant="contained" endIcon={<Download />} onClick={(event) => setExportAnchor(event.currentTarget)}>
Export
</Button>
<Tooltip title={themeMode === 'dark' ? 'Light theme' : 'Dark theme'}>
<Stack direction="row" alignItems="center" spacing={0.5}>
<LightMode fontSize="small" />
<Switch checked={themeMode === 'dark'} onChange={(_, checked) => setThemeMode(checked ? 'dark' : 'light')} />
<DarkMode fontSize="small" />
</Stack>
</Tooltip>
<input ref={fileInputRef} type="file" accept="application/json,.json" hidden onChange={handleJsonFile} />
</Toolbar>
</AppBar>
<Menu anchorEl={exportAnchor} open={Boolean(exportAnchor)} onClose={() => setExportAnchor(null)}>
<MenuItem onClick={exportPdf}>
<PictureAsPdf fontSize="small" sx={{ mr: 1 }} /> PDF report
</MenuItem>
<MenuItem onClick={() => exportImage('png')}>
<Image fontSize="small" sx={{ mr: 1 }} /> Rack layouts as PNG
</MenuItem>
<MenuItem onClick={() => exportImage('jpeg')}>
<Image fontSize="small" sx={{ mr: 1 }} /> Rack layouts as JPG
</MenuItem>
</Menu>
<Box sx={{ flex: 1, minHeight: 0, display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '280px minmax(0, 1fr) 340px' } }}>
<LibraryPanel />
<Box
onDrop={handleCanvasDrop}
onDragOver={(event) => event.preventDefault()}
sx={{
minHeight: 0,
overflow: 'auto',
p: 2,
borderLeft: { lg: 1 },
borderRight: { lg: 1 },
borderColor: 'divider',
}}
>
<Box
ref={workspaceRef}
sx={{
minHeight: '100%',
minWidth: 620,
display: 'flex',
alignItems: 'flex-start',
gap: 2,
p: 2,
border: 1,
borderColor: 'divider',
bgcolor: (muiTheme) => alpha(muiTheme.palette.background.paper, themeMode === 'dark' ? 0.55 : 0.86),
}}
>
{diagram.racks.length === 0 ? (
<Box sx={{ m: 'auto', textAlign: 'center', color: 'text.secondary' }}>
<Dns sx={{ fontSize: 52, mb: 1, opacity: 0.45 }} />
<Typography variant="h6">No racks in diagram</Typography>
</Box>
) : (
diagram.racks.map((rack) => (
<RackView
key={rack.id}
rack={rack}
selected={selection}
onSelectRack={() => setSelection({ kind: 'rack', rackId: rack.id })}
onSelectEquipment={(equipmentId) => setSelection({ kind: 'equipment', rackId: rack.id, equipmentId })}
onDrop={(event) => handleRackDrop(event, rack.id)}
/>
))
)}
</Box>
</Box>
<PropertiesPanel
diagram={diagram}
selectedRack={selectedRack || null}
selectedEquipment={selectedEquipment || null}
selectedEquipmentRack={selectedEquipmentRack}
selection={selection}
onDiagramNameChange={updateDiagramName}
onRackChange={updateRack}
onRackSizeChange={updateRackSize}
onEquipmentChange={updateEquipment}
onEquipmentPlacementChange={updateEquipmentPlacement}
onDelete={deleteSelection}
/>
</Box>
<Dialog open={Boolean(pendingContainer)} onClose={() => setPendingContainer(null)} maxWidth="xs" fullWidth>
<DialogTitle>Add {pendingContainer === 'cabinet' ? 'Cabinet' : 'Rack'}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ pt: 1 }}>
<TextField
type="number"
label="Rack units"
value={pendingSize}
onChange={(event) => setPendingSize(clampNumber(event.target.value, 2, 42, 42))}
inputProps={{ min: 2, max: 42 }}
fullWidth
/>
<Slider value={pendingSize} min={2} max={42} marks={[{ value: 2 }, { value: 24 }, { value: 42 }]} onChange={(_, value) => setPendingSize(value as number)} />
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setPendingContainer(null)}>Cancel</Button>
<Button variant="contained" onClick={addPendingContainer}>
Add
</Button>
</DialogActions>
</Dialog>
<Snackbar open={Boolean(notice)} autoHideDuration={3200} message={notice} onClose={() => setNotice('')} />
</Box>
</ThemeProvider>
);
}
function LibraryPanel() {
const containers = componentLibrary.filter((item) => item.category === 'container');
const equipment = componentLibrary.filter((item) => item.category === 'equipment');
return (
<Paper square elevation={0} sx={{ minHeight: 0, overflow: 'auto', p: 2, display: { xs: 'none', lg: 'block' } }}>
<Stack spacing={2}>
<Box>
<Typography variant="overline" color="text.secondary" sx={{ fontWeight: 800 }}>
Containers
</Typography>
<Stack spacing={1}>
{containers.map((item) => (
<LibraryTile key={item.type} item={item} />
))}
</Stack>
</Box>
<Divider />
<Box>
<Typography variant="overline" color="text.secondary" sx={{ fontWeight: 800 }}>
Components
</Typography>
<Stack spacing={1}>
{equipment.map((item) => (
<LibraryTile key={item.type} item={item} />
))}
</Stack>
</Box>
</Stack>
</Paper>
);
}
function LibraryTile({ item }: { item: (typeof componentLibrary)[number] }) {
return (
<Paper
draggable
onDragStart={(event) => setDragPayload(event, { source: 'library', type: item.type })}
variant="outlined"
sx={{
p: 1,
display: 'grid',
gridTemplateColumns: '32px 1fr auto',
alignItems: 'center',
gap: 1,
cursor: 'grab',
borderLeft: 4,
borderLeftColor: item.color,
'&:active': { cursor: 'grabbing' },
}}
>
<Box sx={{ color: item.color, display: 'flex' }}>{icons[item.type]}</Box>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{item.label}
</Typography>
<Chip label={`${item.defaultU}U`} size="small" variant="outlined" />
</Paper>
);
}
function RackView({
rack,
selected,
onSelectRack,
onSelectEquipment,
onDrop,
}: {
rack: RackContainer;
selected: Selection;
onSelectRack: () => void;
onSelectEquipment: (equipmentId: string) => void;
onDrop: (event: React.DragEvent) => void;
}) {
const occupancy = getOccupancy(rack);
const rackHeight = rack.sizeU * RACK_ROW_HEIGHT;
return (
<Paper
variant="outlined"
onClick={onSelectRack}
sx={{
width: 270,
flex: '0 0 270px',
overflow: 'hidden',
borderColor: selected?.kind === 'rack' && selected.rackId === rack.id ? 'primary.main' : 'divider',
}}
>
<Box sx={{ p: 1.25, bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider' }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" spacing={1}>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle2" noWrap sx={{ fontWeight: 800 }}>
{rack.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{rack.sizeU}U {rack.type}
</Typography>
</Box>
<Chip size="small" label={`${occupancy.percent}%`} color={occupancy.percent > 85 ? 'warning' : 'default'} />
</Stack>
<LinearProgress variant="determinate" value={occupancy.percent} sx={{ mt: 1, height: 5, borderRadius: 1 }} />
</Box>
<Box
onDrop={onDrop}
onDragOver={(event) => event.preventDefault()}
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'),
}}
>
{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);
}}
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>
</Paper>
);
}
function PropertiesPanel({
diagram,
selectedRack,
selectedEquipment,
selectedEquipmentRack,
selection,
onDiagramNameChange,
onRackChange,
onRackSizeChange,
onEquipmentChange,
onEquipmentPlacementChange,
onDelete,
}: {
diagram: DiagramData;
selectedRack: RackContainer | null;
selectedEquipment: Equipment | null;
selectedEquipmentRack: RackContainer | null;
selection: Selection;
onDiagramNameChange: (name: string) => void;
onRackChange: (rackId: string, patch: Partial<RackContainer>) => void;
onRackSizeChange: (rackId: string, sizeU: number) => void;
onEquipmentChange: (rackId: string, equipmentId: string, patch: Partial<Equipment>) => void;
onEquipmentPlacementChange: (rackId: string, equipmentId: string, patch: Partial<Equipment>) => void;
onDelete: () => void;
}) {
return (
<Paper square elevation={0} sx={{ minHeight: 0, overflow: 'auto', p: 2, display: { xs: 'none', lg: 'block' } }}>
<Stack spacing={2}>
<Box>
<Typography variant="overline" color="text.secondary" sx={{ fontWeight: 800 }}>
Properties
</Typography>
<Typography variant="h6" sx={{ fontWeight: 800 }}>
{selectedEquipment ? selectedEquipment.name : selectedRack ? selectedRack.name : 'Diagram'}
</Typography>
</Box>
{!selection && (
<Stack spacing={2}>
<TextField label="Diagram name" value={diagram.name} onChange={(event) => onDiagramNameChange(event.target.value)} fullWidth />
<MetadataSummary diagram={diagram} />
</Stack>
)}
{selectedRack && selection?.kind === 'rack' && (
<Stack spacing={2}>
<TextField label="Name" value={selectedRack.name} onChange={(event) => onRackChange(selectedRack.id, { name: event.target.value })} fullWidth />
<FormControl fullWidth>
<InputLabel>Type</InputLabel>
<Select
value={selectedRack.type}
label="Type"
onChange={(event: SelectChangeEvent) => onRackChange(selectedRack.id, { type: event.target.value as 'rack' | 'cabinet' })}
>
<MenuItem value="rack">Rack</MenuItem>
<MenuItem value="cabinet">Cabinet</MenuItem>
</Select>
</FormControl>
<TextField
label="Rack units"
type="number"
value={selectedRack.sizeU}
inputProps={{ min: 2, max: 42 }}
onChange={(event) => onRackSizeChange(selectedRack.id, clampNumber(event.target.value, 2, 42, selectedRack.sizeU))}
fullWidth
/>
<TextField label="Manufacturer" value={selectedRack.manufacturer} onChange={(event) => onRackChange(selectedRack.id, { manufacturer: event.target.value })} fullWidth />
<TextField label="Model" value={selectedRack.model} onChange={(event) => onRackChange(selectedRack.id, { model: event.target.value })} fullWidth />
<TextField label="Location" value={selectedRack.location} onChange={(event) => onRackChange(selectedRack.id, { location: event.target.value })} fullWidth />
<TextField label="Notes" value={selectedRack.notes} onChange={(event) => onRackChange(selectedRack.id, { notes: event.target.value })} multiline minRows={3} fullWidth />
<Button color="error" variant="outlined" startIcon={<Delete />} onClick={onDelete}>
Delete rack
</Button>
</Stack>
)}
{selectedEquipment && selectedEquipmentRack && selection?.kind === 'equipment' && (
<Stack spacing={2}>
<TextField
label="Name"
value={selectedEquipment.name}
onChange={(event) => onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { name: event.target.value })}
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>
<TextField
label="Manufacturer"
value={selectedEquipment.manufacturer}
onChange={(event) => onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { manufacturer: event.target.value })}
fullWidth
/>
<TextField
label="Model"
value={selectedEquipment.model}
onChange={(event) => onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { model: event.target.value })}
fullWidth
/>
<TextField
label="Serial number"
value={selectedEquipment.serialNumber}
onChange={(event) => onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { serialNumber: event.target.value })}
fullWidth
/>
<TextField
label="Asset tag"
value={selectedEquipment.assetTag}
onChange={(event) => onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { assetTag: event.target.value })}
fullWidth
/>
<TextField
label="Power watts"
type="number"
value={selectedEquipment.powerWatts}
inputProps={{ min: 0 }}
onChange={(event) => onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { powerWatts: clampNumber(event.target.value, 0, 100000, 0) })}
fullWidth
/>
<TextField
label="Notes"
value={selectedEquipment.notes}
onChange={(event) => onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { notes: event.target.value })}
multiline
minRows={3}
fullWidth
/>
<Button color="error" variant="outlined" startIcon={<Delete />} onClick={onDelete}>
Delete component
</Button>
</Stack>
)}
</Stack>
</Paper>
);
}
function MetadataSummary({ diagram }: { diagram: DiagramData }) {
const usedU = diagram.racks.reduce((sum, rack) => sum + getOccupancy(rack).used, 0);
const totalU = diagram.racks.reduce((sum, rack) => sum + rack.sizeU, 0);
const equipmentCount = diagram.racks.reduce((sum, rack) => sum + rack.equipment.length, 0);
return (
<Stack spacing={1}>
<Chip label={`${diagram.racks.length} racks/cabinets`} variant="outlined" />
<Chip label={`${equipmentCount} components`} variant="outlined" />
<Chip label={`${usedU}/${totalU || 0}U allocated`} variant="outlined" />
</Stack>
);
}

View File

@@ -0,0 +1,20 @@
import type { LibraryItem } from '../types';
export const componentLibrary: LibraryItem[] = [
{ type: 'rack', category: 'container', label: 'Rack', defaultU: 42, minU: 2, maxU: 42, color: '#607d8b' },
{ type: 'cabinet', category: 'container', label: 'Cabinet', defaultU: 42, minU: 2, maxU: 42, color: '#546e7a' },
{ type: 'switch', category: 'equipment', label: 'Switch', defaultU: 1, minU: 1, maxU: 24, color: '#00acc1' },
{ type: 'router', category: 'equipment', label: 'Router', defaultU: 1, minU: 1, maxU: 24, color: '#26a69a' },
{ type: 'firewall', category: 'equipment', label: 'Firewall', defaultU: 1, minU: 1, maxU: 24, color: '#ef5350' },
{ type: 'server-rack', category: 'equipment', label: 'Server (Rack)', defaultU: 2, minU: 1, maxU: 24, color: '#5c6bc0' },
{ type: 'blade-chassis', category: 'equipment', label: 'Blade Chassis', defaultU: 10, minU: 1, maxU: 24, color: '#7e57c2' },
{ 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: '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' },
];
export const getLibraryItem = (type: string | undefined) =>
componentLibrary.find((item) => item.type === type);

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

20
src/styles.css Normal file
View File

@@ -0,0 +1,20 @@
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
font-family: Roboto, Arial, sans-serif;
}
button,
input,
textarea {
font: inherit;
}

76
src/types.ts Normal file
View File

@@ -0,0 +1,76 @@
export type ThemeMode = 'light' | 'dark';
export type ComponentType =
| 'rack'
| 'cabinet'
| 'switch'
| 'router'
| 'firewall'
| 'server-rack'
| 'blade-chassis'
| 'storage-array'
| 'storage-switch'
| 'patch-panel'
| 'cable-management'
| 'kvm'
| 'kvm-console';
export type LibraryCategory = 'container' | 'equipment';
export interface LibraryItem {
type: ComponentType;
category: LibraryCategory;
label: string;
defaultU: number;
minU: number;
maxU: number;
color: string;
}
export interface Equipment {
id: string;
type: ComponentType;
name: string;
sizeU: number;
uStart: number;
manufacturer: string;
model: string;
serialNumber: string;
assetTag: string;
powerWatts: number;
notes: string;
color: string;
}
export interface RackContainer {
id: string;
type: 'rack' | 'cabinet';
name: string;
sizeU: number;
manufacturer: string;
model: string;
location: string;
notes: string;
equipment: Equipment[];
}
export interface DiagramData {
appName: 'Datacenter Modeler';
schemaVersion: 1;
name: string;
createdAt: string;
updatedAt: string;
racks: RackContainer[];
}
export type Selection =
| { kind: 'rack'; rackId: string }
| { kind: 'equipment'; rackId: string; equipmentId: string }
| null;
export interface DragPayload {
source: 'library' | 'equipment';
type?: ComponentType;
rackId?: string;
equipmentId?: string;
}

169
src/utils/model.ts Normal file
View File

@@ -0,0 +1,169 @@
import type { DiagramData, Equipment, RackContainer } from '../types';
import { getLibraryItem } from '../data/componentLibrary';
export const MAX_CONTAINERS = 10;
export const createId = (prefix: string) =>
`${prefix}-${Math.random().toString(36).slice(2, 8)}-${Date.now().toString(36)}`;
export const createDiagram = (): DiagramData => {
const now = new Date().toISOString();
return {
appName: 'Datacenter Modeler',
schemaVersion: 1,
name: 'Untitled Datacenter Layout',
createdAt: now,
updatedAt: now,
racks: [],
};
};
export const createRack = (type: 'rack' | 'cabinet', index: number, sizeU = 42): RackContainer => ({
id: createId(type),
type,
name: `${type === 'rack' ? 'Rack' : 'Cabinet'} ${index}`,
sizeU,
manufacturer: '',
model: '',
location: '',
notes: '',
equipment: [],
});
export const createEquipment = (type: string, rack: RackContainer, preferredU: number): Equipment | null => {
const libraryItem = getLibraryItem(type);
if (!libraryItem || libraryItem.category !== 'equipment') {
return null;
}
const uStart = findAvailableU(rack, libraryItem.defaultU, preferredU);
if (uStart === null) {
return null;
}
return {
id: createId(libraryItem.type),
type: libraryItem.type,
name: libraryItem.label,
sizeU: libraryItem.defaultU,
uStart,
manufacturer: '',
model: '',
serialNumber: '',
assetTag: '',
powerWatts: 0,
notes: '',
color: libraryItem.color,
};
};
export const findAvailableU = (
rack: RackContainer,
sizeU: number,
preferredTopU: number,
movingEquipmentId?: string,
) => {
const preferredStart = Math.max(1, Math.min(preferredTopU - sizeU + 1, rack.sizeU - sizeU + 1));
const candidates = [
preferredStart,
...Array.from({ length: rack.sizeU - sizeU + 1 }, (_, index) => rack.sizeU - sizeU + 1 - index),
];
for (const uStart of [...new Set(candidates)]) {
if (isRangeAvailable(rack, uStart, sizeU, movingEquipmentId)) {
return uStart;
}
}
return null;
};
export const isRangeAvailable = (
rack: RackContainer,
uStart: number,
sizeU: number,
ignoreEquipmentId?: string,
) => {
if (sizeU < 1 || uStart < 1 || uStart + sizeU - 1 > rack.sizeU) {
return false;
}
return rack.equipment.every((item) => {
if (item.id === ignoreEquipmentId) {
return true;
}
const itemEnd = item.uStart + item.sizeU - 1;
const newEnd = uStart + sizeU - 1;
return newEnd < item.uStart || uStart > itemEnd;
});
};
export const getOccupancy = (rack: RackContainer) => {
const used = rack.equipment.reduce((sum, item) => sum + item.sizeU, 0);
return {
used,
total: rack.sizeU,
percent: rack.sizeU ? Math.round((used / rack.sizeU) * 100) : 0,
};
};
export const normalizeDiagram = (value: unknown): DiagramData => {
if (!value || typeof value !== 'object') {
throw new Error('The selected file is not a valid Datacenter Modeler JSON file.');
}
const candidate = value as Partial<DiagramData>;
if (!Array.isArray(candidate.racks)) {
throw new Error('The selected JSON file does not contain rack data.');
}
const now = new Date().toISOString();
return {
appName: 'Datacenter Modeler',
schemaVersion: 1,
name: typeof candidate.name === 'string' ? candidate.name : 'Imported Datacenter Layout',
createdAt: typeof candidate.createdAt === 'string' ? candidate.createdAt : now,
updatedAt: now,
racks: candidate.racks.slice(0, MAX_CONTAINERS).map((rack, rackIndex) => ({
id: rack.id || createId('rack'),
type: rack.type === 'cabinet' ? 'cabinet' : 'rack',
name: rack.name || `Rack ${rackIndex + 1}`,
sizeU: clampNumber(rack.sizeU, 2, 42, 42),
manufacturer: rack.manufacturer || '',
model: rack.model || '',
location: rack.location || '',
notes: rack.notes || '',
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);
return {
id: item.id || createId('equipment'),
type: libraryItem.type,
name: item.name || `${libraryItem.label} ${itemIndex + 1}`,
sizeU,
uStart: clampNumber(item.uStart, 1, Math.max(1, rack.sizeU - sizeU + 1), 1),
manufacturer: item.manufacturer || '',
model: item.model || '',
serialNumber: item.serialNumber || '',
assetTag: item.assetTag || '',
powerWatts: clampNumber(item.powerWatts, 0, 100000, 0),
notes: item.notes || '',
color: item.color || libraryItem.color,
};
})
: [],
})),
};
};
export const clampNumber = (value: unknown, min: number, max: number, fallback: number) => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return fallback;
}
return Math.max(min, Math.min(max, Math.round(parsed)));
};

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

6
vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react],
});