Initial Commit
This commit is contained in:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal 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
159
README.md
Normal 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
12
index.html
Normal 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
28
package.json
Normal 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
990
src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/data/componentLibrary.ts
Normal file
20
src/data/componentLibrary.ts
Normal 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
10
src/main.tsx
Normal 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
20
src/styles.css
Normal 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
76
src/types.ts
Normal 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
169
src/utils/model.ts
Normal 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
21
tsconfig.json
Normal 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
10
tsconfig.node.json
Normal 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
6
vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react],
|
||||
});
|
||||
Reference in New Issue
Block a user