From a815d19e52324b5181fa78afeeed8c601dbfc2db Mon Sep 17 00:00:00 2001 From: mpuckett Date: Thu, 14 May 2026 02:09:46 -0500 Subject: [PATCH] Initial Commit --- .gitignore | 14 + README.md | 159 ++++++ index.html | 12 + package.json | 28 + src/App.tsx | 990 +++++++++++++++++++++++++++++++++++ src/data/componentLibrary.ts | 20 + src/main.tsx | 10 + src/styles.css | 20 + src/types.ts | 76 +++ src/utils/model.ts | 169 ++++++ tsconfig.json | 21 + tsconfig.node.json | 10 + vite.config.ts | 6 + 13 files changed, 1535 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 index.html create mode 100644 package.json create mode 100644 src/App.tsx create mode 100644 src/data/componentLibrary.ts create mode 100644 src/main.tsx create mode 100644 src/styles.css create mode 100644 src/types.ts create mode 100644 src/utils/model.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9673348 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7459b28 --- /dev/null +++ b/README.md @@ -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. + diff --git a/index.html b/index.html new file mode 100644 index 0000000..d6beb01 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Datacenter Modeler + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..ab0937b --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..06cb937 --- /dev/null +++ b/src/App.tsx @@ -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 = { + rack: , + cabinet: , + switch: , + router: , + firewall: , + 'server-rack': , + 'blade-chassis': , + 'storage-array': , + 'storage-switch': , + 'patch-panel': , + 'cable-management': , + kvm: , + 'kvm-console': , +}; + +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(() => (localStorage.getItem('datacenter-modeler.theme') as ThemeMode) || 'dark'); + const [diagram, setDiagram] = useState(loadInitialDiagram); + const [selection, setSelection] = useState(null); + const [exportAnchor, setExportAnchor] = useState(null); + const [pendingContainer, setPendingContainer] = useState<'rack' | 'cabinet' | null>(null); + const [pendingSize, setPendingSize] = useState(42); + const [notice, setNotice] = useState(''); + const workspaceRef = useRef(null); + const fileInputRef = useRef(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) => { + 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) => { + 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) => { + 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) => { + 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 ( + + + + + + + + + Datacenter Modeler + + + {diagram.racks.length}/{MAX_CONTAINERS} racks and cabinets + + + updateDiagramName(event.target.value)} + size="small" + label="Diagram" + sx={{ width: { xs: 180, md: 320 }, display: { xs: 'none', sm: 'block' } }} + /> + + + + + + + fileInputRef.current?.click()}> + + + + + + + + setThemeMode(checked ? 'dark' : 'light')} /> + + + + + + + + setExportAnchor(null)}> + + PDF report + + exportImage('png')}> + Rack layouts as PNG + + exportImage('jpeg')}> + Rack layouts as JPG + + + + + + + event.preventDefault()} + sx={{ + minHeight: 0, + overflow: 'auto', + p: 2, + borderLeft: { lg: 1 }, + borderRight: { lg: 1 }, + borderColor: 'divider', + }} + > + alpha(muiTheme.palette.background.paper, themeMode === 'dark' ? 0.55 : 0.86), + }} + > + {diagram.racks.length === 0 ? ( + + + No racks in diagram + + ) : ( + diagram.racks.map((rack) => ( + setSelection({ kind: 'rack', rackId: rack.id })} + onSelectEquipment={(equipmentId) => setSelection({ kind: 'equipment', rackId: rack.id, equipmentId })} + onDrop={(event) => handleRackDrop(event, rack.id)} + /> + )) + )} + + + + + + + setPendingContainer(null)} maxWidth="xs" fullWidth> + Add {pendingContainer === 'cabinet' ? 'Cabinet' : 'Rack'} + + + setPendingSize(clampNumber(event.target.value, 2, 42, 42))} + inputProps={{ min: 2, max: 42 }} + fullWidth + /> + setPendingSize(value as number)} /> + + + + + + + + + setNotice('')} /> + + + ); +} + +function LibraryPanel() { + const containers = componentLibrary.filter((item) => item.category === 'container'); + const equipment = componentLibrary.filter((item) => item.category === 'equipment'); + + return ( + + + + + Containers + + + {containers.map((item) => ( + + ))} + + + + + + Components + + + {equipment.map((item) => ( + + ))} + + + + + ); +} + +function LibraryTile({ item }: { item: (typeof componentLibrary)[number] }) { + return ( + 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' }, + }} + > + {icons[item.type]} + + {item.label} + + + + ); +} + +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 ( + + + + + + {rack.name} + + + {rack.sizeU}U {rack.type} + + + 85 ? 'warning' : 'default'} /> + + + + + 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) => ( + + {u} + + ))} + + {[...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 ( + { + 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, + }} + > + + {item.name} + + + U{item.uStart}-{item.uStart + item.sizeU - 1} + + + ); + })} + + + ); +} + +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) => void; + onRackSizeChange: (rackId: string, sizeU: number) => void; + onEquipmentChange: (rackId: string, equipmentId: string, patch: Partial) => void; + onEquipmentPlacementChange: (rackId: string, equipmentId: string, patch: Partial) => void; + onDelete: () => void; +}) { + return ( + + + + + Properties + + + {selectedEquipment ? selectedEquipment.name : selectedRack ? selectedRack.name : 'Diagram'} + + + + {!selection && ( + + onDiagramNameChange(event.target.value)} fullWidth /> + + + )} + + {selectedRack && selection?.kind === 'rack' && ( + + onRackChange(selectedRack.id, { name: event.target.value })} fullWidth /> + + Type + + + onRackSizeChange(selectedRack.id, clampNumber(event.target.value, 2, 42, selectedRack.sizeU))} + fullWidth + /> + onRackChange(selectedRack.id, { manufacturer: event.target.value })} fullWidth /> + onRackChange(selectedRack.id, { model: event.target.value })} fullWidth /> + onRackChange(selectedRack.id, { location: event.target.value })} fullWidth /> + onRackChange(selectedRack.id, { notes: event.target.value })} multiline minRows={3} fullWidth /> + + + )} + + {selectedEquipment && selectedEquipmentRack && selection?.kind === 'equipment' && ( + + onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { name: event.target.value })} + fullWidth + /> + + + + onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, { + sizeU: clampNumber(event.target.value, 1, 24, selectedEquipment.sizeU), + }) + } + fullWidth + /> + + onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, { + uStart: clampNumber(event.target.value, 1, selectedEquipmentRack.sizeU, selectedEquipment.uStart), + }) + } + fullWidth + /> + + onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { manufacturer: event.target.value })} + fullWidth + /> + onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { model: event.target.value })} + fullWidth + /> + onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { serialNumber: event.target.value })} + fullWidth + /> + onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { assetTag: event.target.value })} + fullWidth + /> + onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { powerWatts: clampNumber(event.target.value, 0, 100000, 0) })} + fullWidth + /> + onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, { notes: event.target.value })} + multiline + minRows={3} + fullWidth + /> + + + )} + + + ); +} + +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 ( + + + + + + ); +} diff --git a/src/data/componentLibrary.ts b/src/data/componentLibrary.ts new file mode 100644 index 0000000..611a814 --- /dev/null +++ b/src/data/componentLibrary.ts @@ -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); diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..8f7da6f --- /dev/null +++ b/src/main.tsx @@ -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( + + + , +); diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..6a9e106 --- /dev/null +++ b/src/styles.css @@ -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; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..321d1cb --- /dev/null +++ b/src/types.ts @@ -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; +} diff --git a/src/utils/model.ts b/src/utils/model.ts new file mode 100644 index 0000000..186f250 --- /dev/null +++ b/src/utils/model.ts @@ -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; + 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))); +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..68ea2c5 --- /dev/null +++ b/tsconfig.json @@ -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" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..7065ca9 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..2181a6f --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react], +});