2283 lines
88 KiB
TypeScript
2283 lines
88 KiB
TypeScript
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 {
|
|
Add,
|
|
Cable,
|
|
Close,
|
|
DarkMode,
|
|
Delete,
|
|
Dns,
|
|
Domain,
|
|
Download,
|
|
DragIndicator,
|
|
FileOpen,
|
|
Image,
|
|
Inventory2,
|
|
LightMode,
|
|
OpenInNew,
|
|
PictureAsPdf,
|
|
Power,
|
|
PushPin,
|
|
PushPinOutlined,
|
|
Router,
|
|
Save,
|
|
Security,
|
|
Settings,
|
|
SettingsInputComponent,
|
|
Storage,
|
|
Terminal,
|
|
VolunteerActivism,
|
|
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,
|
|
createId,
|
|
createRack,
|
|
findAvailableU,
|
|
getOccupancy,
|
|
isRangeAvailable,
|
|
normalizeDiagram,
|
|
} from './utils/model';
|
|
|
|
const STORAGE_KEY = 'datacenter-modeler.diagram';
|
|
const RACK_ROW_HEIGHT = 14;
|
|
const PORT_MAPPED_COMPONENTS = new Set<ComponentType>([
|
|
'blade-chassis',
|
|
'firewall',
|
|
'kvm',
|
|
'router',
|
|
'server-rack',
|
|
'storage-array',
|
|
'storage-switch',
|
|
'switch',
|
|
]);
|
|
|
|
const commandPanelSx = (mode: ThemeMode) => ({
|
|
border: `1px solid ${mode === 'dark' ? 'rgba(97, 255, 207, 0.18)' : 'rgba(0, 108, 95, 0.22)'}`,
|
|
background:
|
|
mode === 'dark'
|
|
? 'linear-gradient(145deg, rgba(9, 18, 20, 0.78), rgba(10, 22, 27, 0.58) 54%, rgba(50, 40, 20, 0.32))'
|
|
: 'linear-gradient(145deg, rgba(246, 250, 242, 0.94), rgba(226, 235, 225, 0.88) 54%, rgba(218, 231, 220, 0.82))',
|
|
backdropFilter: 'blur(22px) saturate(145%)',
|
|
color: 'text.primary',
|
|
boxShadow:
|
|
mode === 'dark'
|
|
? '0 24px 80px rgba(0, 0, 0, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.06)'
|
|
: '0 24px 70px rgba(23, 38, 33, 0.18), inset 0 1px 0 rgba(255, 255, 255, 0.72)',
|
|
});
|
|
|
|
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" />,
|
|
pdu: <Power 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 [welcomeOpen, setWelcomeOpen] = useState(true);
|
|
const [donationAmount, setDonationAmount] = useState(5);
|
|
const [propertiesOpen, setPropertiesOpen] = useState(true);
|
|
const [propertiesDocked, setPropertiesDocked] = useState(true);
|
|
const [propertiesPosition, setPropertiesPosition] = useState({ x: 920, y: 112 });
|
|
const dragOffsetRef = useRef({ x: 0, y: 0 });
|
|
const [draggingProperties, setDraggingProperties] = useState(false);
|
|
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' ? '#61ffcf' : '#006c5f' },
|
|
secondary: { main: '#f0b84d' },
|
|
success: { main: '#5cff8d' },
|
|
warning: { main: '#f0b84d' },
|
|
error: { main: '#ff5f6d' },
|
|
background:
|
|
themeMode === 'dark'
|
|
? { default: '#050807', paper: 'rgba(9, 18, 20, 0.76)' }
|
|
: { default: '#dfe7df', paper: 'rgba(246, 250, 242, 0.86)' },
|
|
text:
|
|
themeMode === 'dark'
|
|
? { primary: '#e6fff7', secondary: 'rgba(198, 229, 216, 0.72)', disabled: 'rgba(198, 229, 216, 0.38)' }
|
|
: { primary: '#16231f', secondary: 'rgba(22, 35, 31, 0.68)', disabled: 'rgba(22, 35, 31, 0.36)' },
|
|
divider: themeMode === 'dark' ? 'rgba(97, 255, 207, 0.16)' : 'rgba(0, 108, 95, 0.18)',
|
|
},
|
|
shape: { borderRadius: 6 },
|
|
typography: {
|
|
fontFamily: '"Inter", "Roboto", "Helvetica", Arial, sans-serif',
|
|
h6: { letterSpacing: 0, fontWeight: 900 },
|
|
subtitle1: { letterSpacing: 0, fontWeight: 800 },
|
|
subtitle2: { letterSpacing: 0, fontWeight: 800 },
|
|
overline: {
|
|
fontFamily: '"Roboto Mono", "SFMono-Regular", Consolas, monospace',
|
|
letterSpacing: '0.08em',
|
|
fontWeight: 900,
|
|
},
|
|
caption: { fontFamily: '"Roboto Mono", "SFMono-Regular", Consolas, monospace' },
|
|
button: {
|
|
textTransform: 'none',
|
|
fontWeight: 850,
|
|
letterSpacing: 0,
|
|
fontFamily: '"Roboto Mono", "SFMono-Regular", Consolas, monospace',
|
|
},
|
|
},
|
|
components: {
|
|
MuiCssBaseline: {
|
|
styleOverrides: {
|
|
body: {
|
|
backgroundColor: themeMode === 'dark' ? '#050807' : '#dfe7df',
|
|
backgroundImage:
|
|
themeMode === 'dark'
|
|
? 'radial-gradient(circle at 12% 18%, rgba(97, 255, 207, 0.12), transparent 30%), radial-gradient(circle at 84% 12%, rgba(240, 184, 77, 0.1), transparent 28%), url("/assets/datacenter-command-bg.png")'
|
|
: 'radial-gradient(circle at 12% 18%, rgba(0, 108, 95, 0.1), transparent 30%), radial-gradient(circle at 84% 12%, rgba(156, 92, 12, 0.1), transparent 28%), url("/assets/datacenter-command-bg.png")',
|
|
backgroundAttachment: 'fixed',
|
|
backgroundPosition: 'center',
|
|
backgroundSize: 'cover',
|
|
},
|
|
'*::selection': {
|
|
background: themeMode === 'dark' ? 'rgba(97, 255, 207, 0.35)' : 'rgba(0, 108, 95, 0.24)',
|
|
},
|
|
'*::-webkit-scrollbar': { width: 10, height: 10 },
|
|
'*::-webkit-scrollbar-thumb': {
|
|
backgroundColor: themeMode === 'dark' ? 'rgba(97, 255, 207, 0.28)' : 'rgba(0, 108, 95, 0.28)',
|
|
border: '2px solid transparent',
|
|
backgroundClip: 'padding-box',
|
|
},
|
|
'*::-webkit-scrollbar-track': { backgroundColor: 'rgba(0, 0, 0, 0.16)' },
|
|
},
|
|
},
|
|
MuiAppBar: {
|
|
styleOverrides: {
|
|
root: {
|
|
color: themeMode === 'dark' ? '#e6fff7' : '#16231f',
|
|
background:
|
|
themeMode === 'dark'
|
|
? 'linear-gradient(135deg, rgba(6, 14, 15, 0.82), rgba(18, 34, 33, 0.68))'
|
|
: 'linear-gradient(135deg, rgba(244, 249, 241, 0.9), rgba(222, 234, 222, 0.76))',
|
|
backdropFilter: 'blur(20px) saturate(145%)',
|
|
},
|
|
},
|
|
},
|
|
MuiPaper: {
|
|
styleOverrides: {
|
|
root: {
|
|
backgroundImage: 'none',
|
|
},
|
|
},
|
|
},
|
|
MuiButton: {
|
|
styleOverrides: {
|
|
root: {
|
|
borderRadius: 5,
|
|
minHeight: 36,
|
|
boxShadow: 'none',
|
|
},
|
|
containedPrimary: {
|
|
color: '#04100d',
|
|
background: 'linear-gradient(135deg, #61ffcf, #8be9ff)',
|
|
boxShadow: '0 0 22px rgba(97, 255, 207, 0.24)',
|
|
},
|
|
outlined: {
|
|
borderColor: themeMode === 'dark' ? 'rgba(97, 255, 207, 0.28)' : 'rgba(0, 108, 95, 0.28)',
|
|
},
|
|
},
|
|
},
|
|
MuiIconButton: {
|
|
styleOverrides: {
|
|
root: {
|
|
borderRadius: 5,
|
|
border: themeMode === 'dark' ? '1px solid rgba(97, 255, 207, 0.16)' : '1px solid rgba(0, 108, 95, 0.16)',
|
|
backgroundColor: themeMode === 'dark' ? 'rgba(97, 255, 207, 0.06)' : 'rgba(255, 255, 255, 0.42)',
|
|
},
|
|
},
|
|
},
|
|
MuiOutlinedInput: {
|
|
defaultProps: {
|
|
size: 'small',
|
|
},
|
|
styleOverrides: {
|
|
root: {
|
|
borderRadius: 5,
|
|
backgroundColor: themeMode === 'dark' ? 'rgba(3, 10, 11, 0.46)' : 'rgba(255, 255, 255, 0.52)',
|
|
fontSize: '0.88rem',
|
|
'& fieldset': {
|
|
borderColor: themeMode === 'dark' ? 'rgba(97, 255, 207, 0.2)' : 'rgba(0, 108, 95, 0.22)',
|
|
},
|
|
'&:hover fieldset': {
|
|
borderColor: themeMode === 'dark' ? 'rgba(97, 255, 207, 0.45)' : 'rgba(0, 108, 95, 0.42)',
|
|
},
|
|
'&.Mui-focused': {
|
|
boxShadow:
|
|
themeMode === 'dark'
|
|
? '0 0 0 1px rgba(97, 255, 207, 0.22), 0 0 18px rgba(97, 255, 207, 0.12)'
|
|
: '0 0 0 1px rgba(0, 108, 95, 0.18)',
|
|
},
|
|
},
|
|
input: {
|
|
paddingTop: 8,
|
|
paddingBottom: 8,
|
|
},
|
|
multiline: {
|
|
paddingTop: 8,
|
|
paddingBottom: 8,
|
|
},
|
|
},
|
|
},
|
|
MuiTextField: {
|
|
defaultProps: {
|
|
size: 'small',
|
|
},
|
|
},
|
|
MuiFormControl: {
|
|
defaultProps: {
|
|
size: 'small',
|
|
},
|
|
styleOverrides: {
|
|
root: {
|
|
minWidth: 0,
|
|
},
|
|
},
|
|
},
|
|
MuiInputLabel: {
|
|
styleOverrides: {
|
|
root: {
|
|
fontFamily: '"Roboto Mono", "SFMono-Regular", Consolas, monospace',
|
|
fontSize: '0.76rem',
|
|
fontWeight: 800,
|
|
textTransform: 'none',
|
|
},
|
|
},
|
|
},
|
|
MuiSelect: {
|
|
defaultProps: {
|
|
size: 'small',
|
|
},
|
|
styleOverrides: {
|
|
select: {
|
|
paddingTop: 8,
|
|
paddingBottom: 8,
|
|
fontSize: '0.88rem',
|
|
},
|
|
},
|
|
},
|
|
MuiMenuItem: {
|
|
styleOverrides: {
|
|
root: {
|
|
minHeight: 34,
|
|
fontSize: '0.88rem',
|
|
fontFamily: '"Roboto Mono", "SFMono-Regular", Consolas, monospace',
|
|
},
|
|
},
|
|
},
|
|
MuiChip: {
|
|
styleOverrides: {
|
|
root: {
|
|
borderRadius: 4,
|
|
fontFamily: '"Roboto Mono", "SFMono-Regular", Consolas, monospace',
|
|
fontWeight: 800,
|
|
},
|
|
},
|
|
},
|
|
MuiDialog: {
|
|
styleOverrides: {
|
|
paper: {
|
|
...commandPanelSx(themeMode),
|
|
borderRadius: 8,
|
|
},
|
|
},
|
|
},
|
|
MuiMenu: {
|
|
styleOverrides: {
|
|
paper: {
|
|
...commandPanelSx(themeMode),
|
|
borderRadius: 6,
|
|
},
|
|
},
|
|
},
|
|
MuiDivider: {
|
|
styleOverrides: {
|
|
root: {
|
|
borderColor: themeMode === 'dark' ? 'rgba(97, 255, 207, 0.16)' : 'rgba(0, 108, 95, 0.16)',
|
|
},
|
|
},
|
|
},
|
|
MuiLinearProgress: {
|
|
styleOverrides: {
|
|
root: {
|
|
backgroundColor: themeMode === 'dark' ? 'rgba(97, 255, 207, 0.12)' : 'rgba(0, 108, 95, 0.14)',
|
|
},
|
|
bar: {
|
|
background: 'linear-gradient(90deg, #61ffcf, #f0b84d)',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
[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]);
|
|
|
|
useEffect(() => {
|
|
if (!draggingProperties) {
|
|
return undefined;
|
|
}
|
|
|
|
const handleMove = (event: MouseEvent) => {
|
|
const nextX = clampNumber(event.clientX - dragOffsetRef.current.x, 8, window.innerWidth - 360, propertiesPosition.x);
|
|
const nextY = clampNumber(event.clientY - dragOffsetRef.current.y, 8, window.innerHeight - 160, propertiesPosition.y);
|
|
setPropertiesPosition({ x: nextX, y: nextY });
|
|
};
|
|
|
|
const handleUp = () => setDraggingProperties(false);
|
|
|
|
window.addEventListener('mousemove', handleMove);
|
|
window.addEventListener('mouseup', handleUp);
|
|
|
|
return () => {
|
|
window.removeEventListener('mousemove', handleMove);
|
|
window.removeEventListener('mouseup', handleUp);
|
|
};
|
|
}, [draggingProperties, propertiesPosition.x, propertiesPosition.y]);
|
|
|
|
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 });
|
|
setPropertiesOpen(true);
|
|
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;
|
|
}
|
|
|
|
if (equipment.type === 'pdu' && equipment.pduMount === 'vertical') {
|
|
commitDiagram((current) => ({
|
|
...current,
|
|
racks: current.racks.map((rack) => {
|
|
if (rack.id === sourceRack.id && rack.id === destinationRack.id) {
|
|
return rack;
|
|
}
|
|
|
|
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: 1 }] };
|
|
}
|
|
|
|
return rack;
|
|
}),
|
|
}));
|
|
setSelection({ kind: 'equipment', rackId, equipmentId: equipment.id });
|
|
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
|
|
.filter((item) => !(item.type === 'pdu' && item.pduMount === 'vertical'))
|
|
.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 nextMount = patch.pduMount ?? equipment.pduMount;
|
|
|
|
if (equipment.type === 'pdu' && nextMount === 'vertical') {
|
|
updateEquipment(rackId, equipmentId, {
|
|
...patch,
|
|
pduMount: 'vertical',
|
|
sizeU: clampNumber(patch.sizeU ?? equipment.sizeU, 1, 3, 1),
|
|
uStart: 1,
|
|
pduSide: patch.pduSide ?? equipment.pduSide ?? 'right',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const maxSize = equipment.type === 'pdu' ? 12 : 24;
|
|
const nextSize = clampNumber(patch.sizeU ?? equipment.sizeU, 1, maxSize, equipment.sizeU);
|
|
const maxStart = Math.max(1, rack.sizeU - nextSize + 1);
|
|
const requestedU = clampNumber(patch.uStart ?? equipment.uStart, 1, maxStart, equipment.uStart);
|
|
const nextU = isRangeAvailable(rack, requestedU, nextSize, equipmentId)
|
|
? requestedU
|
|
: findAvailableU(rack, nextSize, requestedU + nextSize - 1, equipmentId);
|
|
|
|
if (nextU === null) {
|
|
setNotice('That size or position overlaps another component.');
|
|
return;
|
|
}
|
|
|
|
updateEquipment(rackId, equipmentId, { ...patch, pduMount: nextMount, 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 openDonation = () => {
|
|
const amount = clampNumber(donationAmount, 1, 500, 5);
|
|
window.open(`https://cash.app/$MatthewPuckett/${amount}`, '_blank', 'noopener,noreferrer');
|
|
};
|
|
|
|
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 patchPanelDetails =
|
|
item.type === 'patch-panel'
|
|
? ` | ${item.patchPanelMedium === 'fiberoptic' ? 'Fiber optic' : 'Copper'} | ${item.patchPanelPorts} ports | ${item.patchPanelPortMap.length} mapped`
|
|
: '';
|
|
const networkPortDetails = PORT_MAPPED_COMPONENTS.has(item.type) ? ` | ${item.networkPorts.length} mapped ports` : '';
|
|
const managedDetails =
|
|
PORT_MAPPED_COMPONENTS.has(item.type) && item.managed
|
|
? ` | Managed ${item.managedHostname || 'no host'}`
|
|
: '';
|
|
const pduDetails =
|
|
item.type === 'pdu'
|
|
? ` | ${item.pduMount === 'vertical' ? `Vertical ${item.pduSide}, ${item.sizeU}U wide` : `Horizontal ${item.sizeU}U`} | In ${item.pduInputVoltage || 'n/a'} | ${item.pduOutputPorts.length} outputs${item.pduManaged ? ` | Managed ${item.pduHostname || 'no host'}` : ''}`
|
|
: '';
|
|
const rackPosition =
|
|
item.type === 'pdu' && item.pduMount === 'vertical'
|
|
? `${item.pduSide === 'left' ? 'Left' : 'Right'} side`
|
|
: `U${item.uStart}-${item.uStart + item.sizeU - 1}`;
|
|
const line = `${rackPosition}: ${item.name} | ${item.manufacturer || 'n/a'} ${item.model || ''} | ${item.assetTag || 'no asset tag'}${patchPanelDetails}${networkPortDetails}${managedDetails}${pduDetails}`;
|
|
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: 'transparent',
|
|
color: 'text.primary',
|
|
p: { xs: 1, md: 1.5 },
|
|
gap: { xs: 1, md: 1.5 },
|
|
overflow: 'hidden',
|
|
'&::before': {
|
|
content: '""',
|
|
position: 'fixed',
|
|
inset: 0,
|
|
pointerEvents: 'none',
|
|
background:
|
|
'linear-gradient(rgba(97, 255, 207, 0.025) 50%, transparent 50%), radial-gradient(circle at 50% 100%, rgba(240, 184, 77, 0.08), transparent 34%)',
|
|
backgroundSize: '100% 4px, 100% 100%',
|
|
mixBlendMode: themeMode === 'dark' ? 'screen' : 'multiply',
|
|
opacity: themeMode === 'dark' ? 0.72 : 0.18,
|
|
zIndex: 0,
|
|
},
|
|
'& > *': { position: 'relative', zIndex: 1 },
|
|
}}
|
|
>
|
|
<AppBar
|
|
position="static"
|
|
color="default"
|
|
elevation={0}
|
|
sx={{
|
|
...commandPanelSx(themeMode),
|
|
flex: '0 0 auto',
|
|
borderRadius: 2,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<Toolbar
|
|
sx={{
|
|
minHeight: { xs: 118, sm: 66 },
|
|
gap: 1,
|
|
rowGap: 0.75,
|
|
px: { xs: 1, md: 2 },
|
|
flexWrap: { xs: 'wrap', sm: 'nowrap' },
|
|
alignContent: 'center',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
width: 38,
|
|
height: 38,
|
|
flex: '0 0 auto',
|
|
display: 'grid',
|
|
placeItems: 'center',
|
|
color: 'primary.main',
|
|
border: '1px solid',
|
|
borderColor: 'primary.main',
|
|
background: 'linear-gradient(135deg, rgba(97, 255, 207, 0.16), rgba(240, 184, 77, 0.1))',
|
|
boxShadow: '0 0 28px rgba(97, 255, 207, 0.18), inset 0 0 18px rgba(97, 255, 207, 0.08)',
|
|
clipPath: 'polygon(10% 0, 100% 0, 100% 76%, 76% 100%, 0 100%, 0 10%)',
|
|
}}
|
|
>
|
|
<Dns fontSize="small" />
|
|
</Box>
|
|
<Box sx={{ minWidth: 0, flex: { xs: '1 1 220px', sm: 1 } }}>
|
|
<Typography variant="h6" sx={{ fontWeight: 950, lineHeight: 1.05 }}>
|
|
Datacenter Modeler
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary" sx={{ textTransform: 'uppercase' }}>
|
|
{diagram.racks.length}/{MAX_CONTAINERS}
|
|
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>
|
|
{' '}
|
|
racks and cabinets
|
|
</Box>
|
|
<Box component="span" sx={{ display: { xs: 'inline', sm: 'none' } }}>
|
|
{' '}
|
|
racks
|
|
</Box>
|
|
</Typography>
|
|
</Box>
|
|
<TextField
|
|
value={diagram.name}
|
|
onChange={(event) => updateDiagramName(event.target.value)}
|
|
size="small"
|
|
label="Diagram"
|
|
fullWidth
|
|
sx={{
|
|
width: { xs: 180, sm: 340, md: 430, xl: 520 },
|
|
maxWidth: { md: '34vw', xl: 520 },
|
|
display: { xs: 'none', sm: 'block' },
|
|
flex: { sm: '0 0 auto', md: 'none' },
|
|
position: { md: 'absolute' },
|
|
left: { md: '50%' },
|
|
top: { md: '50%' },
|
|
transform: { md: 'translate(-50%, -50%)' },
|
|
zIndex: 2,
|
|
'& .MuiInputBase-input': {
|
|
textOverflow: 'clip',
|
|
},
|
|
}}
|
|
/>
|
|
<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>
|
|
<Tooltip title={propertiesOpen ? 'Hide properties' : 'Show properties'}>
|
|
<IconButton
|
|
color={propertiesOpen ? 'secondary' : 'primary'}
|
|
onClick={() => setPropertiesOpen((open) => !open)}
|
|
aria-label={propertiesOpen ? 'Hide properties' : 'Show properties'}
|
|
>
|
|
<Settings />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Button
|
|
variant="contained"
|
|
endIcon={<Download />}
|
|
onClick={(event) => setExportAnchor(event.currentTarget)}
|
|
sx={{ minWidth: { xs: 48, sm: 96 }, px: { xs: 1.25, sm: 2 } }}
|
|
>
|
|
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>
|
|
Export
|
|
</Box>
|
|
</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)' },
|
|
gap: { xs: 1, md: 1.5 },
|
|
}}
|
|
>
|
|
<LibraryPanel themeMode={themeMode} />
|
|
|
|
<Box
|
|
onDrop={handleCanvasDrop}
|
|
onDragOver={(event) => event.preventDefault()}
|
|
sx={{
|
|
minHeight: 0,
|
|
overflow: 'auto',
|
|
p: { xs: 1, md: 1.5 },
|
|
borderRadius: 2,
|
|
...commandPanelSx(themeMode),
|
|
}}
|
|
>
|
|
<Box
|
|
ref={workspaceRef}
|
|
sx={{
|
|
minHeight: '100%',
|
|
minWidth: { xs: '100%', sm: 620 },
|
|
width: diagram.racks.length ? 'max-content' : '100%',
|
|
display: 'flex',
|
|
alignItems: 'flex-start',
|
|
gap: 2.25,
|
|
p: { xs: 1.5, md: 2 },
|
|
border: '1px solid',
|
|
borderColor: themeMode === 'dark' ? 'rgba(97, 255, 207, 0.14)' : 'rgba(0, 108, 95, 0.16)',
|
|
bgcolor: (muiTheme) => alpha(muiTheme.palette.background.paper, themeMode === 'dark' ? 0.32 : 0.9),
|
|
backgroundImage:
|
|
themeMode === 'dark'
|
|
? 'linear-gradient(rgba(97, 255, 207, 0.045) 1px, transparent 1px), linear-gradient(90deg, rgba(97, 255, 207, 0.035) 1px, transparent 1px)'
|
|
: 'linear-gradient(rgba(0, 108, 95, 0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(0, 108, 95, 0.045) 1px, transparent 1px)',
|
|
backgroundSize: '44px 44px',
|
|
boxShadow: 'inset 0 0 80px rgba(0, 0, 0, 0.22)',
|
|
borderRadius: 1.5,
|
|
'&::after': diagram.racks.length
|
|
? {
|
|
content: '""',
|
|
flex: '0 0 1px',
|
|
alignSelf: 'stretch',
|
|
}
|
|
: undefined,
|
|
}}
|
|
>
|
|
{diagram.racks.length === 0 ? (
|
|
<Box
|
|
sx={{
|
|
m: 'auto',
|
|
textAlign: 'center',
|
|
color: 'text.secondary',
|
|
border: '1px dashed',
|
|
borderColor: 'divider',
|
|
px: 4,
|
|
py: 3,
|
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(0, 0, 0, 0.16)' : 'rgba(255, 255, 255, 0.62)'),
|
|
borderRadius: 1,
|
|
}}
|
|
>
|
|
<Dns sx={{ fontSize: 52, mb: 1, opacity: 0.62, color: 'primary.main' }} />
|
|
<Typography variant="h6">Awaiting rack drop</Typography>
|
|
<Typography variant="caption">Drag a container from the component rail.</Typography>
|
|
</Box>
|
|
) : (
|
|
diagram.racks.map((rack) => (
|
|
<RackView
|
|
key={rack.id}
|
|
rack={rack}
|
|
selected={selection}
|
|
onSelectRack={() => {
|
|
setSelection({ kind: 'rack', rackId: rack.id });
|
|
setPropertiesOpen(true);
|
|
}}
|
|
onSelectEquipment={(equipmentId) => {
|
|
setSelection({ kind: 'equipment', rackId: rack.id, equipmentId });
|
|
setPropertiesOpen(true);
|
|
}}
|
|
onDrop={(event) => handleRackDrop(event, rack.id)}
|
|
/>
|
|
))
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
|
|
{propertiesOpen && (
|
|
<PropertiesPanel
|
|
diagram={diagram}
|
|
selectedRack={selectedRack || null}
|
|
selectedEquipment={selectedEquipment || null}
|
|
selectedEquipmentRack={selectedEquipmentRack}
|
|
selection={selection}
|
|
docked={propertiesDocked}
|
|
position={propertiesPosition}
|
|
dragging={draggingProperties}
|
|
themeMode={themeMode}
|
|
onStartDrag={(event) => {
|
|
if (propertiesDocked) {
|
|
return;
|
|
}
|
|
const panel = event.currentTarget.closest('[data-properties-panel="true"]');
|
|
const bounds = panel?.getBoundingClientRect();
|
|
dragOffsetRef.current = {
|
|
x: event.clientX - (bounds?.left ?? propertiesPosition.x),
|
|
y: event.clientY - (bounds?.top ?? propertiesPosition.y),
|
|
};
|
|
setDraggingProperties(true);
|
|
}}
|
|
onClose={() => setPropertiesOpen(false)}
|
|
onToggleDock={() => setPropertiesDocked((docked) => !docked)}
|
|
onDiagramNameChange={updateDiagramName}
|
|
onRackChange={updateRack}
|
|
onRackSizeChange={updateRackSize}
|
|
onEquipmentChange={updateEquipment}
|
|
onEquipmentPlacementChange={updateEquipmentPlacement}
|
|
onDelete={deleteSelection}
|
|
/>
|
|
)}
|
|
|
|
<Dialog
|
|
open={Boolean(pendingContainer)}
|
|
onClose={() => setPendingContainer(null)}
|
|
maxWidth="xs"
|
|
fullWidth
|
|
BackdropProps={{
|
|
sx: {
|
|
backgroundColor: themeMode === 'dark' ? 'rgba(0, 0, 0, 0.28)' : 'rgba(235, 242, 233, 0.42)',
|
|
backdropFilter: 'blur(6px)',
|
|
},
|
|
}}
|
|
>
|
|
<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>
|
|
|
|
<Dialog
|
|
open={welcomeOpen}
|
|
onClose={() => setWelcomeOpen(false)}
|
|
maxWidth="sm"
|
|
fullWidth
|
|
BackdropProps={{
|
|
sx: {
|
|
backgroundColor: themeMode === 'dark' ? 'rgba(0, 0, 0, 0.28)' : 'rgba(235, 242, 233, 0.42)',
|
|
backdropFilter: 'blur(6px)',
|
|
},
|
|
}}
|
|
>
|
|
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Dns color="primary" />
|
|
Welcome to Datacenter Modeler
|
|
</DialogTitle>
|
|
<DialogContent>
|
|
<Stack spacing={2.5}>
|
|
<Typography color="text.secondary">
|
|
Build rack and cabinet diagrams by dragging components onto the workspace, snapping equipment into rack units,
|
|
editing metadata in the properties panel, and exporting your layout as JSON, images, or a PDF report.
|
|
</Typography>
|
|
|
|
<Divider />
|
|
|
|
<Box>
|
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
|
|
<VolunteerActivism color="secondary" />
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
|
|
Support the project
|
|
</Typography>
|
|
</Stack>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
This application was made by a homebrew developer (<a href="https://www.theblindengineer.com" target="_blank" rel="noopener noreferrer">
|
|
theblindengineer.com
|
|
</a>). A donation is appreciated if this tool helps you,
|
|
but it is absolutely not required.
|
|
</Typography>
|
|
<Stack spacing={1.5}>
|
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
|
{[5, 10, 20, 50].map((amount) => (
|
|
<Button
|
|
key={amount}
|
|
size="small"
|
|
variant={donationAmount === amount ? 'contained' : 'outlined'}
|
|
onClick={() => setDonationAmount(amount)}
|
|
>
|
|
${amount}
|
|
</Button>
|
|
))}
|
|
</Stack>
|
|
<TextField
|
|
label="Donation amount"
|
|
type="number"
|
|
value={donationAmount}
|
|
inputProps={{ min: 1, max: 500 }}
|
|
onChange={(event) => setDonationAmount(clampNumber(event.target.value, 1, 500, donationAmount))}
|
|
fullWidth
|
|
/>
|
|
<Button variant="contained" color="secondary" startIcon={<VolunteerActivism />} endIcon={<OpenInNew />} onClick={openDonation}>
|
|
Donate with Cash App
|
|
</Button>
|
|
</Stack>
|
|
</Box>
|
|
</Stack>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setWelcomeOpen(false)}>Continue to app</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
<Snackbar open={Boolean(notice)} autoHideDuration={3200} message={notice} onClose={() => setNotice('')} />
|
|
</Box>
|
|
</ThemeProvider>
|
|
);
|
|
}
|
|
|
|
function LibraryPanel({ themeMode }: { themeMode: ThemeMode }) {
|
|
const containers = componentLibrary.filter((item) => item.category === 'container');
|
|
const equipment = componentLibrary.filter((item) => item.category === 'equipment');
|
|
|
|
return (
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
...commandPanelSx(themeMode),
|
|
minHeight: 0,
|
|
overflow: 'auto',
|
|
p: 1.5,
|
|
display: { xs: 'none', lg: 'block' },
|
|
borderRadius: 2,
|
|
}}
|
|
>
|
|
<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',
|
|
border: '1px solid',
|
|
borderColor: alpha(item.color, 0.35),
|
|
borderLeft: 3,
|
|
borderLeftColor: item.color,
|
|
color: 'text.primary',
|
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? alpha(item.color, 0.07) : alpha(item.color, 0.13)),
|
|
boxShadow: (theme) => `inset 0 1px 0 ${alpha('#ffffff', theme.palette.mode === 'dark' ? 0.05 : 0.55)}`,
|
|
transition: 'transform 150ms ease, border-color 150ms ease, box-shadow 150ms ease, background-color 150ms ease',
|
|
'&:hover': {
|
|
transform: 'translateY(-1px)',
|
|
borderColor: alpha(item.color, 0.72),
|
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? alpha(item.color, 0.12) : alpha(item.color, 0.2)),
|
|
boxShadow: (theme) =>
|
|
`0 0 22px ${alpha(item.color, theme.palette.mode === 'dark' ? 0.16 : 0.12)}, inset 0 1px 0 ${alpha('#ffffff', theme.palette.mode === 'dark' ? 0.08 : 0.7)}`,
|
|
},
|
|
'&: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;
|
|
const horizontalEquipment = rack.equipment.filter((item) => !(item.type === 'pdu' && item.pduMount === 'vertical'));
|
|
const verticalPdus = rack.equipment.filter((item) => item.type === 'pdu' && item.pduMount === 'vertical');
|
|
|
|
return (
|
|
<Paper
|
|
variant="outlined"
|
|
onClick={onSelectRack}
|
|
sx={{
|
|
width: 270,
|
|
flex: '0 0 270px',
|
|
overflow: 'hidden',
|
|
borderColor: (theme) =>
|
|
selected?.kind === 'rack' && selected.rackId === rack.id
|
|
? 'primary.main'
|
|
: theme.palette.mode === 'dark'
|
|
? 'rgba(97, 255, 207, 0.16)'
|
|
: 'rgba(0, 108, 95, 0.24)',
|
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(3, 8, 9, 0.72)' : 'rgba(238, 244, 236, 0.96)'),
|
|
borderRadius: 1,
|
|
boxShadow: (theme) =>
|
|
selected?.kind === 'rack' && selected.rackId === rack.id
|
|
? theme.palette.mode === 'dark'
|
|
? '0 0 34px rgba(97, 255, 207, 0.2), 0 18px 40px rgba(0, 0, 0, 0.32)'
|
|
: '0 0 0 1px rgba(0, 108, 95, 0.18), 0 16px 34px rgba(23, 38, 33, 0.18)'
|
|
: theme.palette.mode === 'dark'
|
|
? '0 18px 40px rgba(0, 0, 0, 0.24)'
|
|
: '0 14px 28px rgba(23, 38, 33, 0.14)',
|
|
clipPath: 'polygon(0 0, 96% 0, 100% 12px, 100% 100%, 4% 100%, 0 calc(100% - 12px))',
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
p: 1.25,
|
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(9, 20, 20, 0.78)' : 'rgba(218, 232, 220, 0.96)'),
|
|
borderBottom: 1,
|
|
borderColor: 'divider',
|
|
backgroundImage: (theme) =>
|
|
theme.palette.mode === 'dark'
|
|
? 'linear-gradient(90deg, rgba(97, 255, 207, 0.12), rgba(240, 184, 77, 0.04))'
|
|
: 'linear-gradient(90deg, rgba(0, 108, 95, 0.12), rgba(156, 92, 12, 0.07))',
|
|
}}
|
|
>
|
|
<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' ? '#030706' : '#edf2e6'),
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
left: 34,
|
|
right: 34,
|
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? '#081210' : '#f8faf2'),
|
|
backgroundImage: (theme) =>
|
|
`linear-gradient(${alpha(theme.palette.primary.main, 0.18)} 1px, transparent 1px), linear-gradient(90deg, ${alpha(theme.palette.primary.main, 0.05)} 1px, transparent 1px)`,
|
|
backgroundSize: `100% ${RACK_ROW_HEIGHT}px`,
|
|
borderLeft: 10,
|
|
borderRight: 10,
|
|
borderColor: (theme) => (theme.palette.mode === 'dark' ? '#1b332e' : '#b4c7bb'),
|
|
boxShadow: 'inset 0 0 36px rgba(0, 0, 0, 0.32)',
|
|
}}
|
|
>
|
|
{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>
|
|
))}
|
|
|
|
{[...horizontalEquipment]
|
|
.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(item.color, 0.48),
|
|
bgcolor: alpha(item.color, 0.9),
|
|
color: '#04100d',
|
|
px: 0.75,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
gap: 1,
|
|
cursor: 'grab',
|
|
overflow: 'hidden',
|
|
boxShadow: selectedItem ? `0 0 20px ${alpha(item.color, 0.45)}` : `0 4px 14px ${alpha('#000000', 0.32)}`,
|
|
backgroundImage:
|
|
'linear-gradient(180deg, rgba(255, 255, 255, 0.22), transparent 44%), repeating-linear-gradient(90deg, rgba(0, 0, 0, 0.12) 0 1px, transparent 1px 12px)',
|
|
}}
|
|
>
|
|
<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>
|
|
|
|
{verticalPdus.map((item) => {
|
|
const selectedItem = selected?.kind === 'equipment' && selected.equipmentId === item.id;
|
|
const width = 12 + item.sizeU * 7;
|
|
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',
|
|
top: 6,
|
|
bottom: 6,
|
|
width,
|
|
left: item.pduSide === 'left' ? 3 : 'auto',
|
|
right: item.pduSide === 'right' ? 3 : 'auto',
|
|
border: 2,
|
|
borderColor: selectedItem ? 'secondary.main' : alpha(item.color, 0.48),
|
|
bgcolor: alpha(item.color, 0.9),
|
|
color: '#04100d',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
cursor: 'grab',
|
|
boxShadow: selectedItem ? `0 0 20px ${alpha(item.color, 0.45)}` : `0 4px 14px ${alpha('#000000', 0.32)}`,
|
|
backgroundImage: 'linear-gradient(180deg, rgba(255, 255, 255, 0.22), transparent 44%)',
|
|
overflow: 'hidden',
|
|
zIndex: 2,
|
|
}}
|
|
>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{
|
|
writingMode: 'vertical-rl',
|
|
transform: 'rotate(180deg)',
|
|
fontWeight: 900,
|
|
lineHeight: 1,
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{item.name}
|
|
</Typography>
|
|
</Box>
|
|
);
|
|
})}
|
|
</Box>
|
|
</Paper>
|
|
);
|
|
}
|
|
|
|
function PropertiesPanel({
|
|
diagram,
|
|
selectedRack,
|
|
selectedEquipment,
|
|
selectedEquipmentRack,
|
|
selection,
|
|
docked,
|
|
position,
|
|
dragging,
|
|
themeMode,
|
|
onStartDrag,
|
|
onClose,
|
|
onToggleDock,
|
|
onDiagramNameChange,
|
|
onRackChange,
|
|
onRackSizeChange,
|
|
onEquipmentChange,
|
|
onEquipmentPlacementChange,
|
|
onDelete,
|
|
}: {
|
|
diagram: DiagramData;
|
|
selectedRack: RackContainer | null;
|
|
selectedEquipment: Equipment | null;
|
|
selectedEquipmentRack: RackContainer | null;
|
|
selection: Selection;
|
|
docked: boolean;
|
|
position: { x: number; y: number };
|
|
dragging: boolean;
|
|
themeMode: ThemeMode;
|
|
onStartDrag: (event: React.MouseEvent<HTMLDivElement>) => void;
|
|
onClose: () => void;
|
|
onToggleDock: () => void;
|
|
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
|
|
data-properties-panel="true"
|
|
elevation={0}
|
|
sx={{
|
|
...commandPanelSx(themeMode),
|
|
position: 'fixed',
|
|
zIndex: 20,
|
|
top: { xs: 8, lg: docked ? 96 : position.y },
|
|
right: { xs: 8, lg: docked ? 18 : 'auto' },
|
|
bottom: { xs: 8, lg: docked ? 18 : 'auto' },
|
|
left: { xs: 8, lg: docked ? 'auto' : position.x },
|
|
width: { xs: 'auto', lg: 390 },
|
|
maxWidth: { xs: 'none', lg: 'calc(100vw - 36px)' },
|
|
height: { xs: 'auto', lg: docked ? 'auto' : 'min(760px, calc(100vh - 36px))' },
|
|
maxHeight: { xs: 'calc(100vh - 16px)', lg: docked ? 'calc(100vh - 114px)' : 'calc(100vh - 36px)' },
|
|
overflow: 'hidden',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
transform: dragging ? 'scale(1.006)' : 'none',
|
|
transition: dragging ? 'none' : 'box-shadow 160ms ease, transform 160ms ease',
|
|
borderRadius: 2,
|
|
resize: { xs: 'none', lg: docked ? 'none' : 'both' },
|
|
}}
|
|
>
|
|
<Box
|
|
onMouseDown={onStartDrag}
|
|
sx={{
|
|
cursor: docked ? 'default' : dragging ? 'grabbing' : 'grab',
|
|
px: 1.25,
|
|
py: 0.9,
|
|
borderBottom: 1,
|
|
borderColor: 'divider',
|
|
display: 'grid',
|
|
gridTemplateColumns: 'auto minmax(0, 1fr) auto',
|
|
alignItems: 'center',
|
|
gap: 1,
|
|
bgcolor: (theme) => (theme.palette.mode === 'dark' ? 'rgba(97, 255, 207, 0.05)' : 'rgba(0, 108, 95, 0.08)'),
|
|
userSelect: 'none',
|
|
}}
|
|
>
|
|
<DragIndicator fontSize="small" sx={{ color: docked ? 'text.disabled' : 'primary.main' }} />
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<Typography variant="overline" color="text.secondary" sx={{ display: 'block', lineHeight: 1.1, fontWeight: 800 }}>
|
|
Properties
|
|
</Typography>
|
|
<Typography variant="subtitle1" noWrap sx={{ fontWeight: 900, lineHeight: 1.25 }}>
|
|
{selectedEquipment ? selectedEquipment.name : selectedRack ? selectedRack.name : 'Diagram'}
|
|
</Typography>
|
|
</Box>
|
|
<Stack direction="row" spacing={0.75} onMouseDown={(event) => event.stopPropagation()}>
|
|
<Tooltip title={docked ? 'Undock properties' : 'Dock right'}>
|
|
<IconButton size="small" color={docked ? 'secondary' : 'primary'} onClick={onToggleDock} aria-label={docked ? 'Undock properties' : 'Dock properties'}>
|
|
{docked ? <PushPin fontSize="small" /> : <PushPinOutlined fontSize="small" />}
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Tooltip title="Close properties">
|
|
<IconButton size="small" color="primary" onClick={onClose} aria-label="Close properties">
|
|
<Close fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Stack>
|
|
</Box>
|
|
|
|
<Box sx={{ minHeight: 0, overflow: 'auto', p: 1.25 }}>
|
|
<Stack spacing={1.25}>
|
|
{!selection && (
|
|
<Stack spacing={1.25}>
|
|
<TextField label="Diagram name" value={diagram.name} onChange={(event) => onDiagramNameChange(event.target.value)} fullWidth />
|
|
<MetadataSummary diagram={diagram} />
|
|
</Stack>
|
|
)}
|
|
|
|
{selectedRack && selection?.kind === 'rack' && (
|
|
<Stack spacing={1.25}>
|
|
<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={1.25}>
|
|
<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 />
|
|
{selectedEquipment.type === 'pdu' ? (
|
|
<>
|
|
<FormControl fullWidth>
|
|
<InputLabel>Mounting</InputLabel>
|
|
<Select
|
|
value={selectedEquipment.pduMount}
|
|
label="Mounting"
|
|
onChange={(event: SelectChangeEvent) =>
|
|
onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
pduMount: event.target.value as Equipment['pduMount'],
|
|
sizeU:
|
|
event.target.value === 'vertical'
|
|
? clampNumber(selectedEquipment.sizeU, 1, 3, 1)
|
|
: clampNumber(selectedEquipment.sizeU, 1, 12, 1),
|
|
})
|
|
}
|
|
>
|
|
<MenuItem value="horizontal">Horizontal rack mount</MenuItem>
|
|
<MenuItem value="vertical">Vertical side mount</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
{selectedEquipment.pduMount === 'vertical' ? (
|
|
<Stack direction="row" spacing={1.5}>
|
|
<TextField
|
|
label="Width U"
|
|
type="number"
|
|
value={selectedEquipment.sizeU}
|
|
inputProps={{ min: 1, max: 3 }}
|
|
onChange={(event) =>
|
|
onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
sizeU: clampNumber(event.target.value, 1, 3, selectedEquipment.sizeU),
|
|
})
|
|
}
|
|
fullWidth
|
|
/>
|
|
<FormControl fullWidth>
|
|
<InputLabel>Side</InputLabel>
|
|
<Select
|
|
value={selectedEquipment.pduSide}
|
|
label="Side"
|
|
onChange={(event: SelectChangeEvent) =>
|
|
onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
pduSide: event.target.value as Equipment['pduSide'],
|
|
})
|
|
}
|
|
>
|
|
<MenuItem value="left">Left</MenuItem>
|
|
<MenuItem value="right">Right</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
</Stack>
|
|
) : (
|
|
<Stack direction="row" spacing={1.5}>
|
|
<TextField
|
|
label="Size U"
|
|
type="number"
|
|
value={selectedEquipment.sizeU}
|
|
inputProps={{ min: 1, max: 12 }}
|
|
onChange={(event) =>
|
|
onEquipmentPlacementChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
sizeU: clampNumber(event.target.value, 1, 12, 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>
|
|
)}
|
|
</>
|
|
) : (
|
|
<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
|
|
/>
|
|
{selectedEquipment.type === 'patch-panel' && (
|
|
<>
|
|
<Divider />
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
|
Patch Panel
|
|
</Typography>
|
|
<FormControl fullWidth>
|
|
<InputLabel>Medium</InputLabel>
|
|
<Select
|
|
value={selectedEquipment.patchPanelMedium || 'copper'}
|
|
label="Medium"
|
|
onChange={(event: SelectChangeEvent) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
patchPanelMedium: event.target.value as Equipment['patchPanelMedium'],
|
|
})
|
|
}
|
|
>
|
|
<MenuItem value="copper">Copper</MenuItem>
|
|
<MenuItem value="fiberoptic">Fiber optic</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<TextField
|
|
label="Number of ports"
|
|
type="number"
|
|
value={selectedEquipment.patchPanelPorts}
|
|
inputProps={{ min: 0, max: 10000 }}
|
|
onChange={(event) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
patchPanelPorts: clampNumber(event.target.value, 0, 10000, selectedEquipment.patchPanelPorts),
|
|
})
|
|
}
|
|
fullWidth
|
|
/>
|
|
<Stack spacing={1}>
|
|
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
|
Port Map
|
|
</Typography>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
startIcon={<Add />}
|
|
onClick={() =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
patchPanelPortMap: [
|
|
...selectedEquipment.patchPanelPortMap,
|
|
{
|
|
id: createId('patch-port'),
|
|
portId: `Port ${selectedEquipment.patchPanelPortMap.length + 1}`,
|
|
cableId: '',
|
|
},
|
|
],
|
|
})
|
|
}
|
|
>
|
|
Add port
|
|
</Button>
|
|
</Stack>
|
|
{selectedEquipment.patchPanelPortMap.length === 0 ? (
|
|
<Typography variant="caption" color="text.secondary">
|
|
No ports mapped.
|
|
</Typography>
|
|
) : (
|
|
selectedEquipment.patchPanelPortMap.map((port) => (
|
|
<Stack key={port.id} direction="row" spacing={1} alignItems="center">
|
|
<TextField
|
|
label="Port ID"
|
|
value={port.portId}
|
|
size="small"
|
|
onChange={(event) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
patchPanelPortMap: selectedEquipment.patchPanelPortMap.map((item) =>
|
|
item.id === port.id ? { ...item, portId: event.target.value } : item,
|
|
),
|
|
})
|
|
}
|
|
sx={{ flex: 1 }}
|
|
/>
|
|
<TextField
|
|
label="Cable ID"
|
|
value={port.cableId}
|
|
size="small"
|
|
onChange={(event) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
patchPanelPortMap: selectedEquipment.patchPanelPortMap.map((item) =>
|
|
item.id === port.id ? { ...item, cableId: event.target.value } : item,
|
|
),
|
|
})
|
|
}
|
|
sx={{ flex: 1 }}
|
|
/>
|
|
<Tooltip title="Delete port">
|
|
<IconButton
|
|
color="error"
|
|
size="small"
|
|
onClick={() =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
patchPanelPortMap: selectedEquipment.patchPanelPortMap.filter((item) => item.id !== port.id),
|
|
})
|
|
}
|
|
>
|
|
<Delete fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Stack>
|
|
))
|
|
)}
|
|
</Stack>
|
|
</>
|
|
)}
|
|
{PORT_MAPPED_COMPONENTS.has(selectedEquipment.type) && (
|
|
<>
|
|
<Divider />
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
|
Component Ports
|
|
</Typography>
|
|
<Stack spacing={1}>
|
|
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
|
Port Map
|
|
</Typography>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
startIcon={<Add />}
|
|
onClick={() =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
networkPorts: [
|
|
...selectedEquipment.networkPorts,
|
|
{
|
|
id: createId('network-port'),
|
|
portId: `Port ${selectedEquipment.networkPorts.length + 1}`,
|
|
cableId: '',
|
|
vlan: 1,
|
|
},
|
|
],
|
|
})
|
|
}
|
|
>
|
|
Add port
|
|
</Button>
|
|
</Stack>
|
|
{selectedEquipment.networkPorts.length === 0 ? (
|
|
<Typography variant="caption" color="text.secondary">
|
|
No component ports mapped.
|
|
</Typography>
|
|
) : (
|
|
selectedEquipment.networkPorts.map((port) => (
|
|
<Stack key={port.id} direction="row" spacing={1} alignItems="center">
|
|
<TextField
|
|
label="Port ID"
|
|
value={port.portId}
|
|
size="small"
|
|
onChange={(event) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
networkPorts: selectedEquipment.networkPorts.map((item) =>
|
|
item.id === port.id ? { ...item, portId: event.target.value } : item,
|
|
),
|
|
})
|
|
}
|
|
sx={{ flex: 1 }}
|
|
/>
|
|
<TextField
|
|
label="Cable ID"
|
|
value={port.cableId}
|
|
size="small"
|
|
onChange={(event) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
networkPorts: selectedEquipment.networkPorts.map((item) =>
|
|
item.id === port.id ? { ...item, cableId: event.target.value } : item,
|
|
),
|
|
})
|
|
}
|
|
sx={{ flex: 1 }}
|
|
/>
|
|
<TextField
|
|
label="VLAN"
|
|
type="number"
|
|
value={port.vlan}
|
|
size="small"
|
|
inputProps={{ min: 1, max: 4094 }}
|
|
onChange={(event) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
networkPorts: selectedEquipment.networkPorts.map((item) =>
|
|
item.id === port.id ? { ...item, vlan: clampNumber(event.target.value, 1, 4094, port.vlan) } : item,
|
|
),
|
|
})
|
|
}
|
|
sx={{ flex: 0.8 }}
|
|
/>
|
|
<Tooltip title="Delete port">
|
|
<IconButton
|
|
color="error"
|
|
size="small"
|
|
onClick={() =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
networkPorts: selectedEquipment.networkPorts.filter((item) => item.id !== port.id),
|
|
})
|
|
}
|
|
>
|
|
<Delete fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Stack>
|
|
))
|
|
)}
|
|
</Stack>
|
|
</>
|
|
)}
|
|
{PORT_MAPPED_COMPONENTS.has(selectedEquipment.type) && (
|
|
<>
|
|
<Divider />
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
|
Managed Device
|
|
</Typography>
|
|
<Box>
|
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
|
Managed
|
|
</Typography>
|
|
<Slider
|
|
value={selectedEquipment.managed ? 1 : 0}
|
|
min={0}
|
|
max={1}
|
|
step={1}
|
|
marks={[
|
|
{ value: 0, label: 'No' },
|
|
{ value: 1, label: 'Yes' },
|
|
]}
|
|
onChange={(_, value) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
managed: (value as number) === 1,
|
|
})
|
|
}
|
|
/>
|
|
</Box>
|
|
{selectedEquipment.managed && (
|
|
<>
|
|
<TextField
|
|
label="IP address / hostname"
|
|
value={selectedEquipment.managedHostname}
|
|
onChange={(event) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
managedHostname: event.target.value,
|
|
})
|
|
}
|
|
fullWidth
|
|
/>
|
|
<TextField
|
|
label="URL"
|
|
value={selectedEquipment.managedUrl}
|
|
onChange={(event) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
managedUrl: event.target.value,
|
|
})
|
|
}
|
|
fullWidth
|
|
/>
|
|
<TextField
|
|
label="User"
|
|
value={selectedEquipment.managedUser}
|
|
onChange={(event) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
managedUser: event.target.value,
|
|
})
|
|
}
|
|
fullWidth
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
{selectedEquipment.type === 'pdu' && (
|
|
<>
|
|
<Divider />
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
|
Power Distribution
|
|
</Typography>
|
|
<TextField
|
|
label="Input voltage"
|
|
value={selectedEquipment.pduInputVoltage}
|
|
onChange={(event) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
pduInputVoltage: event.target.value,
|
|
})
|
|
}
|
|
placeholder="208V 3-phase, 220V, 120V"
|
|
fullWidth
|
|
/>
|
|
<Stack spacing={1}>
|
|
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
|
Output Ports
|
|
</Typography>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
startIcon={<Add />}
|
|
onClick={() =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
pduOutputPorts: [
|
|
...selectedEquipment.pduOutputPorts,
|
|
{
|
|
id: createId('pdu-port'),
|
|
label: `Port ${selectedEquipment.pduOutputPorts.length + 1}`,
|
|
voltage: 120,
|
|
amps: 10,
|
|
},
|
|
],
|
|
})
|
|
}
|
|
>
|
|
Add port
|
|
</Button>
|
|
</Stack>
|
|
{selectedEquipment.pduOutputPorts.length === 0 ? (
|
|
<Typography variant="caption" color="text.secondary">
|
|
No output ports defined.
|
|
</Typography>
|
|
) : (
|
|
selectedEquipment.pduOutputPorts.map((port) => (
|
|
<Stack key={port.id} direction="row" spacing={1} alignItems="center">
|
|
<TextField
|
|
label="Port"
|
|
value={port.label}
|
|
size="small"
|
|
onChange={(event) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
pduOutputPorts: selectedEquipment.pduOutputPorts.map((item) =>
|
|
item.id === port.id ? { ...item, label: event.target.value } : item,
|
|
),
|
|
})
|
|
}
|
|
sx={{ flex: 1.3 }}
|
|
/>
|
|
<TextField
|
|
label="Volts"
|
|
type="number"
|
|
value={port.voltage}
|
|
size="small"
|
|
inputProps={{ min: 0, max: 1000 }}
|
|
onChange={(event) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
pduOutputPorts: selectedEquipment.pduOutputPorts.map((item) =>
|
|
item.id === port.id ? { ...item, voltage: clampNumber(event.target.value, 0, 1000, port.voltage) } : item,
|
|
),
|
|
})
|
|
}
|
|
sx={{ flex: 1 }}
|
|
/>
|
|
<TextField
|
|
label="Amps"
|
|
type="number"
|
|
value={port.amps}
|
|
size="small"
|
|
inputProps={{ min: 0, max: 1000 }}
|
|
onChange={(event) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
pduOutputPorts: selectedEquipment.pduOutputPorts.map((item) =>
|
|
item.id === port.id ? { ...item, amps: clampNumber(event.target.value, 0, 1000, port.amps) } : item,
|
|
),
|
|
})
|
|
}
|
|
sx={{ flex: 1 }}
|
|
/>
|
|
<Tooltip title="Delete port">
|
|
<IconButton
|
|
color="error"
|
|
size="small"
|
|
onClick={() =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
pduOutputPorts: selectedEquipment.pduOutputPorts.filter((item) => item.id !== port.id),
|
|
})
|
|
}
|
|
>
|
|
<Delete fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Stack>
|
|
))
|
|
)}
|
|
</Stack>
|
|
<Box>
|
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
|
Managed PDU
|
|
</Typography>
|
|
<Slider
|
|
value={selectedEquipment.pduManaged ? 1 : 0}
|
|
min={0}
|
|
max={1}
|
|
step={1}
|
|
marks={[
|
|
{ value: 0, label: 'No' },
|
|
{ value: 1, label: 'Yes' },
|
|
]}
|
|
onChange={(_, value) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
pduManaged: (value as number) === 1,
|
|
})
|
|
}
|
|
/>
|
|
</Box>
|
|
{selectedEquipment.pduManaged && (
|
|
<>
|
|
<TextField
|
|
label="IP address / hostname"
|
|
value={selectedEquipment.pduHostname}
|
|
onChange={(event) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
pduHostname: event.target.value,
|
|
})
|
|
}
|
|
fullWidth
|
|
/>
|
|
<TextField
|
|
label="URL"
|
|
value={selectedEquipment.pduUrl}
|
|
onChange={(event) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
pduUrl: event.target.value,
|
|
})
|
|
}
|
|
fullWidth
|
|
/>
|
|
<TextField
|
|
label="User"
|
|
value={selectedEquipment.pduUser}
|
|
onChange={(event) =>
|
|
onEquipmentChange(selectedEquipmentRack.id, selectedEquipment.id, {
|
|
pduUser: 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>
|
|
</Box>
|
|
</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>
|
|
);
|
|
}
|