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