diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..d551e1f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,57 @@
+services:
+ dcm-site:
+ image: ubuntu:latest
+ restart: unless-stopped
+
+ environment:
+ PORT: "3000"
+ NODE_ENV: production
+ CLIENT_ORIGIN: https://www.datacentermodeler.com
+ DEBIAN_FRONTEND: noninteractive
+
+ command:
+ - bash
+ - -lc
+ - |
+ set -e
+ apt-get update
+ apt-get install -y --no-install-recommends \
+ python3 \
+ make \
+ g++ \
+ nodejs \
+ nano \
+ npm \
+ git \
+ ca-certificates
+ rm -rf /var/lib/apt/lists/*
+ if [ -f /apps/package.json ] && [ -d /apps/.git ]; then
+ cd /apps
+ git reset --hard
+ git pull origin main
+ else
+ mkdir -p /apps
+ cd /apps
+ git init
+ git remote remove origin 2>/dev/null || true
+ git remote add origin "https://addb4a1f69126e229fe50ad4a7ea5837b1a3a090@git.datacentermodeler.com/pucketm2016/DCM.git"
+ git fetch origin main
+ git checkout -B main origin/main
+ fi
+ npm install
+ npm run build
+ npm run start
+
+ volumes:
+ - dcm_data:/apps/data
+
+ networks:
+ - dcm_internal
+
+volumes:
+ dcm_data:
+
+networks:
+ dcm_internal:
+ driver: overlay
+ attachable: true
\ No newline at end of file
diff --git a/package.json b/package.json
index ab0937b..f50ab24 100644
--- a/package.json
+++ b/package.json
@@ -6,13 +6,16 @@
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
- "preview": "vite preview --host 0.0.0.0"
+ "preview": "vite preview --host 0.0.0.0",
+ "prestart": "npm run build",
+ "start": "node server/index.js"
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
"@mui/icons-material": "^5.15.20",
"@mui/material": "^5.15.20",
+ "express": "^4.22.2",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"react": "^18.2.0",
diff --git a/public/assets/datacenter-command-bg.png b/public/assets/datacenter-command-bg.png
new file mode 100644
index 0000000..35c01ec
Binary files /dev/null and b/public/assets/datacenter-command-bg.png differ
diff --git a/public/favicon.svg b/public/favicon.svg
index 5128772..dd2633c 100644
--- a/public/favicon.svg
+++ b/public/favicon.svg
@@ -1,8 +1,28 @@
diff --git a/server/api.js b/server/api.js
new file mode 100644
index 0000000..69d8e1e
--- /dev/null
+++ b/server/api.js
@@ -0,0 +1,9 @@
+import express from 'express';
+
+const router = express.Router();
+
+router.get('/health', (req, res) => {
+ res.json({ status: 'ok' });
+});
+
+export default router;
diff --git a/server/index.js b/server/index.js
new file mode 100644
index 0000000..6aaab9d
--- /dev/null
+++ b/server/index.js
@@ -0,0 +1,23 @@
+import express from 'express';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import apiRouter from './api.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const distDir = path.join(__dirname, '..', 'dist');
+const port = process.env.PORT || 3000;
+
+const app = express();
+
+app.use(express.json());
+app.use('/api', apiRouter);
+
+app.use(express.static(distDir));
+
+app.get('*', (req, res) => {
+ res.sendFile(path.join(distDir, 'index.html'));
+});
+
+app.listen(port, () => {
+ console.log(`Datacenter Modeler listening on http://localhost:${port}`);
+});
diff --git a/src/App.tsx b/src/App.tsx
index 3d90e14..3f42c3f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -33,11 +33,13 @@ import type { SelectChangeEvent } from '@mui/material';
import {
Add,
Cable,
+ Close,
DarkMode,
Delete,
Dns,
Domain,
Download,
+ DragIndicator,
FileOpen,
Image,
Inventory2,
@@ -45,9 +47,12 @@ import {
OpenInNew,
PictureAsPdf,
Power,
+ PushPin,
+ PushPinOutlined,
Router,
Save,
Security,
+ Settings,
SettingsInputComponent,
Storage,
Terminal,
@@ -85,6 +90,20 @@ const PORT_MAPPED_COMPONENTS = new Set([
'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 = {
rack: ,
cabinet: ,
@@ -161,6 +180,11 @@ export default function App() {
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(null);
const fileInputRef = useRef(null);
@@ -170,17 +194,230 @@ export default function App() {
createTheme({
palette: {
mode: themeMode,
- primary: { main: themeMode === 'dark' ? '#7dd3fc' : '#006c9c' },
- secondary: { main: '#f59e0b' },
+ primary: { main: themeMode === 'dark' ? '#61ffcf' : '#006c5f' },
+ secondary: { main: '#f0b84d' },
+ success: { main: '#5cff8d' },
+ warning: { main: '#f0b84d' },
+ error: { main: '#ff5f6d' },
background:
themeMode === 'dark'
- ? { default: '#0f1720', paper: '#16212c' }
- : { default: '#eef3f7', paper: '#ffffff' },
+ ? { 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: 8 },
+ shape: { borderRadius: 6 },
typography: {
- fontFamily: 'Roboto, Arial, sans-serif',
- button: { textTransform: 'none', fontWeight: 700 },
+ 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],
@@ -202,6 +439,28 @@ export default function App() {
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() }));
};
@@ -232,6 +491,7 @@ export default function App() {
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);
};
@@ -565,16 +825,84 @@ export default function App() {
return (
-
-
-
-
-
-
+ *': { position: 'relative', zIndex: 1 },
+ }}
+ >
+
+
+
+
+
+
+
Datacenter Modeler
-
- {diagram.racks.length}/{MAX_CONTAINERS} racks and cabinets
+
+ {diagram.racks.length}/{MAX_CONTAINERS}
+
+ {' '}
+ racks and cabinets
+
+
+ {' '}
+ racks
+
updateDiagramName(event.target.value)}
size="small"
label="Diagram"
- sx={{ width: { xs: 180, md: 320 }, display: { xs: 'none', sm: 'block' } }}
+ 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',
+ },
+ }}
/>
@@ -594,8 +936,24 @@ export default function App() {
- } onClick={(event) => setExportAnchor(event.currentTarget)}>
- Export
+
+ setPropertiesOpen((open) => !open)}
+ aria-label={propertiesOpen ? 'Hide properties' : 'Show properties'}
+ >
+
+
+
+ }
+ onClick={(event) => setExportAnchor(event.currentTarget)}
+ sx={{ minWidth: { xs: 48, sm: 96 }, px: { xs: 1.25, sm: 2 } }}
+ >
+
+ Export
+
@@ -620,8 +978,16 @@ export default function App() {
-
-
+
+
alpha(muiTheme.palette.background.paper, themeMode === 'dark' ? 0.55 : 0.86),
+ 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 ? (
-
-
- No racks in diagram
+ (theme.palette.mode === 'dark' ? 'rgba(0, 0, 0, 0.16)' : 'rgba(255, 255, 255, 0.62)'),
+ borderRadius: 1,
+ }}
+ >
+
+ Awaiting rack drop
+ Drag a container from the component rail.
) : (
diagram.racks.map((rack) => (
@@ -660,21 +1053,47 @@ export default function App() {
key={rack.id}
rack={rack}
selected={selection}
- onSelectRack={() => setSelection({ kind: 'rack', rackId: rack.id })}
- onSelectEquipment={(equipmentId) => setSelection({ kind: 'equipment', rackId: rack.id, equipmentId })}
+ 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)}
/>
))
)}
+
+ {propertiesOpen && (
{
+ 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}
@@ -682,9 +1101,20 @@ export default function App() {
onEquipmentPlacementChange={updateEquipmentPlacement}
onDelete={deleteSelection}
/>
-
+ )}
-
- This application was made by a homebrew developer. A donation is appreciated if this tool helps you,
+ This application was made by a homebrew developer (
+ theblindengineer.com
+ ). A donation is appreciated if this tool helps you,
but it is absolutely not required.
@@ -771,12 +1214,22 @@ export default function App() {
);
}
-function LibraryPanel() {
+function LibraryPanel({ themeMode }: { themeMode: ThemeMode }) {
const containers = componentLibrary.filter((item) => item.category === 'container');
const equipment = componentLibrary.filter((item) => item.category === 'equipment');
return (
-
+
@@ -817,8 +1270,21 @@ function LibraryTile({ item }: { item: (typeof componentLibrary)[number] }) {
alignItems: 'center',
gap: 1,
cursor: 'grab',
- borderLeft: 4,
+ 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' },
}}
>
@@ -857,10 +1323,37 @@ function RackView({
width: 270,
flex: '0 0 270px',
overflow: 'hidden',
- borderColor: selected?.kind === 'rack' && selected.rackId === rack.id ? 'primary.main' : 'divider',
+ 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))',
}}
>
-
+ (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))',
+ }}
+ >
@@ -881,7 +1374,7 @@ function RackView({
sx={{
position: 'relative',
height: rackHeight,
- bgcolor: (theme) => (theme.palette.mode === 'dark' ? '#0b1118' : '#edf2f7'),
+ bgcolor: (theme) => (theme.palette.mode === 'dark' ? '#030706' : '#edf2e6'),
}}
>
(theme.palette.mode === 'dark' ? '#101820' : '#f8fafc'),
+ bgcolor: (theme) => (theme.palette.mode === 'dark' ? '#081210' : '#f8faf2'),
backgroundImage: (theme) =>
- `linear-gradient(${alpha(theme.palette.text.primary, 0.08)} 1px, transparent 1px)`,
+ `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' ? '#263442' : '#cbd5e1'),
+ 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) => (
@@ -942,9 +1436,9 @@ function RackView({
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',
+ borderColor: selectedItem ? 'secondary.main' : alpha(item.color, 0.48),
+ bgcolor: alpha(item.color, 0.9),
+ color: '#04100d',
px: 0.75,
display: 'flex',
alignItems: 'center',
@@ -952,7 +1446,9 @@ function RackView({
gap: 1,
cursor: 'grab',
overflow: 'hidden',
- boxShadow: selectedItem ? 4 : 1,
+ 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)',
}}
>
@@ -982,21 +1478,22 @@ function RackView({
onSelectEquipment(item.id);
}}
sx={{
- position: 'absolute',
+ 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('#000000', 0.24),
- bgcolor: item.color,
- color: '#111827',
+ 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 ? 4 : 1,
+ 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,
}}
@@ -1027,6 +1524,13 @@ function PropertiesPanel({
selectedEquipment,
selectedEquipmentRack,
selection,
+ docked,
+ position,
+ dragging,
+ themeMode,
+ onStartDrag,
+ onClose,
+ onToggleDock,
onDiagramNameChange,
onRackChange,
onRackSizeChange,
@@ -1039,6 +1543,13 @@ function PropertiesPanel({
selectedEquipment: Equipment | null;
selectedEquipmentRack: RackContainer | null;
selection: Selection;
+ docked: boolean;
+ position: { x: number; y: number };
+ dragging: boolean;
+ themeMode: ThemeMode;
+ onStartDrag: (event: React.MouseEvent) => void;
+ onClose: () => void;
+ onToggleDock: () => void;
onDiagramNameChange: (name: string) => void;
onRackChange: (rackId: string, patch: Partial) => void;
onRackSizeChange: (rackId: string, sizeU: number) => void;
@@ -1047,26 +1558,80 @@ function PropertiesPanel({
onDelete: () => void;
}) {
return (
-
-
-
-
+
+ (theme.palette.mode === 'dark' ? 'rgba(97, 255, 207, 0.05)' : 'rgba(0, 108, 95, 0.08)'),
+ userSelect: 'none',
+ }}
+ >
+
+
+
Properties
-
+
{selectedEquipment ? selectedEquipment.name : selectedRack ? selectedRack.name : 'Diagram'}
+ event.stopPropagation()}>
+
+
+ {docked ? : }
+
+
+
+
+
+
+
+
+
+
+
{!selection && (
-
+
onDiagramNameChange(event.target.value)} fullWidth />
)}
{selectedRack && selection?.kind === 'rack' && (
-
+
onRackChange(selectedRack.id, { name: event.target.value })} fullWidth />
Type
@@ -1098,7 +1663,7 @@ function PropertiesPanel({
)}
{selectedEquipment && selectedEquipmentRack && selection?.kind === 'equipment' && (
-
+
)}
+
);
}
diff --git a/src/data/componentLibrary.ts b/src/data/componentLibrary.ts
index 660239a..453ce32 100644
--- a/src/data/componentLibrary.ts
+++ b/src/data/componentLibrary.ts
@@ -1,20 +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: 'pdu', category: 'equipment', label: 'PDU', defaultU: 1, minU: 1, maxU: 12, color: '#f97316' },
- { 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' },
+ { type: 'rack', category: 'container', label: 'Rack', defaultU: 42, minU: 2, maxU: 42, color: '#6f8179' },
+ { type: 'cabinet', category: 'container', label: 'Cabinet', defaultU: 42, minU: 2, maxU: 42, color: '#51675f' },
+ { type: 'switch', category: 'equipment', label: 'Switch', defaultU: 1, minU: 1, maxU: 24, color: '#61ffcf' },
+ { type: 'router', category: 'equipment', label: 'Router', defaultU: 1, minU: 1, maxU: 24, color: '#89ff7a' },
+ { type: 'firewall', category: 'equipment', label: 'Firewall', defaultU: 1, minU: 1, maxU: 24, color: '#ff5f6d' },
+ { type: 'server-rack', category: 'equipment', label: 'Server (Rack)', defaultU: 2, minU: 1, maxU: 24, color: '#8be9ff' },
+ { type: 'blade-chassis', category: 'equipment', label: 'Blade Chassis', defaultU: 10, minU: 1, maxU: 24, color: '#b38cff' },
+ { type: 'storage-array', category: 'equipment', label: 'Storage Array', defaultU: 4, minU: 1, maxU: 24, color: '#f0b84d' },
+ { type: 'storage-switch', category: 'equipment', label: 'Storage Switch', defaultU: 1, minU: 1, maxU: 24, color: '#4dd7ff' },
+ { type: 'patch-panel', category: 'equipment', label: 'Patch Panel', defaultU: 1, minU: 1, maxU: 24, color: '#d9a87b' },
+ { type: 'pdu', category: 'equipment', label: 'PDU', defaultU: 1, minU: 1, maxU: 12, color: '#ff9b42' },
+ { type: 'cable-management', category: 'equipment', label: 'Cable Management', defaultU: 1, minU: 1, maxU: 24, color: '#9cad9d' },
+ { type: 'kvm', category: 'equipment', label: 'KVM', defaultU: 1, minU: 1, maxU: 24, color: '#5cff8d' },
+ { type: 'kvm-console', category: 'equipment', label: 'KVM (Console)', defaultU: 1, minU: 1, maxU: 24, color: '#ff7ac8' },
];
export const getLibraryItem = (type: string | undefined) =>
diff --git a/src/styles.css b/src/styles.css
index 6a9e106..844d6de 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -10,7 +10,9 @@ body,
}
body {
- font-family: Roboto, Arial, sans-serif;
+ min-width: 360px;
+ font-family: Inter, Roboto, Arial, sans-serif;
+ font-variant-ligatures: contextual;
}
button,
@@ -18,3 +20,14 @@ input,
textarea {
font: inherit;
}
+
+a {
+ color: inherit;
+ text-decoration-color: rgba(97, 255, 207, 0.55);
+ text-underline-offset: 3px;
+}
+
+img,
+svg {
+ max-width: 100%;
+}