Initial scaffold: full-stack RackMapper application

Complete project scaffold with working auth, REST API, Prisma/SQLite
schema, Docker config, and React frontend for both Rack Planner and
Service Mapper modules. Both server and client pass TypeScript strict
mode with zero errors. Initial migration applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 21:48:56 -05:00
parent 61a4d37d94
commit 231de3d005
79 changed files with 12983 additions and 0 deletions

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# Copy this file to .env and fill in values before running locally
# In Docker/Unraid, set these as container environment variables instead
# Admin credentials
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH= # Generate with: npx ts-node scripts/hashPassword.ts yourpassword
# JWT
JWT_SECRET= # Min 32 random chars — generate with: openssl rand -hex 32
JWT_EXPIRY=8h
# Database (relative path inside container; bind-mounted to ./data/)
DATABASE_URL=file:./data/rackmapper.db
# Server
PORT=3001
NODE_ENV=development

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Dependencies
node_modules/
client/node_modules/
# Environment - never commit secrets
.env
.env.local
.env.*.local
# Build output
dist/
client/dist/
# Database - persisted via Docker volume
data/
*.db
*.db-journal
*.db-wal
*.db-shm
# Prisma generated client (regenerated on build)
node_modules/.prisma/
# Logs
logs/
*.log
npm-debug.log*
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
*.swo
# Test coverage
coverage/
# TypeScript incremental build info
*.tsbuildinfo

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2
}

31
Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM node:20-alpine
WORKDIR /app
# Install build tools needed for better-sqlite3 native bindings
RUN apk add --no-cache python3 make g++
# Copy package manifests
COPY package*.json ./
COPY client/package*.json ./client/
# Install all dependencies (dev deps needed for prisma CLI + tsc build)
RUN npm install
RUN cd client && npm install
# Copy source
COPY . .
# Generate Prisma client for target platform (must happen before tsc)
RUN npx prisma generate
# Build server (tsc) + client (vite)
RUN npm run build
# Ensure data directory exists for SQLite bind mount
RUN mkdir -p /app/data
EXPOSE 3001
# Apply pending migrations then start
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/server/index.js"]

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RackMapper</title>
<meta name="description" content="Network rack planner and service mapper" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3079
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
client/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "rackmapper-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@xyflow/react": "^12.3.4",
"clsx": "^2.1.1",
"html-to-image": "^1.11.11",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"sonner": "^1.7.0",
"tailwind-merge": "^2.5.4",
"zustand": "^5.0.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}

6
client/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

53
client/src/App.tsx Normal file
View File

@@ -0,0 +1,53 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './store/useAuthStore';
import { LoginPage } from './components/auth/LoginPage';
import { ProtectedRoute } from './components/auth/ProtectedRoute';
import { RackPlanner } from './components/rack/RackPlanner';
import { ServiceMapper } from './components/mapper/ServiceMapper';
import { Skeleton } from './components/ui/Skeleton';
export default function App() {
const { checkAuth, loading } = useAuthStore();
useEffect(() => {
checkAuth();
}, [checkAuth]);
if (loading) {
return (
<div className="min-h-screen bg-[#0f1117] flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="text-2xl font-bold text-slate-300 tracking-widest">RACKMAPPER</div>
<Skeleton className="w-48 h-1" />
</div>
</div>
);
}
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<Navigate to="/rack" replace />} />
<Route
path="/rack"
element={
<ProtectedRoute>
<RackPlanner />
</ProtectedRoute>
}
/>
<Route
path="/map"
element={
<ProtectedRoute>
<ServiceMapper />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/rack" replace />} />
</Routes>
</BrowserRouter>
);
}

196
client/src/api/client.ts Normal file
View File

@@ -0,0 +1,196 @@
import type {
Rack,
Module,
Port,
Vlan,
ServiceMap,
ServiceMapSummary,
ServiceNode,
ServiceEdge,
ModuleType,
PortType,
VlanMode,
NodeType,
} from '../types';
// ---- Core fetch wrapper ----
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`/api${endpoint}`, {
...options,
headers: { 'Content-Type': 'application/json', ...options.headers },
credentials: 'include',
});
const body = await res.json().catch(() => ({ data: null, error: `HTTP ${res.status}` }));
if (!res.ok) {
throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`);
}
return (body as { data: T }).data;
}
function get<T>(path: string) {
return request<T>(path);
}
function post<T>(path: string, data?: unknown) {
return request<T>(path, { method: 'POST', body: JSON.stringify(data) });
}
function put<T>(path: string, data?: unknown) {
return request<T>(path, { method: 'PUT', body: JSON.stringify(data) });
}
function del<T>(path: string) {
return request<T>(path, { method: 'DELETE' });
}
// ---- Auth ----
const auth = {
me: () => get<{ authenticated: boolean }>('/auth/me'),
login: (username: string, password: string) =>
post<{ success: boolean }>('/auth/login', { username, password }),
logout: () => post<{ success: boolean }>('/auth/logout'),
};
// ---- Racks ----
const racks = {
list: () => get<Rack[]>('/racks'),
get: (id: string) => get<Rack>(`/racks/${id}`),
create: (data: { name: string; totalU?: number; location?: string; displayOrder?: number }) =>
post<Rack>('/racks', data),
update: (
id: string,
data: Partial<{ name: string; totalU: number; location: string; displayOrder: number }>
) => put<Rack>(`/racks/${id}`, data),
delete: (id: string) => del<null>(`/racks/${id}`),
addModule: (
rackId: string,
data: {
name: string;
type: ModuleType;
uPosition: number;
uSize?: number;
manufacturer?: string;
model?: string;
ipAddress?: string;
notes?: string;
portCount?: number;
portType?: PortType;
}
) => post<Module>(`/racks/${rackId}/modules`, data),
};
// ---- Modules ----
const modules = {
update: (
id: string,
data: Partial<{
name: string;
uPosition: number;
uSize: number;
manufacturer: string;
model: string;
ipAddress: string;
notes: string;
}>
) => put<Module>(`/modules/${id}`, data),
delete: (id: string) => del<null>(`/modules/${id}`),
getPorts: (id: string) => get<Port[]>(`/modules/${id}/ports`),
};
// ---- Ports ----
const ports = {
update: (
id: string,
data: {
label?: string;
mode?: VlanMode;
nativeVlan?: number | null;
notes?: string;
vlans?: Array<{ vlanId: string; tagged: boolean }>;
}
) => put<Port>(`/ports/${id}`, data),
};
// ---- VLANs ----
const vlans = {
list: () => get<Vlan[]>('/vlans'),
create: (data: { vlanId: number; name: string; description?: string; color?: string }) =>
post<Vlan>('/vlans', data),
update: (id: string, data: Partial<{ name: string; description: string; color: string }>) =>
put<Vlan>(`/vlans/${id}`, data),
delete: (id: string) => del<null>(`/vlans/${id}`),
};
// ---- Service Maps ----
const maps = {
list: () => get<ServiceMapSummary[]>('/maps'),
get: (id: string) => get<ServiceMap>(`/maps/${id}`),
create: (data: { name: string; description?: string }) => post<ServiceMap>('/maps', data),
update: (id: string, data: Partial<{ name: string; description: string }>) =>
put<ServiceMap>(`/maps/${id}`, data),
delete: (id: string) => del<null>(`/maps/${id}`),
addNode: (
mapId: string,
data: {
label: string;
nodeType: NodeType;
positionX: number;
positionY: number;
metadata?: string;
color?: string;
icon?: string;
moduleId?: string;
}
) => post<ServiceNode>(`/maps/${mapId}/nodes`, data),
populate: (mapId: string) => post<ServiceMap>(`/maps/${mapId}/populate`),
addEdge: (
mapId: string,
data: {
sourceId: string;
targetId: string;
label?: string;
edgeType?: string;
animated?: boolean;
}
) => post<ServiceEdge>(`/maps/${mapId}/edges`, data),
};
// ---- Nodes ----
const nodes = {
update: (
id: string,
data: Partial<{
label: string;
positionX: number;
positionY: number;
metadata: string;
color: string;
icon: string;
moduleId: string | null;
}>
) => put<ServiceNode>(`/nodes/${id}`, data),
delete: (id: string) => del<null>(`/nodes/${id}`),
};
// ---- Edges ----
const edges = {
update: (
id: string,
data: Partial<{ label: string; edgeType: string; animated: boolean; metadata: string }>
) => put<ServiceEdge>(`/edges/${id}`, data),
delete: (id: string) => del<null>(`/edges/${id}`),
};
export const apiClient = { auth, racks, modules, ports, vlans, maps, nodes, edges };

View File

@@ -0,0 +1,107 @@
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useAuthStore } from '../../store/useAuthStore';
import { Button } from '../ui/Button';
export function LoginPage() {
const navigate = useNavigate();
const { login } = useAuthStore();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!username.trim() || !password) return;
setLoading(true);
try {
await login(username.trim(), password);
navigate('/rack', { replace: true });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-[#0f1117] flex items-center justify-center p-4">
<div className="w-full max-w-sm">
{/* Logo / wordmark */}
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 mb-2">
<div className="w-8 h-8 bg-blue-500 rounded flex items-center justify-center">
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="1" y="2" width="16" height="3" rx="1" fill="white" />
<rect x="1" y="7" width="16" height="3" rx="1" fill="white" opacity="0.7" />
<rect x="1" y="12" width="16" height="3" rx="1" fill="white" opacity="0.4" />
</svg>
</div>
<span className="text-xl font-bold text-white tracking-wider">RACKMAPPER</span>
</div>
<p className="text-slate-500 text-sm">Network infrastructure management</p>
</div>
{/* Card */}
<form
onSubmit={handleSubmit}
className="bg-slate-800 border border-slate-700 rounded-xl p-6 space-y-4 shadow-2xl"
>
<div className="space-y-1">
<label htmlFor="username" className="block text-sm font-medium text-slate-300">
Username
</label>
<input
id="username"
type="text"
autoComplete="username"
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-100 text-sm placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
placeholder="admin"
/>
</div>
<div className="space-y-1">
<label htmlFor="password" className="block text-sm font-medium text-slate-300">
Password
</label>
<input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-100 text-sm placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
placeholder="••••••••"
/>
</div>
<Button
type="submit"
disabled={loading || !username.trim() || !password}
loading={loading}
className="w-full mt-2"
>
Sign in
</Button>
</form>
<p className="text-center text-xs text-slate-600 mt-4">
Credentials are set via Docker environment variables.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import type { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
interface ProtectedRouteProps {
children: ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated } = useAuthStore();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,142 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Download, Server, LogOut, ChevronDown } from 'lucide-react';
import { toast } from 'sonner';
import { toPng } from 'html-to-image';
import { useReactFlow } from '@xyflow/react';
import { Button } from '../ui/Button';
import { useAuthStore } from '../../store/useAuthStore';
import type { ServiceMapSummary } from '../../types';
import { apiClient } from '../../api/client';
interface MapToolbarProps {
maps: ServiceMapSummary[];
activeMapId: string | null;
activeMapName: string;
onSelectMap: (id: string) => void;
onCreateMap: () => void;
onPopulate: () => void;
flowContainerRef: React.RefObject<HTMLDivElement | null>;
}
export function MapToolbar({
maps,
activeMapId,
activeMapName,
onSelectMap,
onCreateMap,
onPopulate,
flowContainerRef,
}: MapToolbarProps) {
const navigate = useNavigate();
const { logout } = useAuthStore();
const { fitView } = useReactFlow();
const [exporting, setExporting] = useState(false);
const [mapDropdownOpen, setMapDropdownOpen] = useState(false);
async function handleExport() {
if (!flowContainerRef.current) return;
setExporting(true);
const toastId = toast.loading('Exporting…');
// Temporarily hide React Flow UI chrome
const minimap = flowContainerRef.current.querySelector('.react-flow__minimap') as HTMLElement | null;
const controls = flowContainerRef.current.querySelector('.react-flow__controls') as HTMLElement | null;
if (minimap) minimap.style.display = 'none';
if (controls) controls.style.display = 'none';
try {
const dataUrl = await toPng(flowContainerRef.current, { cacheBust: true });
const link = document.createElement('a');
link.download = `rackmapper-map-${activeMapName.replace(/\s+/g, '-')}-${Date.now()}.png`;
link.href = dataUrl;
link.click();
toast.success('Exported', { id: toastId });
} catch {
toast.error('Export failed', { id: toastId });
} finally {
if (minimap) minimap.style.display = '';
if (controls) controls.style.display = '';
setExporting(false);
}
}
async function handleLogout() {
await logout();
navigate('/login', { replace: true });
}
return (
<div className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700 z-10">
{/* Left */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-blue-500 rounded flex items-center justify-center">
<svg width="14" height="14" viewBox="0 0 18 18" fill="none">
<rect x="1" y="2" width="16" height="3" rx="1" fill="white" />
<rect x="1" y="7" width="16" height="3" rx="1" fill="white" opacity="0.7" />
<rect x="1" y="12" width="16" height="3" rx="1" fill="white" opacity="0.4" />
</svg>
</div>
<span className="text-sm font-bold text-slate-200 tracking-wider">RACKMAPPER</span>
</div>
<span className="text-slate-600 text-xs hidden sm:inline">Service Mapper</span>
{/* Map selector */}
<div className="relative">
<button
onClick={() => setMapDropdownOpen((v) => !v)}
className="flex items-center gap-1 px-2 py-1 rounded bg-slate-700 border border-slate-600 text-sm text-slate-200 hover:bg-slate-600 transition-colors"
>
<span className="max-w-[140px] truncate">{activeMapId ? activeMapName : 'Select map…'}</span>
<ChevronDown size={12} />
</button>
{mapDropdownOpen && (
<div className="absolute top-full left-0 mt-1 w-52 bg-slate-800 border border-slate-700 rounded-lg shadow-xl z-50 overflow-hidden">
{maps.map((m) => (
<button
key={m.id}
className={`w-full text-left px-3 py-2 text-sm hover:bg-slate-700 transition-colors ${m.id === activeMapId ? 'text-blue-400' : 'text-slate-200'}`}
onClick={() => { onSelectMap(m.id); setMapDropdownOpen(false); }}
>
{m.name}
</button>
))}
<div className="border-t border-slate-700">
<button
className="w-full text-left px-3 py-2 text-sm text-blue-400 hover:bg-slate-700 transition-colors"
onClick={() => { onCreateMap(); setMapDropdownOpen(false); }}
>
+ New map
</button>
</div>
</div>
)}
</div>
</div>
{/* Right */}
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => navigate('/rack')}>
<Server size={14} />
Rack Planner
</Button>
{activeMapId && (
<>
<Button size="sm" variant="secondary" onClick={onPopulate} title="Import all rack modules as nodes">
Import Rack
</Button>
<Button size="sm" variant="secondary" onClick={() => fitView({ padding: 0.1 })}>
Fit View
</Button>
<Button size="sm" variant="secondary" onClick={handleExport} loading={exporting} disabled={exporting}>
<Download size={14} />
Export PNG
</Button>
</>
)}
<Button size="sm" variant="ghost" onClick={handleLogout} aria-label="Sign out">
<LogOut size={14} />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,270 @@
/**
* ServiceMapper — React Flow canvas for service/infrastructure mapping.
*
* SCAFFOLD STATUS:
* ✅ Canvas renders with all node types
* ✅ Map list, select, create via toolbar
* ✅ Auto-populate from rack modules
* ✅ Node drag + debounced position save
* ✅ Edge creation by connecting handles
* ✅ Minimap, controls, dot background
* ✅ PNG export
* ⚠️ Right-click context menus (canvas + node + edge) — TODO
* ⚠️ Node edit modal (label, color, link to module) — TODO
* ⚠️ Edge type/animation toggle — TODO
* ⚠️ Multi-select operations — functional but no toolbar actions
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
addEdge,
useNodesState,
useEdgesState,
type Node,
type Edge,
type OnConnect,
type NodeChange,
type EdgeChange,
BackgroundVariant,
ReactFlowProvider,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { toast } from 'sonner';
import { DeviceNode } from './nodes/DeviceNode';
import { ServiceNode as ServiceNodeComponent } from './nodes/ServiceNode';
import { DatabaseNode } from './nodes/DatabaseNode';
import { ApiNode } from './nodes/ApiNode';
import { ExternalNode } from './nodes/ExternalNode';
import { VlanNode } from './nodes/VlanNode';
import { FirewallNode } from './nodes/FirewallNode';
import { LBNode } from './nodes/LBNode';
import { UserNode } from './nodes/UserNode';
import { NoteNode } from './nodes/NoteNode';
import { MapToolbar } from './MapToolbar';
import { useMapStore } from '../../store/useMapStore';
import { apiClient } from '../../api/client';
import type { ServiceMap } from '../../types';
const NODE_TYPES = {
DEVICE: DeviceNode,
SERVICE: ServiceNodeComponent,
DATABASE: DatabaseNode,
API: ApiNode,
EXTERNAL: ExternalNode,
VLAN: VlanNode,
FIREWALL: FirewallNode,
LOAD_BALANCER: LBNode,
USER: UserNode,
NOTE: NoteNode,
};
function toFlowNodes(map: ServiceMap): Node[] {
return map.nodes.map((n) => ({
id: n.id,
type: n.nodeType,
position: { x: n.positionX, y: n.positionY },
data: {
label: n.label,
color: n.color,
icon: n.icon,
metadata: n.metadata,
module: n.module,
},
}));
}
function toFlowEdges(map: ServiceMap): Edge[] {
return map.edges.map((e) => ({
id: e.id,
source: e.sourceId,
target: e.targetId,
label: e.label,
type: e.edgeType,
animated: e.animated,
}));
}
function ServiceMapperInner() {
const { maps, activeMap, fetchMaps, loadMap, createMap, setActiveMap } = useMapStore();
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const flowContainerRef = useRef<HTMLDivElement>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Load maps list on mount
useEffect(() => {
fetchMaps().catch(() => toast.error('Failed to load maps'));
}, [fetchMaps]);
// When active map changes, update flow state
useEffect(() => {
if (activeMap) {
setNodes(toFlowNodes(activeMap));
setEdges(toFlowEdges(activeMap));
} else {
setNodes([]);
setEdges([]);
}
}, [activeMap, setNodes, setEdges]);
// Debounced node position save (500ms after drag end)
const handleNodesChange = useCallback(
(changes: NodeChange<Node>[]) => {
onNodesChange(changes);
const positionChanges = changes.filter(
(c): c is NodeChange<Node> & { type: 'position'; dragging: false } =>
c.type === 'position' && !(c as { dragging?: boolean }).dragging
);
if (positionChanges.length === 0) return;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => {
for (const change of positionChanges) {
const nodeId = (change as { id: string }).id;
const position = (change as { position?: { x: number; y: number } }).position;
if (!position) continue;
try {
await apiClient.nodes.update(nodeId, {
positionX: position.x,
positionY: position.y,
});
} catch {
// Silent — position drift on failure is acceptable
}
}
}, 500);
},
[onNodesChange]
);
const handleEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => {
onEdgesChange(changes);
},
[onEdgesChange]
);
const onConnect: OnConnect = useCallback(
async (connection) => {
if (!activeMap) return;
try {
const edge = await apiClient.maps.addEdge(activeMap.id, {
sourceId: connection.source,
targetId: connection.target,
});
setEdges((eds) =>
addEdge(
{
id: edge.id,
source: edge.sourceId,
target: edge.targetId,
type: edge.edgeType,
animated: edge.animated,
},
eds
)
);
} catch {
toast.error('Failed to create connection');
}
},
[activeMap, setEdges]
);
async function handleSelectMap(id: string) {
try {
await loadMap(id);
} catch {
toast.error('Failed to load map');
}
}
async function handleCreateMap() {
const name = prompt('Map name:');
if (!name?.trim()) return;
try {
const map = await createMap(name.trim());
setActiveMap(map);
} catch {
toast.error('Failed to create map');
}
}
async function handlePopulate() {
if (!activeMap) return;
try {
const updated = await apiClient.maps.populate(activeMap.id);
setActiveMap(updated);
toast.success('Rack modules imported');
} catch {
toast.error('Failed to import rack modules');
}
}
return (
<div className="flex flex-col h-screen bg-[#0f1117]">
<MapToolbar
maps={maps}
activeMapId={activeMap?.id ?? null}
activeMapName={activeMap?.name ?? ''}
onSelectMap={handleSelectMap}
onCreateMap={handleCreateMap}
onPopulate={handlePopulate}
flowContainerRef={flowContainerRef}
/>
<div ref={flowContainerRef} className="flex-1 relative">
{!activeMap ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<p className="text-slate-300 font-medium">No map selected</p>
<p className="text-slate-500 text-sm">
Select a map from the toolbar or create a new one.
</p>
</div>
) : (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
snapToGrid
snapGrid={[15, 15]}
fitView
deleteKeyCode="Delete"
className="bg-[#1e2433]"
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="#2d3748"
/>
<Controls className="!bg-slate-800 !border-slate-700 !shadow-xl" />
<MiniMap
className="!bg-slate-900 !border-slate-700"
nodeColor="#475569"
maskColor="rgba(15,17,23,0.7)"
/>
</ReactFlow>
)}
</div>
</div>
);
}
export function ServiceMapper() {
return (
<ReactFlowProvider>
<ServiceMapperInner />
</ReactFlowProvider>
);
}

View File

@@ -0,0 +1,24 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Zap } from 'lucide-react';
export const ApiNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'API';
const method = (data as { method?: string }).method;
return (
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-yellow-500 border-yellow-500' : 'border-yellow-700'}`}>
<Handle type="target" position={Position.Top} className="!bg-yellow-400 !border-yellow-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Zap size={13} className="text-yellow-400 shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate flex-1">{label}</span>
{method && (
<span className="text-[10px] px-1 py-0.5 rounded bg-yellow-900/60 text-yellow-300 border border-yellow-700/50 font-mono shrink-0">
{method}
</span>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-yellow-400 !border-yellow-600" />
</div>
);
});
ApiNode.displayName = 'ApiNode';

View File

@@ -0,0 +1,18 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Database } from 'lucide-react';
export const DatabaseNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Database';
return (
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-teal-500 border-teal-500' : 'border-teal-700'}`}>
<Handle type="target" position={Position.Top} className="!bg-teal-400 !border-teal-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Database size={13} className="text-teal-400 shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-teal-400 !border-teal-600" />
</div>
);
});
DatabaseNode.displayName = 'DatabaseNode';

View File

@@ -0,0 +1,56 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import type { Module } from '../../../types';
import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS } from '../../../lib/constants';
import { Badge } from '../../ui/Badge';
export interface DeviceNodeData {
label: string;
module?: Module;
[key: string]: unknown;
}
export const DeviceNode = memo(({ data, selected }: NodeProps) => {
const nodeData = data as DeviceNodeData;
const mod = nodeData.module;
const colors = mod ? MODULE_TYPE_COLORS[mod.type] : null;
return (
<div
className={`min-w-[160px] max-w-[200px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden transition-all ${
selected ? 'ring-2 ring-blue-500 border-blue-500' : 'border-slate-600'
} ${colors ? colors.border : ''}`}
>
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
{/* Colored accent strip */}
{colors && <div className={`h-1 w-full ${colors.bg}`} />}
<div className="px-3 py-2">
<div className="flex items-center gap-1.5 mb-1">
<svg width="12" height="12" viewBox="0 0 18 18" fill="none" className="shrink-0 opacity-60">
<rect x="1" y="2" width="16" height="3" rx="1" fill="currentColor" />
<rect x="1" y="7" width="16" height="3" rx="1" fill="currentColor" opacity="0.7" />
<rect x="1" y="12" width="16" height="3" rx="1" fill="currentColor" opacity="0.4" />
</svg>
<span className="text-xs font-semibold text-slate-100 truncate">{nodeData.label}</span>
</div>
{mod && (
<div className="flex flex-wrap gap-1">
<Badge variant="slate" className="text-[10px]">{MODULE_TYPE_LABELS[mod.type]}</Badge>
{mod.ipAddress && (
<span className="text-[10px] text-slate-400 font-mono">{mod.ipAddress}</span>
)}
</div>
)}
{!mod && (
<span className="text-[10px] text-slate-500">Unlinked device</span>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div>
);
});
DeviceNode.displayName = 'DeviceNode';

View File

@@ -0,0 +1,18 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Cloud } from 'lucide-react';
export const ExternalNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'External';
return (
<div className={`min-w-[140px] bg-slate-800 border-2 border-dashed rounded-lg shadow-lg ${selected ? 'ring-2 ring-slate-400 border-slate-400' : 'border-slate-500'}`}>
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Cloud size={13} className="text-slate-400 shrink-0" />
<span className="text-xs font-medium text-slate-300 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div>
);
});
ExternalNode.displayName = 'ExternalNode';

View File

@@ -0,0 +1,18 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Shield } from 'lucide-react';
export const FirewallNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Firewall';
return (
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-red-500 border-red-500' : 'border-red-700'}`}>
<Handle type="target" position={Position.Top} className="!bg-red-400 !border-red-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Shield size={13} className="text-red-400 shrink-0" />
<span className="text-xs font-semibold text-slate-100 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-red-400 !border-red-600" />
</div>
);
});
FirewallNode.displayName = 'FirewallNode';

View File

@@ -0,0 +1,18 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Scale } from 'lucide-react';
export const LBNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Load Balancer';
return (
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-orange-500 border-orange-500' : 'border-orange-700'}`}>
<Handle type="target" position={Position.Top} className="!bg-orange-400 !border-orange-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Scale size={13} className="text-orange-400 shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-orange-400 !border-orange-600" />
</div>
);
});
LBNode.displayName = 'LBNode';

View File

@@ -0,0 +1,21 @@
import { memo } from 'react';
import { type NodeProps } from '@xyflow/react';
import { StickyNote } from 'lucide-react';
/** NoteNode has no handles — it's a free-floating annotation. */
export const NoteNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Note';
return (
<div
className={`min-w-[140px] max-w-[240px] bg-yellow-900/40 border rounded-lg shadow-lg ${
selected ? 'ring-2 ring-yellow-400 border-yellow-400' : 'border-yellow-700/60'
}`}
>
<div className="px-3 py-2 flex items-start gap-2">
<StickyNote size={12} className="text-yellow-400 shrink-0 mt-0.5" />
<span className="text-xs text-yellow-100/90 whitespace-pre-wrap break-words">{label}</span>
</div>
</div>
);
});
NoteNode.displayName = 'NoteNode';

View File

@@ -0,0 +1,25 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Layers } from 'lucide-react';
export const ServiceNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Service';
const color = (data as { color?: string }).color ?? '#3b82f6';
return (
<div
className={`min-w-[140px] bg-slate-800 rounded-lg shadow-lg border overflow-hidden ${
selected ? 'ring-2 ring-blue-500 border-blue-500' : 'border-slate-600'
}`}
style={{ borderLeftColor: color, borderLeftWidth: 3 }}
>
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Layers size={13} style={{ color }} className="shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div>
);
});
ServiceNode.displayName = 'ServiceNode';

View File

@@ -0,0 +1,18 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { User } from 'lucide-react';
export const UserNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'User';
return (
<div className={`min-w-[120px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-slate-400 border-slate-400' : 'border-slate-600'}`}>
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
<div className="px-3 py-2 flex items-center gap-2">
<User size={13} className="text-slate-400 shrink-0" />
<span className="text-xs font-medium text-slate-300 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div>
);
});
UserNode.displayName = 'UserNode';

View File

@@ -0,0 +1,21 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
export const VlanNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'VLAN';
const color = (data as { color?: string }).color ?? '#6366f1';
return (
<div
className={`min-w-[120px] rounded-lg shadow-lg border-2 ${selected ? 'ring-2 ring-offset-1 ring-offset-slate-900' : ''}`}
style={{ backgroundColor: `${color}22`, borderColor: color }}
>
<Handle type="target" position={Position.Top} style={{ borderColor: color }} />
<div className="px-3 py-2 flex items-center gap-2">
<div className="w-3 h-3 rounded-sm shrink-0" style={{ backgroundColor: color }} />
<span className="text-xs font-semibold text-slate-100 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} style={{ borderColor: color }} />
</div>
);
});
VlanNode.displayName = 'VlanNode';

View File

@@ -0,0 +1,227 @@
import { useState, type FormEvent } from 'react';
import { toast } from 'sonner';
import type { ModuleType } from '../../types';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/Button';
import { useRackStore } from '../../store/useRackStore';
import {
MODULE_TYPE_LABELS,
MODULE_TYPE_COLORS,
MODULE_U_DEFAULTS,
MODULE_PORT_DEFAULTS,
} from '../../lib/constants';
import { cn } from '../../lib/utils';
interface AddModuleModalProps {
open: boolean;
onClose: () => void;
rackId: string;
uPosition: number;
}
const ALL_TYPES: ModuleType[] = [
'SWITCH', 'AGGREGATE_SWITCH', 'ROUTER', 'FIREWALL', 'PATCH_PANEL',
'MODEM', 'SERVER', 'NAS', 'PDU', 'AP', 'BLANK', 'OTHER',
];
export function AddModuleModal({ open, onClose, rackId, uPosition }: AddModuleModalProps) {
const { addModule } = useRackStore();
const [selectedType, setSelectedType] = useState<ModuleType | null>(null);
const [name, setName] = useState('');
const [uSize, setUSize] = useState(1);
const [portCount, setPortCount] = useState(0);
const [ipAddress, setIpAddress] = useState('');
const [manufacturer, setManufacturer] = useState('');
const [model, setModel] = useState('');
const [loading, setLoading] = useState(false);
function handleTypeSelect(type: ModuleType) {
setSelectedType(type);
setName(MODULE_TYPE_LABELS[type]);
setUSize(MODULE_U_DEFAULTS[type]);
setPortCount(MODULE_PORT_DEFAULTS[type]);
}
function reset() {
setSelectedType(null);
setName('');
setUSize(1);
setPortCount(0);
setIpAddress('');
setManufacturer('');
setModel('');
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!selectedType || !name.trim()) return;
setLoading(true);
try {
await addModule(rackId, {
name: name.trim(),
type: selectedType,
uPosition,
uSize,
portCount,
ipAddress: ipAddress.trim() || undefined,
manufacturer: manufacturer.trim() || undefined,
model: model.trim() || undefined,
});
toast.success(`${name.trim()} added at U${uPosition}`);
reset();
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add module');
} finally {
setLoading(false);
}
}
function handleClose() {
if (!loading) {
reset();
onClose();
}
}
return (
<Modal open={open} onClose={handleClose} title={`Add Module — U${uPosition}`} size="md">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Type selector */}
{!selectedType ? (
<div>
<p className="text-xs text-slate-400 mb-2">Select device type</p>
<div className="grid grid-cols-3 gap-1.5">
{ALL_TYPES.map((type) => {
const colors = MODULE_TYPE_COLORS[type];
return (
<button
key={type}
type="button"
onClick={() => handleTypeSelect(type)}
className={cn(
'flex flex-col items-center gap-1 px-2 py-2 rounded border text-center hover:brightness-125 transition-all',
colors.bg,
colors.border
)}
>
<span className="text-[11px] font-medium text-white leading-tight">
{MODULE_TYPE_LABELS[type]}
</span>
<span className="text-[10px] text-slate-400">
{MODULE_U_DEFAULTS[type]}U
</span>
</button>
);
})}
</div>
</div>
) : (
<>
<div className="flex items-center gap-2">
<div
className={cn(
'px-2 py-0.5 rounded text-xs font-medium border',
MODULE_TYPE_COLORS[selectedType].bg,
MODULE_TYPE_COLORS[selectedType].border,
'text-white'
)}
>
{MODULE_TYPE_LABELS[selectedType]}
</div>
<button
type="button"
onClick={() => setSelectedType(null)}
className="text-xs text-slate-500 hover:text-slate-300 underline"
>
Change type
</button>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">
Name <span className="text-red-400">*</span>
</label>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<label className="block text-sm text-slate-300">Size (U)</label>
<input
type="number"
min={1}
max={20}
value={uSize}
onChange={(e) => setUSize(Number(e.target.value))}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Port count</label>
<input
type="number"
min={0}
max={128}
value={portCount}
onChange={(e) => setPortCount(Number(e.target.value))}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">IP Address</label>
<input
value={ipAddress}
onChange={(e) => setIpAddress(e.target.value)}
disabled={loading}
placeholder="192.168.1.1"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-sm text-slate-300">Manufacturer</label>
<input
value={manufacturer}
onChange={(e) => setManufacturer(e.target.value)}
disabled={loading}
placeholder="Cisco, Ubiquiti…"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Model</label>
<input
value={model}
onChange={(e) => setModel(e.target.value)}
disabled={loading}
placeholder="SG300-28…"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-1">
<Button variant="secondary" size="sm" type="button" onClick={handleClose} disabled={loading}>
Cancel
</Button>
<Button size="sm" type="submit" loading={loading} disabled={!name.trim()}>
Add Module
</Button>
</div>
</>
)}
</form>
</Modal>
);
}

View File

@@ -0,0 +1,109 @@
import { useState, type FormEvent } from 'react';
import { toast } from 'sonner';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/Button';
import { useRackStore } from '../../store/useRackStore';
interface AddRackModalProps {
open: boolean;
onClose: () => void;
}
export function AddRackModal({ open, onClose }: AddRackModalProps) {
const { addRack } = useRackStore();
const [name, setName] = useState('');
const [totalU, setTotalU] = useState(42);
const [location, setLocation] = useState('');
const [loading, setLoading] = useState(false);
function reset() {
setName('');
setTotalU(42);
setLocation('');
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!name.trim()) return;
setLoading(true);
try {
await addRack(name.trim(), totalU, location.trim() || undefined);
toast.success(`Rack "${name.trim()}" created`);
reset();
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create rack');
} finally {
setLoading(false);
}
}
function handleClose() {
if (!loading) {
reset();
onClose();
}
}
return (
<Modal open={open} onClose={handleClose} title="Add Rack" size="sm">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label htmlFor="rack-name" className="block text-sm text-slate-300">
Name <span className="text-red-400">*</span>
</label>
<input
id="rack-name"
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
disabled={loading}
placeholder="e.g. Main Rack"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label htmlFor="rack-u" className="block text-sm text-slate-300">
Size (U)
</label>
<input
id="rack-u"
type="number"
min={1}
max={100}
value={totalU}
onChange={(e) => setTotalU(Number(e.target.value))}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label htmlFor="rack-location" className="block text-sm text-slate-300">
Location
</label>
<input
id="rack-location"
value={location}
onChange={(e) => setLocation(e.target.value)}
disabled={loading}
placeholder="e.g. Server Room"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-1">
<Button variant="secondary" size="sm" type="button" onClick={handleClose} disabled={loading}>
Cancel
</Button>
<Button size="sm" type="submit" loading={loading} disabled={!name.trim()}>
Create Rack
</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,146 @@
import { useState, useEffect, type FormEvent } from 'react';
import { toast } from 'sonner';
import type { Module } from '../../types';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/Button';
import { Badge } from '../ui/Badge';
import { useRackStore } from '../../store/useRackStore';
import { apiClient } from '../../api/client';
import { MODULE_TYPE_LABELS } from '../../lib/constants';
interface ModuleEditPanelProps {
module: Module;
open: boolean;
onClose: () => void;
}
export function ModuleEditPanel({ module, open, onClose }: ModuleEditPanelProps) {
const { updateModuleLocal } = useRackStore();
const [name, setName] = useState(module.name);
const [ipAddress, setIpAddress] = useState(module.ipAddress ?? '');
const [manufacturer, setManufacturer] = useState(module.manufacturer ?? '');
const [modelVal, setModelVal] = useState(module.model ?? '');
const [notes, setNotes] = useState(module.notes ?? '');
const [uSize, setUSize] = useState(module.uSize);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
setName(module.name);
setIpAddress(module.ipAddress ?? '');
setManufacturer(module.manufacturer ?? '');
setModelVal(module.model ?? '');
setNotes(module.notes ?? '');
setUSize(module.uSize);
}
}, [open, module]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setLoading(true);
try {
const updated = await apiClient.modules.update(module.id, {
name: name.trim(),
uSize,
ipAddress: ipAddress.trim() || undefined,
manufacturer: manufacturer.trim() || undefined,
model: modelVal.trim() || undefined,
notes: notes.trim() || undefined,
});
updateModuleLocal(module.id, updated);
toast.success('Module updated');
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Update failed');
} finally {
setLoading(false);
}
}
return (
<Modal open={open} onClose={onClose} title="Edit Module" size="md">
<form onSubmit={handleSubmit} className="space-y-3">
{/* Type (read-only) */}
<div className="flex items-center gap-2 pb-1">
<Badge variant="slate">{MODULE_TYPE_LABELS[module.type]}</Badge>
<span className="text-xs text-slate-500">U{module.uPosition} · rack position</span>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Name <span className="text-red-400">*</span></label>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-sm text-slate-300">Size (U)</label>
<input
type="number" min={1} max={20}
value={uSize}
onChange={(e) => setUSize(Number(e.target.value))}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">IP Address</label>
<input
value={ipAddress}
onChange={(e) => setIpAddress(e.target.value)}
disabled={loading}
placeholder="192.168.1.1"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-sm text-slate-300">Manufacturer</label>
<input
value={manufacturer}
onChange={(e) => setManufacturer(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Model</label>
<input
value={modelVal}
onChange={(e) => setModelVal(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Notes</label>
<textarea
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 resize-none"
/>
</div>
<div className="flex justify-end gap-3 pt-1">
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button size="sm" type="submit" loading={loading} disabled={!name.trim()}>
Save Changes
</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,267 @@
import { useState, useEffect, type FormEvent } from 'react';
import { toast } from 'sonner';
import type { Port, Vlan, VlanMode } from '../../types';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/Button';
import { Badge } from '../ui/Badge';
import { apiClient } from '../../api/client';
import { useRackStore } from '../../store/useRackStore';
interface PortConfigModalProps {
portId: string;
open: boolean;
onClose: () => void;
}
export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps) {
const { racks, updateModuleLocal } = useRackStore();
const [port, setPort] = useState<Port | null>(null);
const [vlans, setVlans] = useState<Vlan[]>([]);
const [label, setLabel] = useState('');
const [mode, setMode] = useState<VlanMode>('ACCESS');
const [nativeVlanId, setNativeVlanId] = useState<string>('');
const [taggedVlanIds, setTaggedVlanIds] = useState<string[]>([]);
const [notes, setNotes] = useState('');
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
// Quick-create VLAN
const [newVlanId, setNewVlanId] = useState('');
const [newVlanName, setNewVlanName] = useState('');
const [creatingVlan, setCreatingVlan] = useState(false);
// Find the port from store
useEffect(() => {
if (!open) return;
let found: Port | undefined;
for (const rack of racks) {
for (const mod of rack.modules) {
found = mod.ports.find((p) => p.id === portId);
if (found) break;
}
if (found) break;
}
if (found) {
setPort(found);
setLabel(found.label ?? '');
setMode(found.mode);
setNativeVlanId(found.nativeVlan?.toString() ?? '');
setTaggedVlanIds(found.vlans.filter((v) => v.tagged).map((v) => v.vlanId));
setNotes(found.notes ?? '');
}
}, [open, portId, racks]);
// Load VLAN list
useEffect(() => {
if (!open) return;
setFetching(true);
apiClient.vlans
.list()
.then(setVlans)
.catch(() => toast.error('Failed to load VLANs'))
.finally(() => setFetching(false));
}, [open]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setLoading(true);
try {
const vlanAssignments = [
...(mode === 'ACCESS' && nativeVlanId
? [{ vlanId: vlans.find((v) => v.vlanId === Number(nativeVlanId))?.id ?? '', tagged: false }]
: []),
...(mode !== 'ACCESS'
? taggedVlanIds.map((id) => ({ vlanId: id, tagged: true }))
: []),
].filter((v) => v.vlanId);
await apiClient.ports.update(portId, {
label: label.trim() || undefined,
mode,
nativeVlan: nativeVlanId ? Number(nativeVlanId) : null,
notes: notes.trim() || undefined,
vlans: vlanAssignments,
});
// Refresh racks to reflect changes
const portsInRack = racks.flatMap((r) => r.modules).find((m) => m.ports.some((p) => p.id === portId));
if (portsInRack) {
const updatedPorts = await apiClient.modules.getPorts(portsInRack.id);
updateModuleLocal(portsInRack.id, { ports: updatedPorts });
}
toast.success('Port saved');
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Save failed');
} finally {
setLoading(false);
}
}
async function handleCreateVlan() {
const id = Number(newVlanId);
if (!id || !newVlanName.trim()) return;
setCreatingVlan(true);
try {
const created = await apiClient.vlans.create({ vlanId: id, name: newVlanName.trim() });
setVlans((v) => [...v, created].sort((a, b) => a.vlanId - b.vlanId));
setNewVlanId('');
setNewVlanName('');
toast.success(`VLAN ${id} created`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create VLAN');
} finally {
setCreatingVlan(false);
}
}
function toggleTaggedVlan(vlanDbId: string) {
setTaggedVlanIds((prev) =>
prev.includes(vlanDbId) ? prev.filter((id) => id !== vlanDbId) : [...prev, vlanDbId]
);
}
if (!port) return null;
return (
<Modal open={open} onClose={onClose} title={`Port ${port.portNumber} Configuration`} size="md">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Port info */}
<div className="flex items-center gap-2">
<Badge variant="slate">{port.portType}</Badge>
<span className="text-xs text-slate-500">Port #{port.portNumber}</span>
</div>
{/* Label */}
<div className="space-y-1">
<label className="block text-sm text-slate-300">Label</label>
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
disabled={loading}
placeholder="e.g. Server 1 uplink"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
{/* Mode */}
<div className="space-y-1">
<label className="block text-sm text-slate-300">Mode</label>
<div className="flex gap-2">
{(['ACCESS', 'TRUNK', 'HYBRID'] as VlanMode[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
className={`px-3 py-1.5 rounded text-xs font-medium border transition-colors ${
mode === m
? 'bg-blue-600 border-blue-500 text-white'
: 'bg-slate-900 border-slate-600 text-slate-400 hover:border-slate-500'
}`}
>
{m}
</button>
))}
</div>
</div>
{/* Native VLAN */}
<div className="space-y-1">
<label className="block text-sm text-slate-300">Native VLAN</label>
<select
value={nativeVlanId}
onChange={(e) => setNativeVlanId(e.target.value)}
disabled={loading || fetching}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
>
<option value=""> Untagged </option>
{vlans.map((v) => (
<option key={v.id} value={v.vlanId.toString()}>
VLAN {v.vlanId} {v.name}
</option>
))}
</select>
</div>
{/* Tagged VLANs — Trunk/Hybrid only */}
{mode !== 'ACCESS' && (
<div className="space-y-1">
<label className="block text-sm text-slate-300">Tagged VLANs</label>
<div className="max-h-32 overflow-y-auto bg-slate-900 border border-slate-600 rounded-lg p-2 flex flex-wrap gap-1.5">
{vlans.length === 0 && (
<span className="text-xs text-slate-600">No VLANs defined yet</span>
)}
{vlans.map((v) => (
<button
key={v.id}
type="button"
onClick={() => toggleTaggedVlan(v.id)}
className={`px-2 py-0.5 rounded text-xs border transition-colors ${
taggedVlanIds.includes(v.id)
? 'bg-blue-700 border-blue-500 text-white'
: 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-400'
}`}
>
{v.vlanId} {v.name}
</button>
))}
</div>
</div>
)}
{/* Quick-create VLAN */}
<div className="border border-slate-700 rounded-lg p-3 space-y-2">
<p className="text-xs font-medium text-slate-400">Quick-create VLAN</p>
<div className="flex gap-2">
<input
type="number"
min={1}
max={4094}
value={newVlanId}
onChange={(e) => setNewVlanId(e.target.value)}
placeholder="VLAN ID"
className="w-24 bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<input
value={newVlanName}
onChange={(e) => setNewVlanName(e.target.value)}
placeholder="Name"
className="flex-1 bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<Button
type="button"
size="sm"
variant="secondary"
onClick={handleCreateVlan}
loading={creatingVlan}
disabled={!newVlanId || !newVlanName.trim()}
>
Add
</Button>
</div>
</div>
{/* Notes */}
<div className="space-y-1">
<label className="block text-sm text-slate-300">Notes</label>
<textarea
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 resize-none"
/>
</div>
<div className="flex justify-end gap-3">
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button size="sm" type="submit" loading={loading}>
Save Port
</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,57 @@
/**
* DevicePalette — sidebar showing all available device types.
*
* SCAFFOLD: Currently a static visual list. Full drag-to-rack DnD requires
* @dnd-kit integration with the RackColumn drop targets (see roadmap).
*/
import type { ModuleType } from '../../types';
import { MODULE_TYPE_LABELS, MODULE_TYPE_COLORS, MODULE_U_DEFAULTS, MODULE_PORT_DEFAULTS } from '../../lib/constants';
import { cn } from '../../lib/utils';
const ALL_TYPES: ModuleType[] = [
'SWITCH', 'AGGREGATE_SWITCH', 'ROUTER', 'FIREWALL', 'PATCH_PANEL',
'MODEM', 'SERVER', 'NAS', 'PDU', 'AP', 'BLANK', 'OTHER',
];
interface DevicePaletteProps {
/** Called when user clicks a device type to place it. */
onSelect?: (type: ModuleType) => void;
}
export function DevicePalette({ onSelect }: DevicePaletteProps) {
return (
<aside className="w-44 shrink-0 flex flex-col bg-slate-800 border-r border-slate-700 overflow-y-auto">
<div className="px-3 py-2 border-b border-slate-700">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">Devices</p>
<p className="text-[10px] text-slate-600 mt-0.5">Click a slot, then choose type</p>
</div>
<div className="flex flex-col gap-1 p-2">
{ALL_TYPES.map((type) => {
const colors = MODULE_TYPE_COLORS[type];
return (
<button
key={type}
onClick={() => onSelect?.(type)}
className={cn(
'flex items-center gap-2 px-2 py-1.5 rounded border text-left w-full hover:brightness-125 transition-all',
colors.bg,
colors.border
)}
aria-label={`Add ${MODULE_TYPE_LABELS[type]}`}
>
<div className={cn('w-2 h-2 rounded-sm shrink-0', colors.bg, 'brightness-150')} />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-white truncate">
{MODULE_TYPE_LABELS[type]}
</div>
<div className="text-[10px] text-slate-400">
{MODULE_U_DEFAULTS[type]}U · {MODULE_PORT_DEFAULTS[type]} ports
</div>
</div>
</button>
);
})}
</div>
</aside>
);
}

View File

@@ -0,0 +1,148 @@
import { useState } from 'react';
import { Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import type { Module } from '../../types';
import { cn } from '../../lib/utils';
import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS, U_HEIGHT_PX } from '../../lib/constants';
import { Badge } from '../ui/Badge';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { ModuleEditPanel } from '../modals/ModuleEditPanel';
import { PortConfigModal } from '../modals/PortConfigModal';
import { useRackStore } from '../../store/useRackStore';
import { apiClient } from '../../api/client';
interface ModuleBlockProps {
module: Module;
}
export function ModuleBlock({ module }: ModuleBlockProps) {
const { removeModuleLocal } = useRackStore();
const [hovered, setHovered] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deletingLoading, setDeletingLoading] = useState(false);
const [portModalOpen, setPortModalOpen] = useState(false);
const [selectedPortId, setSelectedPortId] = useState<string | null>(null);
const colors = MODULE_TYPE_COLORS[module.type];
const height = module.uSize * U_HEIGHT_PX;
const hasPorts = module.ports.length > 0;
async function handleDelete() {
setDeletingLoading(true);
try {
await apiClient.modules.delete(module.id);
removeModuleLocal(module.id);
toast.success(`${module.name} removed`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Delete failed');
} finally {
setDeletingLoading(false);
setConfirmDeleteOpen(false);
}
}
function openPort(portId: string) {
setSelectedPortId(portId);
setPortModalOpen(true);
}
return (
<>
<div
className={cn(
'relative w-full border-l-2 cursor-pointer select-none flex flex-col justify-between px-2 py-0.5 overflow-hidden transition-opacity',
colors.bg,
colors.border,
hovered && 'brightness-110'
)}
style={{ height }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => setEditOpen(true)}
role="button"
tabIndex={0}
aria-label={`Edit ${module.name}`}
onKeyDown={(e) => e.key === 'Enter' && setEditOpen(true)}
>
{/* Main content */}
<div className="flex items-center gap-1 min-w-0">
<span className="text-xs font-semibold text-white truncate flex-1">{module.name}</span>
<Badge variant="slate" className="text-[10px] shrink-0">
{MODULE_TYPE_LABELS[module.type]}
</Badge>
</div>
{module.ipAddress && (
<div className="text-[10px] text-slate-300 font-mono truncate">{module.ipAddress}</div>
)}
{/* Port dots — only if module has ports and enough height */}
{hasPorts && height >= 28 && (
<div
className="flex flex-wrap gap-0.5 mt-0.5"
onClick={(e) => e.stopPropagation()}
>
{module.ports.slice(0, 32).map((port) => {
const hasVlan = port.vlans.length > 0;
return (
<button
key={port.id}
onClick={() => openPort(port.id)}
aria-label={`Port ${port.portNumber}`}
className={cn(
'w-2.5 h-2.5 rounded-sm border transition-colors',
hasVlan
? 'bg-green-400 border-green-500 hover:bg-green-300'
: 'bg-slate-600 border-slate-500 hover:bg-slate-400'
)}
/>
);
})}
{module.ports.length > 32 && (
<span className="text-[9px] text-slate-400">+{module.ports.length - 32}</span>
)}
</div>
)}
{/* Delete button — hover only */}
{hovered && (
<button
className="absolute top-0.5 right-0.5 p-0.5 rounded bg-red-800/80 hover:bg-red-600 text-white transition-colors z-10"
onClick={(e) => {
e.stopPropagation();
setConfirmDeleteOpen(true);
}}
aria-label={`Delete ${module.name}`}
>
<Trash2 size={11} />
</button>
)}
</div>
<ModuleEditPanel module={module} open={editOpen} onClose={() => setEditOpen(false)} />
<ConfirmDialog
open={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={handleDelete}
title="Remove Module"
message={`Remove "${module.name}" from the rack? This will also delete all associated port configuration.`}
confirmLabel="Remove"
loading={deletingLoading}
/>
{selectedPortId && (
<PortConfigModal
portId={selectedPortId}
open={portModalOpen}
onClose={() => {
setPortModalOpen(false);
setSelectedPortId(null);
}}
/>
)}
</>
);
}

View File

@@ -0,0 +1,106 @@
import { useState } from 'react';
import { Trash2, MapPin } from 'lucide-react';
import { toast } from 'sonner';
import type { Rack } from '../../types';
import { buildOccupancyMap } from '../../lib/utils';
import { ModuleBlock } from './ModuleBlock';
import { RackSlot } from './RackSlot';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { useRackStore } from '../../store/useRackStore';
interface RackColumnProps {
rack: Rack;
}
export function RackColumn({ rack }: RackColumnProps) {
const { deleteRack } = useRackStore();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const occupancy = buildOccupancyMap(rack.modules);
async function handleDelete() {
setDeleting(true);
try {
await deleteRack(rack.id);
toast.success(`Rack "${rack.name}" deleted`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Delete failed');
} finally {
setDeleting(false);
setConfirmDeleteOpen(false);
}
}
// Build the slot render list — modules span multiple U slots
const slots: Array<{ u: number; moduleId: string | null }> = [];
const renderedModuleIds = new Set<string>();
for (let u = 1; u <= rack.totalU; u++) {
const moduleId = occupancy.get(u) ?? null;
slots.push({ u, moduleId });
}
return (
<>
<div className="flex flex-col min-w-[200px] w-48 shrink-0">
{/* Rack header */}
<div className="flex items-center gap-1 bg-slate-700 border border-slate-600 rounded-t-lg px-2 py-1.5 group">
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-slate-100 truncate">{rack.name}</div>
{rack.location && (
<div className="flex items-center gap-0.5 text-[10px] text-slate-400">
<MapPin size={9} />
{rack.location}
</div>
)}
</div>
<button
onClick={() => setConfirmDeleteOpen(true)}
aria-label={`Delete rack ${rack.name}`}
className="p-1 rounded hover:bg-red-800/50 text-slate-500 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100"
>
<Trash2 size={12} />
</button>
</div>
{/* U-slot body */}
<div className="border-x border-slate-600 bg-[#1e2433] flex flex-col">
{slots.map(({ u, moduleId }) => {
if (moduleId) {
const module = rack.modules.find((m) => m.id === moduleId);
if (!module) return null;
// Only render the block on its first U (top)
if (module.uPosition !== u) return null;
if (renderedModuleIds.has(moduleId)) return null;
renderedModuleIds.add(moduleId);
return <ModuleBlock key={module.id} module={module} />;
}
return <RackSlot key={u} rackId={rack.id} uPosition={u} />;
})}
</div>
{/* Rack footer */}
<div className="bg-slate-700 border border-slate-600 rounded-b-lg px-2 py-1 flex items-center justify-between">
<span className="text-[10px] text-slate-400">{rack.totalU}U rack</span>
<span className="text-[10px] text-slate-500">
{rack.modules.reduce((acc, m) => acc + m.uSize, 0)}/{rack.totalU}U used
</span>
</div>
</div>
<ConfirmDialog
open={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={handleDelete}
title="Delete Rack"
message={`Delete "${rack.name}" and all modules inside? This cannot be undone.`}
confirmLabel="Delete Rack"
loading={deleting}
/>
</>
);
}

View File

@@ -0,0 +1,56 @@
import { useEffect, useRef } from 'react';
import { toast } from 'sonner';
import { useRackStore } from '../../store/useRackStore';
import { RackToolbar } from './RackToolbar';
import { RackColumn } from './RackColumn';
import { RackSkeleton } from '../ui/Skeleton';
export function RackPlanner() {
const { racks, loading, fetchRacks } = useRackStore();
const canvasRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetchRacks().catch(() => toast.error('Failed to load racks'));
}, [fetchRacks]);
return (
<div className="flex flex-col h-screen bg-[#0f1117]">
<RackToolbar rackCanvasRef={canvasRef} />
<div className="flex flex-1 overflow-hidden">
{/* Main rack canvas */}
<div className="flex-1 overflow-auto">
{loading ? (
<RackSkeleton />
) : racks.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-4">
<div className="w-16 h-16 bg-slate-800 rounded-xl border border-slate-700 flex items-center justify-center">
<svg width="32" height="32" viewBox="0 0 18 18" fill="none">
<rect x="1" y="2" width="16" height="3" rx="1" fill="#475569" />
<rect x="1" y="7" width="16" height="3" rx="1" fill="#475569" opacity="0.7" />
<rect x="1" y="12" width="16" height="3" rx="1" fill="#475569" opacity="0.4" />
</svg>
</div>
<div>
<p className="text-slate-300 font-medium">No racks yet</p>
<p className="text-slate-500 text-sm mt-1">
Click <strong className="text-slate-300">Add Rack</strong> in the toolbar to create your first rack.
</p>
</div>
</div>
) : (
<div
ref={canvasRef}
className="flex gap-4 p-4 min-h-full items-start"
style={{ background: '#0f1117' }}
>
{racks.map((rack) => (
<RackColumn key={rack.id} rack={rack} />
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { useState } from 'react';
import { Plus } from 'lucide-react';
import { U_HEIGHT_PX } from '../../lib/constants';
import { AddModuleModal } from '../modals/AddModuleModal';
interface RackSlotProps {
rackId: string;
uPosition: number;
/** True if this slot is occupied (skips rendering — ModuleBlock renders instead) */
occupied?: boolean;
}
export function RackSlot({ rackId, uPosition, occupied = false }: RackSlotProps) {
const [addModuleOpen, setAddModuleOpen] = useState(false);
if (occupied) return null;
return (
<>
<div
className="w-full border border-dashed border-slate-700/50 hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors group cursor-pointer flex items-center justify-between px-2"
style={{ height: U_HEIGHT_PX }}
onClick={() => setAddModuleOpen(true)}
role="button"
tabIndex={0}
aria-label={`Add module at U${uPosition}`}
onKeyDown={(e) => e.key === 'Enter' && setAddModuleOpen(true)}
>
<span className="text-[10px] text-slate-600 group-hover:text-slate-500 font-mono">
U{uPosition}
</span>
<Plus
size={10}
className="text-slate-700 group-hover:text-blue-500 opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
<AddModuleModal
open={addModuleOpen}
onClose={() => setAddModuleOpen(false)}
rackId={rackId}
uPosition={uPosition}
/>
</>
);
}

View File

@@ -0,0 +1,84 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Download, Map, LogOut } from 'lucide-react';
import { toast } from 'sonner';
import { toPng } from 'html-to-image';
import { Button } from '../ui/Button';
import { AddRackModal } from '../modals/AddRackModal';
import { useAuthStore } from '../../store/useAuthStore';
interface RackToolbarProps {
rackCanvasRef: React.RefObject<HTMLDivElement | null>;
}
export function RackToolbar({ rackCanvasRef }: RackToolbarProps) {
const navigate = useNavigate();
const { logout } = useAuthStore();
const [addRackOpen, setAddRackOpen] = useState(false);
const [exporting, setExporting] = useState(false);
async function handleExport() {
if (!rackCanvasRef.current) return;
setExporting(true);
const toastId = toast.loading('Exporting…');
try {
const dataUrl = await toPng(rackCanvasRef.current, { cacheBust: true });
const link = document.createElement('a');
link.download = `rackmapper-rack-${Date.now()}.png`;
link.href = dataUrl;
link.click();
toast.success('Exported successfully', { id: toastId });
} catch {
toast.error('Export failed', { id: toastId });
} finally {
setExporting(false);
}
}
async function handleLogout() {
await logout();
navigate('/login', { replace: true });
}
return (
<>
<div className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700">
{/* Left: brand */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-blue-500 rounded flex items-center justify-center">
<svg width="14" height="14" viewBox="0 0 18 18" fill="none">
<rect x="1" y="2" width="16" height="3" rx="1" fill="white" />
<rect x="1" y="7" width="16" height="3" rx="1" fill="white" opacity="0.7" />
<rect x="1" y="12" width="16" height="3" rx="1" fill="white" opacity="0.4" />
</svg>
</div>
<span className="text-sm font-bold text-slate-200 tracking-wider">RACKMAPPER</span>
</div>
<span className="text-slate-600 text-xs hidden sm:inline">Rack Planner</span>
</div>
{/* Right: actions */}
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => navigate('/map')}>
<Map size={14} />
Service Map
</Button>
<Button size="sm" onClick={() => setAddRackOpen(true)}>
<Plus size={14} />
Add Rack
</Button>
<Button size="sm" variant="secondary" onClick={handleExport} loading={exporting} disabled={exporting}>
<Download size={14} />
Export PNG
</Button>
<Button size="sm" variant="ghost" onClick={handleLogout} aria-label="Sign out">
<LogOut size={14} />
</Button>
</div>
</div>
<AddRackModal open={addRackOpen} onClose={() => setAddRackOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
import { cn } from '../../lib/utils';
interface BadgeProps {
children: ReactNode;
variant?: 'default' | 'blue' | 'green' | 'red' | 'yellow' | 'purple' | 'slate';
className?: string;
}
const variants = {
default: 'bg-slate-700 text-slate-300',
blue: 'bg-blue-900/60 text-blue-300 border border-blue-700/50',
green: 'bg-green-900/60 text-green-300 border border-green-700/50',
red: 'bg-red-900/60 text-red-300 border border-red-700/50',
yellow: 'bg-yellow-900/60 text-yellow-300 border border-yellow-700/50',
purple: 'bg-purple-900/60 text-purple-300 border border-purple-700/50',
slate: 'bg-slate-800 text-slate-400 border border-slate-700',
};
export function Badge({ children, variant = 'default', className }: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium',
variants[variant],
className
)}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,61 @@
import type { ButtonHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
}
export function Button({
variant = 'primary',
size = 'md',
loading = false,
disabled,
className,
children,
...props
}: ButtonProps) {
const base =
'inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-50 disabled:pointer-events-none';
const variants = {
primary: 'bg-blue-600 hover:bg-blue-500 text-white focus:ring-blue-500',
secondary:
'bg-slate-700 hover:bg-slate-600 text-slate-100 border border-slate-600 focus:ring-slate-500',
ghost: 'hover:bg-slate-700 text-slate-300 hover:text-slate-100 focus:ring-slate-500',
danger: 'bg-red-700 hover:bg-red-600 text-white focus:ring-red-500',
};
const sizes = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-5 py-2.5 text-base',
};
return (
<button
disabled={disabled || loading}
className={cn(base, variants[variant], sizes[size], className)}
{...props}
>
{loading && (
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{children}
</button>
);
}

View File

@@ -0,0 +1,36 @@
import { Modal } from './Modal';
import { Button } from './Button';
interface ConfirmDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => void | Promise<void>;
title: string;
message: string;
confirmLabel?: string;
loading?: boolean;
}
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
message,
confirmLabel = 'Delete',
loading = false,
}: ConfirmDialogProps) {
return (
<Modal open={open} onClose={onClose} title={title} size="sm">
<p className="text-sm text-slate-300 mb-5">{message}</p>
<div className="flex justify-end gap-3">
<Button variant="secondary" size="sm" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button variant="danger" size="sm" onClick={onConfirm} loading={loading}>
{confirmLabel}
</Button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,90 @@
import { type ReactNode, useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { cn } from '../../lib/utils';
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
}
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-2xl',
};
export function Modal({ open, onClose, title, children, size = 'md', className }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
// Close on Escape
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onClose]);
// Trap focus — scroll lock
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Panel */}
<div
ref={dialogRef}
className={cn(
'relative w-full bg-slate-800 border border-slate-700 rounded-xl shadow-2xl',
sizeClasses[size],
className
)}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700">
<h2 id="modal-title" className="text-base font-semibold text-slate-100">
{title}
</h2>
<button
onClick={onClose}
aria-label="Close modal"
className="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors"
>
<X size={16} />
</button>
</div>
{/* Body */}
<div className="px-5 py-4">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { cn } from '../../lib/utils';
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className }: SkeletonProps) {
return (
<div
className={cn('animate-pulse rounded bg-slate-700/60', className)}
aria-hidden="true"
/>
);
}
export function RackSkeleton() {
return (
<div className="flex gap-4 p-4">
{[1, 2].map((i) => (
<div key={i} className="w-48 flex flex-col gap-1">
<Skeleton className="h-6 w-full mb-2" />
{Array.from({ length: 12 }).map((_, j) => (
<Skeleton key={j} className="h-7 w-full" />
))}
</div>
))}
</div>
);
}

30
client/src/index.css Normal file
View File

@@ -0,0 +1,30 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-[#0f1117] text-slate-100 antialiased;
}
/* Custom scrollbar — dark themed */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-slate-900;
}
::-webkit-scrollbar-thumb {
@apply bg-slate-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-slate-500;
}
}
@layer utilities {
.rack-slot-height {
height: 1.75rem; /* 28px per U */
}
}

View File

@@ -0,0 +1,76 @@
import type { ModuleType } from '../types';
// ---- Port count defaults per module type ----
export const MODULE_PORT_DEFAULTS: Record<ModuleType, number> = {
SWITCH: 24,
AGGREGATE_SWITCH: 8,
ROUTER: 4,
FIREWALL: 8,
PATCH_PANEL: 24,
AP: 1,
MODEM: 2,
SERVER: 2,
NAS: 1,
PDU: 12,
BLANK: 0,
OTHER: 0,
};
// ---- U-height defaults per module type ----
export const MODULE_U_DEFAULTS: Record<ModuleType, number> = {
SWITCH: 1,
AGGREGATE_SWITCH: 2,
ROUTER: 1,
FIREWALL: 1,
PATCH_PANEL: 1,
AP: 1,
MODEM: 1,
SERVER: 2,
NAS: 4,
PDU: 1,
BLANK: 1,
OTHER: 1,
};
// ---- Module type display labels ----
export const MODULE_TYPE_LABELS: Record<ModuleType, string> = {
SWITCH: 'Switch',
AGGREGATE_SWITCH: 'Agg Switch',
MODEM: 'Modem',
ROUTER: 'Router',
NAS: 'NAS',
PDU: 'PDU',
PATCH_PANEL: 'Patch Panel',
SERVER: 'Server',
FIREWALL: 'Firewall',
AP: 'Access Point',
BLANK: 'Blank',
OTHER: 'Other',
};
// ---- Tailwind bg+border color per module type ----
export const MODULE_TYPE_COLORS: Record<ModuleType, { bg: string; border: string; text: string }> =
{
SWITCH: { bg: 'bg-blue-700', border: 'border-blue-500', text: 'text-blue-100' },
AGGREGATE_SWITCH: {
bg: 'bg-indigo-700',
border: 'border-indigo-500',
text: 'text-indigo-100',
},
MODEM: { bg: 'bg-green-700', border: 'border-green-500', text: 'text-green-100' },
ROUTER: { bg: 'bg-teal-700', border: 'border-teal-500', text: 'text-teal-100' },
NAS: { bg: 'bg-purple-700', border: 'border-purple-500', text: 'text-purple-100' },
PDU: { bg: 'bg-yellow-700', border: 'border-yellow-500', text: 'text-yellow-100' },
PATCH_PANEL: { bg: 'bg-slate-600', border: 'border-slate-400', text: 'text-slate-100' },
SERVER: { bg: 'bg-slate-700', border: 'border-slate-500', text: 'text-slate-100' },
FIREWALL: { bg: 'bg-red-700', border: 'border-red-500', text: 'text-red-100' },
AP: { bg: 'bg-cyan-700', border: 'border-cyan-500', text: 'text-cyan-100' },
BLANK: { bg: 'bg-slate-800', border: 'border-slate-700', text: 'text-slate-500' },
OTHER: { bg: 'bg-slate-600', border: 'border-slate-500', text: 'text-slate-100' },
};
// ---- U-slot height in px (used for layout calculations) ----
export const U_HEIGHT_PX = 28;
// ---- Default rack size ----
export const DEFAULT_RACK_U = 42;

25
client/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,25 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/** Conditional className composition — Tailwind-aware merge. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Returns all U-slot numbers occupied by a module. */
export function occupiedSlots(uPosition: number, uSize: number): number[] {
return Array.from({ length: uSize }, (_, i) => uPosition + i);
}
/** Build a Set of occupied U-slots from a list of modules. */
export function buildOccupancyMap(
modules: Array<{ id: string; uPosition: number; uSize: number }>
): Map<number, string> {
const map = new Map<number, string>();
for (const m of modules) {
for (let u = m.uPosition; u < m.uPosition + m.uSize; u++) {
map.set(u, m.id);
}
}
return map;
}

15
client/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Toaster } from 'sonner';
import App from './App';
import './index.css';
const root = document.getElementById('root');
if (!root) throw new Error('Root element not found');
createRoot(root).render(
<StrictMode>
<App />
<Toaster theme="dark" position="bottom-right" richColors closeButton />
</StrictMode>
);

View File

@@ -0,0 +1,37 @@
import { create } from 'zustand';
import { apiClient } from '../api/client';
interface AuthState {
isAuthenticated: boolean;
loading: boolean;
checkAuth: () => Promise<void>;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
isAuthenticated: false,
loading: true,
checkAuth: async () => {
try {
await apiClient.auth.me();
set({ isAuthenticated: true, loading: false });
} catch {
set({ isAuthenticated: false, loading: false });
}
},
login: async (username, password) => {
await apiClient.auth.login(username, password);
set({ isAuthenticated: true });
},
logout: async () => {
try {
await apiClient.auth.logout();
} finally {
set({ isAuthenticated: false });
}
},
}));

View File

@@ -0,0 +1,58 @@
import { create } from 'zustand';
import type { ServiceMap, ServiceMapSummary } from '../types';
import { apiClient } from '../api/client';
interface MapState {
maps: ServiceMapSummary[];
activeMap: ServiceMap | null;
loading: boolean;
fetchMaps: () => Promise<void>;
loadMap: (id: string) => Promise<void>;
createMap: (name: string, description?: string) => Promise<ServiceMap>;
deleteMap: (id: string) => Promise<void>;
setActiveMap: (map: ServiceMap | null) => void;
}
export const useMapStore = create<MapState>((set) => ({
maps: [],
activeMap: null,
loading: false,
fetchMaps: async () => {
set({ loading: true });
try {
const maps = await apiClient.maps.list();
set({ maps, loading: false });
} catch {
set({ loading: false });
throw new Error('Failed to load maps');
}
},
loadMap: async (id) => {
set({ loading: true });
try {
const map = await apiClient.maps.get(id);
set({ activeMap: map, loading: false });
} catch {
set({ loading: false });
throw new Error('Failed to load map');
}
},
createMap: async (name, description) => {
const map = await apiClient.maps.create({ name, description });
set((s) => ({ maps: [{ id: map.id, name: map.name, description: map.description, createdAt: map.createdAt, updatedAt: map.updatedAt }, ...s.maps] }));
return map;
},
deleteMap: async (id) => {
await apiClient.maps.delete(id);
set((s) => ({
maps: s.maps.filter((m) => m.id !== id),
activeMap: s.activeMap?.id === id ? null : s.activeMap,
}));
},
setActiveMap: (map) => set({ activeMap: map }),
}));

View File

@@ -0,0 +1,90 @@
import { create } from 'zustand';
import type { Rack, Module } from '../types';
import { apiClient } from '../api/client';
interface RackState {
racks: Rack[];
loading: boolean;
selectedModuleId: string | null;
// Fetch
fetchRacks: () => Promise<void>;
// Rack CRUD
addRack: (name: string, totalU?: number, location?: string) => Promise<Rack>;
updateRack: (id: string, data: Partial<{ name: string; totalU: number; location: string; displayOrder: number }>) => Promise<void>;
deleteRack: (id: string) => Promise<void>;
// Module CRUD (optimistic update helpers)
addModule: (rackId: string, data: Parameters<typeof apiClient.racks.addModule>[1]) => Promise<Module>;
updateModuleLocal: (moduleId: string, data: Partial<Module>) => void;
removeModuleLocal: (moduleId: string) => void;
// Selection
setSelectedModule: (id: string | null) => void;
}
export const useRackStore = create<RackState>((set, get) => ({
racks: [],
loading: false,
selectedModuleId: null,
fetchRacks: async () => {
set({ loading: true });
try {
const racks = await apiClient.racks.list();
set({ racks, loading: false });
} catch {
set({ loading: false });
throw new Error('Failed to load racks');
}
},
addRack: async (name, totalU = 42, location) => {
const rack = await apiClient.racks.create({ name, totalU, location });
set((s) => ({ racks: [...s.racks, rack].sort((a, b) => a.displayOrder - b.displayOrder) }));
return rack;
},
updateRack: async (id, data) => {
const updated = await apiClient.racks.update(id, data);
set((s) => ({
racks: s.racks
.map((r) => (r.id === id ? updated : r))
.sort((a, b) => a.displayOrder - b.displayOrder),
}));
},
deleteRack: async (id) => {
await apiClient.racks.delete(id);
set((s) => ({ racks: s.racks.filter((r) => r.id !== id) }));
},
addModule: async (rackId, data) => {
const module = await apiClient.racks.addModule(rackId, data);
set((s) => ({
racks: s.racks.map((r) =>
r.id === rackId
? { ...r, modules: [...r.modules, module].sort((a, b) => a.uPosition - b.uPosition) }
: r
),
}));
return module;
},
updateModuleLocal: (moduleId, data) => {
set((s) => ({
racks: s.racks.map((r) => ({
...r,
modules: r.modules.map((m) => (m.id === moduleId ? { ...m, ...data } : m)),
})),
}));
},
removeModuleLocal: (moduleId) => {
set((s) => ({
racks: s.racks.map((r) => ({
...r,
modules: r.modules.filter((m) => m.id !== moduleId),
})),
}));
},
setSelectedModule: (id) => set({ selectedModuleId: id }),
}));

129
client/src/types/index.ts Normal file
View File

@@ -0,0 +1,129 @@
// ---- Enums (mirror Prisma enums) ----
export type ModuleType =
| 'SWITCH'
| 'AGGREGATE_SWITCH'
| 'MODEM'
| 'ROUTER'
| 'NAS'
| 'PDU'
| 'PATCH_PANEL'
| 'SERVER'
| 'FIREWALL'
| 'AP'
| 'BLANK'
| 'OTHER';
export type PortType = 'ETHERNET' | 'SFP' | 'SFP_PLUS' | 'QSFP' | 'CONSOLE' | 'UPLINK';
export type VlanMode = 'ACCESS' | 'TRUNK' | 'HYBRID';
export type NodeType =
| 'SERVICE'
| 'DATABASE'
| 'API'
| 'DEVICE'
| 'EXTERNAL'
| 'USER'
| 'VLAN'
| 'FIREWALL'
| 'LOAD_BALANCER'
| 'NOTE';
// ---- Domain models ----
export interface Vlan {
id: string;
vlanId: number;
name: string;
description?: string;
color?: string;
}
export interface PortVlanAssignment {
vlanId: string;
vlan: Vlan;
tagged: boolean;
}
export interface Port {
id: string;
moduleId: string;
portNumber: number;
label?: string;
portType: PortType;
mode: VlanMode;
nativeVlan?: number;
vlans: PortVlanAssignment[];
notes?: string;
}
export interface Module {
id: string;
rackId: string;
name: string;
type: ModuleType;
uPosition: number;
uSize: number;
manufacturer?: string;
model?: string;
ipAddress?: string;
notes?: string;
ports: Port[];
createdAt: string;
updatedAt: string;
}
export interface Rack {
id: string;
name: string;
totalU: number;
location?: string;
displayOrder: number;
modules: Module[];
createdAt: string;
updatedAt: string;
}
export interface ServiceNode {
id: string;
mapId: string;
label: string;
nodeType: NodeType;
positionX: number;
positionY: number;
metadata?: string;
color?: string;
icon?: string;
moduleId?: string;
module?: Module;
}
export interface ServiceEdge {
id: string;
mapId: string;
sourceId: string;
targetId: string;
label?: string;
edgeType: string;
animated: boolean;
metadata?: string;
}
export interface ServiceMap {
id: string;
name: string;
description?: string;
nodes: ServiceNode[];
edges: ServiceEdge[];
createdAt: string;
updatedAt: string;
}
export interface ServiceMapSummary {
id: string;
name: string;
description?: string;
createdAt: string;
updatedAt: string;
}

18
client/tailwind.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
// RackMapper palette aliases
surface: 'rgb(30 36 51)', // slate-800 equivalent
border: 'rgb(51 65 85)', // slate-700
accent: 'rgb(59 130 246)', // blue-500
danger: 'rgb(239 68 68)', // red-500
},
},
},
plugins: [],
} satisfies Config;

21
client/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
client/tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

19
client/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
});

26
docker-compose.yml Normal file
View File

@@ -0,0 +1,26 @@
version: '3.8'
services:
rackmapper:
build: .
container_name: rackmapper
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- PORT=3001
- DATABASE_URL=file:./data/rackmapper.db
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH}
- JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRY=${JWT_EXPIRY:-8h}
volumes:
# Persists SQLite database across container restarts
- ./data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/auth/me"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

5262
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View File

@@ -0,0 +1,50 @@
{
"name": "rackmapper",
"version": "1.0.0",
"private": true,
"description": "Web-based network rack planner and service mapper",
"scripts": {
"dev": "concurrently -n server,client -c cyan,magenta \"npm run dev:server\" \"npm run dev:client\"",
"dev:server": "nodemon --exec tsx server/index.ts --watch server --ext ts",
"dev:client": "cd client && npm run dev",
"build": "npm run build:server && cd client && npm run build",
"build:server": "tsc -p tsconfig.json",
"start": "node dist/server/index.js",
"typecheck": "tsc --noEmit && cd client && tsc --noEmit",
"lint": "eslint \"server/**/*.ts\" \"scripts/**/*.ts\"",
"lint:fix": "eslint \"server/**/*.ts\" \"scripts/**/*.ts\" --fix",
"format": "prettier --write \"**/*.{ts,tsx,json,md}\" --ignore-path .gitignore",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.5.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.12",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.9.0",
"concurrently": "^9.1.0",
"eslint": "^9.14.0",
"nodemon": "^3.1.7",
"prettier": "^3.3.3",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3",
"vitest": "^2.1.5"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}

View File

@@ -0,0 +1,103 @@
-- CreateTable
CREATE TABLE "Rack" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"totalU" INTEGER NOT NULL DEFAULT 42,
"location" TEXT,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Module" (
"id" TEXT NOT NULL PRIMARY KEY,
"rackId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"uPosition" INTEGER NOT NULL,
"uSize" INTEGER NOT NULL DEFAULT 1,
"manufacturer" TEXT,
"model" TEXT,
"ipAddress" TEXT,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Module_rackId_fkey" FOREIGN KEY ("rackId") REFERENCES "Rack" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Port" (
"id" TEXT NOT NULL PRIMARY KEY,
"moduleId" TEXT NOT NULL,
"portNumber" INTEGER NOT NULL,
"label" TEXT,
"portType" TEXT NOT NULL DEFAULT 'ETHERNET',
"mode" TEXT NOT NULL DEFAULT 'ACCESS',
"nativeVlan" INTEGER,
"notes" TEXT,
CONSTRAINT "Port_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "Module" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Vlan" (
"id" TEXT NOT NULL PRIMARY KEY,
"vlanId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"color" TEXT
);
-- CreateTable
CREATE TABLE "PortVlan" (
"portId" TEXT NOT NULL,
"vlanId" TEXT NOT NULL,
"tagged" BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY ("portId", "vlanId"),
CONSTRAINT "PortVlan_portId_fkey" FOREIGN KEY ("portId") REFERENCES "Port" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "PortVlan_vlanId_fkey" FOREIGN KEY ("vlanId") REFERENCES "Vlan" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ServiceMap" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "ServiceNode" (
"id" TEXT NOT NULL PRIMARY KEY,
"mapId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"nodeType" TEXT NOT NULL,
"positionX" REAL NOT NULL,
"positionY" REAL NOT NULL,
"metadata" TEXT,
"color" TEXT,
"icon" TEXT,
"moduleId" TEXT,
CONSTRAINT "ServiceNode_mapId_fkey" FOREIGN KEY ("mapId") REFERENCES "ServiceMap" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ServiceNode_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "Module" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ServiceEdge" (
"id" TEXT NOT NULL PRIMARY KEY,
"mapId" TEXT NOT NULL,
"sourceId" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"label" TEXT,
"edgeType" TEXT NOT NULL DEFAULT 'smoothstep',
"animated" BOOLEAN NOT NULL DEFAULT false,
"metadata" TEXT,
CONSTRAINT "ServiceEdge_mapId_fkey" FOREIGN KEY ("mapId") REFERENCES "ServiceMap" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ServiceEdge_sourceId_fkey" FOREIGN KEY ("sourceId") REFERENCES "ServiceNode" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ServiceEdge_targetId_fkey" FOREIGN KEY ("targetId") REFERENCES "ServiceNode" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Vlan_vlanId_key" ON "Vlan"("vlanId");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

116
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,116 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// NOTE: SQLite does not support Prisma enums.
// All enum-like fields are stored as String with validation enforced in the service layer.
// Valid values are documented in server/lib/constants.ts and client/src/types/index.ts
model Rack {
id String @id @default(cuid())
name String
totalU Int @default(42)
location String?
displayOrder Int @default(0)
modules Module[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Module {
id String @id @default(cuid())
rackId String
rack Rack @relation(fields: [rackId], references: [id], onDelete: Cascade)
name String
type String // ModuleType: SWITCH | AGGREGATE_SWITCH | MODEM | ROUTER | NAS | PDU | PATCH_PANEL | SERVER | FIREWALL | AP | BLANK | OTHER
uPosition Int
uSize Int @default(1)
manufacturer String?
model String?
ipAddress String?
notes String?
ports Port[]
serviceNodes ServiceNode[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Port {
id String @id @default(cuid())
moduleId String
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
portNumber Int
label String?
portType String @default("ETHERNET") // PortType: ETHERNET | SFP | SFP_PLUS | QSFP | CONSOLE | UPLINK
mode String @default("ACCESS") // VlanMode: ACCESS | TRUNK | HYBRID
nativeVlan Int?
vlans PortVlan[]
notes String?
}
model Vlan {
id String @id @default(cuid())
vlanId Int @unique
name String
description String?
color String?
ports PortVlan[]
}
model PortVlan {
portId String
port Port @relation(fields: [portId], references: [id], onDelete: Cascade)
vlanId String
vlan Vlan @relation(fields: [vlanId], references: [id], onDelete: Cascade)
tagged Boolean @default(false)
@@id([portId, vlanId])
}
// --- Service Mapper ---
model ServiceMap {
id String @id @default(cuid())
name String
description String?
nodes ServiceNode[]
edges ServiceEdge[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ServiceNode {
id String @id @default(cuid())
mapId String
map ServiceMap @relation(fields: [mapId], references: [id], onDelete: Cascade)
label String
nodeType String // NodeType: SERVICE | DATABASE | API | DEVICE | EXTERNAL | USER | VLAN | FIREWALL | LOAD_BALANCER | NOTE
positionX Float
positionY Float
metadata String?
color String?
icon String?
moduleId String?
module Module? @relation(fields: [moduleId], references: [id], onDelete: SetNull)
sourceEdges ServiceEdge[] @relation("EdgeSource")
targetEdges ServiceEdge[] @relation("EdgeTarget")
}
model ServiceEdge {
id String @id @default(cuid())
mapId String
map ServiceMap @relation(fields: [mapId], references: [id], onDelete: Cascade)
sourceId String
source ServiceNode @relation("EdgeSource", fields: [sourceId], references: [id], onDelete: Cascade)
targetId String
target ServiceNode @relation("EdgeTarget", fields: [targetId], references: [id], onDelete: Cascade)
label String?
edgeType String @default("smoothstep")
animated Boolean @default(false)
metadata String?
}

19
prisma/seed.ts Normal file
View File

@@ -0,0 +1,19 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// No default seed data — all content is created by the user.
// This script is safe to run multiple times (idempotent no-op).
console.log('RackMapper database is ready. No seed data configured.');
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});

20
scripts/hashPassword.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Generates a bcrypt hash for use as ADMIN_PASSWORD_HASH env var.
*
* Usage:
* npx tsx scripts/hashPassword.ts yourpassword
*
* Copy the output and paste it into your Docker env var or .env file.
*/
import bcrypt from 'bcryptjs';
const password = process.argv[2];
if (!password) {
console.error('Usage: npx tsx scripts/hashPassword.ts <password>');
process.exit(1);
}
bcrypt.hash(password, 12).then((hash) => {
console.log(hash);
});

63
server/index.ts Normal file
View File

@@ -0,0 +1,63 @@
import 'dotenv/config';
import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import path from 'path';
import { authRouter } from './routes/auth';
import { racksRouter } from './routes/racks';
import { modulesRouter } from './routes/modules';
import { portsRouter } from './routes/ports';
import { vlansRouter } from './routes/vlans';
import { serviceMapRouter } from './routes/serviceMap';
import { nodesRouter } from './routes/nodes';
import { edgesRouter } from './routes/edges';
import { authMiddleware } from './middleware/authMiddleware';
import { errorHandler } from './middleware/errorHandler';
const app = express();
const PORT = process.env.PORT ?? 3001;
// ---- Core middleware ----
app.use(express.json());
app.use(cookieParser());
// CORS only needed in local dev (Vite :5173 → Node :3001)
if (process.env.NODE_ENV !== 'production') {
app.use(
cors({
origin: 'http://localhost:5173',
credentials: true,
})
);
}
// ---- Auth routes (no JWT required) ----
app.use('/api/auth', authRouter);
// ---- Protected API routes ----
app.use('/api', authMiddleware);
app.use('/api/racks', racksRouter);
app.use('/api/modules', modulesRouter);
app.use('/api/ports', portsRouter);
app.use('/api/vlans', vlansRouter);
app.use('/api/maps', serviceMapRouter);
app.use('/api/nodes', nodesRouter);
app.use('/api/edges', edgesRouter);
// ---- Serve Vite build in production ----
if (process.env.NODE_ENV === 'production') {
const clientDist = path.join(process.cwd(), 'client', 'dist');
app.use(express.static(clientDist));
// SPA fallback — always serve index.html for non-API routes
app.get(/^(?!\/api).*/, (_req, res) => {
res.sendFile(path.join(clientDist, 'index.html'));
});
}
// ---- Error handler (must be last) ----
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`RackMapper running on port ${PORT} [${process.env.NODE_ENV ?? 'development'}]`);
});

44
server/lib/constants.ts Normal file
View File

@@ -0,0 +1,44 @@
// SQLite doesn't support Prisma enums — use string literals throughout the server.
// These types mirror client/src/types/index.ts
export type ModuleType =
| 'SWITCH' | 'AGGREGATE_SWITCH' | 'MODEM' | 'ROUTER' | 'NAS'
| 'PDU' | 'PATCH_PANEL' | 'SERVER' | 'FIREWALL' | 'AP' | 'BLANK' | 'OTHER';
export type PortType = 'ETHERNET' | 'SFP' | 'SFP_PLUS' | 'QSFP' | 'CONSOLE' | 'UPLINK';
export type VlanMode = 'ACCESS' | 'TRUNK' | 'HYBRID';
export type NodeType =
| 'SERVICE' | 'DATABASE' | 'API' | 'DEVICE' | 'EXTERNAL'
| 'USER' | 'VLAN' | 'FIREWALL' | 'LOAD_BALANCER' | 'NOTE';
export const MODULE_PORT_DEFAULTS: Record<ModuleType, number> = {
SWITCH: 24,
AGGREGATE_SWITCH: 8,
ROUTER: 4,
FIREWALL: 8,
PATCH_PANEL: 24,
AP: 1,
MODEM: 2,
SERVER: 2,
NAS: 1,
PDU: 12,
BLANK: 0,
OTHER: 0,
};
export const MODULE_U_DEFAULTS: Record<ModuleType, number> = {
SWITCH: 1,
AGGREGATE_SWITCH: 2,
ROUTER: 1,
FIREWALL: 1,
PATCH_PANEL: 1,
AP: 1,
MODEM: 1,
SERVER: 2,
NAS: 4,
PDU: 1,
BLANK: 1,
OTHER: 1,
};

14
server/lib/prisma.ts Normal file
View File

@@ -0,0 +1,14 @@
import { PrismaClient } from '@prisma/client';
// Singleton pattern prevents multiple PrismaClient instances in dev (hot reload)
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}

View File

@@ -0,0 +1,26 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { AppError, AuthenticatedRequest } from '../types/index';
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const token = (req.cookies as Record<string, string | undefined>)?.token;
if (!token) {
next(new AppError('Unauthorized', 401, 'NO_TOKEN'));
return;
}
const secret = process.env.JWT_SECRET;
if (!secret) {
next(new AppError('Server misconfiguration: JWT_SECRET not set', 500, 'CONFIG_ERROR'));
return;
}
try {
const payload = jwt.verify(token, secret) as { sub: string };
(req as AuthenticatedRequest).user = { sub: payload.sub };
next();
} catch {
next(new AppError('Invalid or expired session', 401, 'INVALID_TOKEN'));
}
}

View File

@@ -0,0 +1,22 @@
import { Request, Response, NextFunction } from 'express';
import { AppError, err } from '../types/index';
export function errorHandler(
error: Error,
_req: Request,
res: Response,
_next: NextFunction
): void {
const statusCode = error instanceof AppError ? error.statusCode : 500;
const code = error instanceof AppError ? error.code : 'INTERNAL_ERROR';
const message =
process.env.NODE_ENV === 'production' && statusCode === 500
? 'Internal server error'
: error.message;
if (statusCode === 500 && process.env.NODE_ENV !== 'production') {
console.error('[ErrorHandler]', error);
}
res.status(statusCode).json(err(message, code ? { code } : undefined));
}

60
server/routes/auth.ts Normal file
View File

@@ -0,0 +1,60 @@
import { Router, Request, Response, NextFunction } from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { AppError, ok } from '../types/index';
import { authMiddleware } from '../middleware/authMiddleware';
export const authRouter = Router();
const COOKIE_OPTS = {
httpOnly: true,
sameSite: 'strict' as const,
secure: process.env.NODE_ENV === 'production',
path: '/',
};
authRouter.post('/login', async (req: Request, res: Response, next: NextFunction) => {
try {
const { username, password } = req.body as { username?: string; password?: string };
if (!username || !password) {
throw new AppError('Username and password are required', 400, 'MISSING_FIELDS');
}
const adminUsername = process.env.ADMIN_USERNAME;
const adminHash = process.env.ADMIN_PASSWORD_HASH;
if (!adminUsername || !adminHash) {
throw new AppError('Server not configured: admin credentials missing', 500, 'CONFIG_ERROR');
}
const usernameMatch = username === adminUsername;
// Always run bcrypt to prevent timing attacks even if username is wrong
const passwordMatch = await bcrypt.compare(password, adminHash);
if (!usernameMatch || !passwordMatch) {
throw new AppError('Invalid username or password', 401, 'INVALID_CREDENTIALS');
}
const secret = process.env.JWT_SECRET;
if (!secret) throw new AppError('Server not configured: JWT_SECRET missing', 500, 'CONFIG_ERROR');
const token = jwt.sign({ sub: 'admin' }, secret, {
expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'],
});
res.cookie('token', token, COOKIE_OPTS);
res.json(ok({ success: true }));
} catch (e) {
next(e);
}
});
authRouter.post('/logout', (_req: Request, res: Response) => {
res.clearCookie('token', COOKIE_OPTS);
res.json(ok({ success: true }));
});
authRouter.get('/me', authMiddleware, (_req: Request, res: Response) => {
res.json(ok({ authenticated: true }));
});

28
server/routes/edges.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as mapService from '../services/mapService';
import { ok } from '../types/index';
export const edgesRouter = Router();
edgesRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { label, edgeType, animated, metadata } = req.body as {
label?: string;
edgeType?: string;
animated?: boolean;
metadata?: string;
};
res.json(ok(await mapService.updateEdge(req.params.id, { label, edgeType, animated, metadata })));
} catch (e) {
next(e);
}
});
edgesRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await mapService.deleteEdge(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});

51
server/routes/modules.ts Normal file
View File

@@ -0,0 +1,51 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as moduleService from '../services/moduleService';
import { ok } from '../types/index';
export const modulesRouter = Router();
modulesRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, uPosition, uSize, manufacturer, model, ipAddress, notes } = req.body as {
name?: string;
uPosition?: number;
uSize?: number;
manufacturer?: string;
model?: string;
ipAddress?: string;
notes?: string;
};
res.json(
ok(
await moduleService.updateModule(req.params.id, {
name,
uPosition,
uSize,
manufacturer,
model,
ipAddress,
notes,
})
)
);
} catch (e) {
next(e);
}
});
modulesRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await moduleService.deleteModule(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});
modulesRouter.get('/:id/ports', async (req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await moduleService.getModulePorts(req.params.id)));
} catch (e) {
next(e);
}
});

33
server/routes/nodes.ts Normal file
View File

@@ -0,0 +1,33 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as mapService from '../services/mapService';
import { ok } from '../types/index';
export const nodesRouter = Router();
nodesRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { label, positionX, positionY, metadata, color, icon, moduleId } = req.body as {
label?: string;
positionX?: number;
positionY?: number;
metadata?: string;
color?: string;
icon?: string;
moduleId?: string | null;
};
res.json(
ok(await mapService.updateNode(req.params.id, { label, positionX, positionY, metadata, color, icon, moduleId }))
);
} catch (e) {
next(e);
}
});
nodesRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await mapService.deleteNode(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});

21
server/routes/ports.ts Normal file
View File

@@ -0,0 +1,21 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as portService from '../services/portService';
import { ok } from '../types/index';
import type { VlanMode } from '../lib/constants';
export const portsRouter = Router();
portsRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { label, mode, nativeVlan, notes, vlans } = req.body as {
label?: string;
mode?: VlanMode;
nativeVlan?: number | null;
notes?: string;
vlans?: Array<{ vlanId: string; tagged: boolean }>;
};
res.json(ok(await portService.updatePort(req.params.id, { label, mode, nativeVlan, notes, vlans })));
} catch (e) {
next(e);
}
});

83
server/routes/racks.ts Normal file
View File

@@ -0,0 +1,83 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as rackService from '../services/rackService';
import * as moduleService from '../services/moduleService';
import { ok } from '../types/index';
import type { ModuleType, PortType } from '../lib/constants';
export const racksRouter = Router();
racksRouter.get('/', async (_req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await rackService.listRacks()));
} catch (e) {
next(e);
}
});
racksRouter.post('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, totalU, location, displayOrder } = req.body as {
name: string;
totalU?: number;
location?: string;
displayOrder?: number;
};
res.status(201).json(ok(await rackService.createRack({ name, totalU, location, displayOrder })));
} catch (e) {
next(e);
}
});
racksRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await rackService.getRack(req.params.id)));
} catch (e) {
next(e);
}
});
racksRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, totalU, location, displayOrder } = req.body as {
name?: string;
totalU?: number;
location?: string;
displayOrder?: number;
};
res.json(ok(await rackService.updateRack(req.params.id, { name, totalU, location, displayOrder })));
} catch (e) {
next(e);
}
});
racksRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await rackService.deleteRack(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});
racksRouter.post('/:id/modules', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType } =
req.body as {
name: string;
type: ModuleType;
uPosition: number;
uSize?: number;
manufacturer?: string;
model?: string;
ipAddress?: string;
notes?: string;
portCount?: number;
portType?: PortType;
};
res.status(201).json(
ok(await moduleService.createModule(req.params.id, { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType }))
);
} catch (e) {
next(e);
}
});

View File

@@ -0,0 +1,97 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as mapService from '../services/mapService';
import { ok } from '../types/index';
import type { NodeType } from '../lib/constants';
export const serviceMapRouter = Router();
serviceMapRouter.get('/', async (_req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await mapService.listMaps()));
} catch (e) {
next(e);
}
});
serviceMapRouter.post('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, description } = req.body as { name: string; description?: string };
res.status(201).json(ok(await mapService.createMap({ name, description })));
} catch (e) {
next(e);
}
});
serviceMapRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await mapService.getMap(req.params.id)));
} catch (e) {
next(e);
}
});
serviceMapRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, description } = req.body as { name?: string; description?: string };
res.json(ok(await mapService.updateMap(req.params.id, { name, description })));
} catch (e) {
next(e);
}
});
serviceMapRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await mapService.deleteMap(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});
serviceMapRouter.post('/:id/nodes', async (req: Request, res: Response, next: NextFunction) => {
try {
const { label, nodeType, positionX, positionY, metadata, color, icon, moduleId } = req.body as {
label: string;
nodeType: NodeType;
positionX: number;
positionY: number;
metadata?: string;
color?: string;
icon?: string;
moduleId?: string;
};
res.status(201).json(
ok(await mapService.addNode(req.params.id, { label, nodeType, positionX, positionY, metadata, color, icon, moduleId }))
);
} catch (e) {
next(e);
}
});
serviceMapRouter.post('/:id/populate', async (req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await mapService.populateFromRack(req.params.id)));
} catch (e) {
next(e);
}
});
serviceMapRouter.post('/:id/edges', async (req: Request, res: Response, next: NextFunction) => {
try {
const { sourceId, targetId, label, edgeType, animated, metadata } = req.body as {
sourceId: string;
targetId: string;
label?: string;
edgeType?: string;
animated?: boolean;
metadata?: string;
};
res.status(201).json(
ok(await mapService.addEdge(req.params.id, { sourceId, targetId, label, edgeType, animated, metadata }))
);
} catch (e) {
next(e);
}
});
export { mapService };

49
server/routes/vlans.ts Normal file
View File

@@ -0,0 +1,49 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as vlanService from '../services/vlanService';
import { ok } from '../types/index';
export const vlansRouter = Router();
vlansRouter.get('/', async (_req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await vlanService.listVlans()));
} catch (e) {
next(e);
}
});
vlansRouter.post('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const { vlanId, name, description, color } = req.body as {
vlanId: number;
name: string;
description?: string;
color?: string;
};
res.status(201).json(ok(await vlanService.createVlan({ vlanId, name, description, color })));
} catch (e) {
next(e);
}
});
vlansRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, description, color } = req.body as {
name?: string;
description?: string;
color?: string;
};
res.json(ok(await vlanService.updateVlan(req.params.id, { name, description, color })));
} catch (e) {
next(e);
}
});
vlansRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await vlanService.deleteVlan(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});

View File

@@ -0,0 +1,170 @@
import { prisma } from '../lib/prisma';
import { AppError } from '../types/index';
import type { NodeType } from '../lib/constants';
const mapInclude = {
nodes: {
include: { module: true },
},
edges: true,
};
export async function listMaps() {
return prisma.serviceMap.findMany({
orderBy: { createdAt: 'desc' },
select: { id: true, name: true, description: true, createdAt: true, updatedAt: true },
});
}
export async function getMap(id: string) {
const map = await prisma.serviceMap.findUnique({ where: { id }, include: mapInclude });
if (!map) throw new AppError('Map not found', 404, 'NOT_FOUND');
return map;
}
export async function createMap(data: { name: string; description?: string }) {
return prisma.serviceMap.create({ data, include: mapInclude });
}
export async function updateMap(id: string, data: Partial<{ name: string; description: string }>) {
await getMap(id);
return prisma.serviceMap.update({ where: { id }, data, include: mapInclude });
}
export async function deleteMap(id: string) {
await getMap(id);
return prisma.serviceMap.delete({ where: { id } });
}
// ---- Nodes ----
export async function addNode(
mapId: string,
data: {
label: string;
nodeType: NodeType;
positionX: number;
positionY: number;
metadata?: string;
color?: string;
icon?: string;
moduleId?: string;
}
) {
await getMap(mapId);
return prisma.serviceNode.create({
data: { mapId, ...data },
include: { module: true },
});
}
export async function populateFromRack(mapId: string) {
await getMap(mapId);
const modules = await prisma.module.findMany({
orderBy: [{ rack: { displayOrder: 'asc' } }, { uPosition: 'asc' }],
include: { rack: true },
});
const existing = await prisma.serviceNode.findMany({
where: { mapId, moduleId: { not: null } },
select: { moduleId: true },
});
const existingModuleIds = new Set(existing.map((n) => n.moduleId as string));
const newModules = modules.filter((m) => !existingModuleIds.has(m.id));
if (newModules.length === 0) return getMap(mapId);
const byRack = new Map<string, typeof modules>();
for (const mod of newModules) {
if (!byRack.has(mod.rackId)) byRack.set(mod.rackId, []);
byRack.get(mod.rackId)!.push(mod);
}
const NODE_W = 200;
const NODE_H = 80;
const COL_GAP = 260;
const ROW_GAP = 110;
const nodesToCreate: Array<{
mapId: string;
label: string;
nodeType: NodeType;
positionX: number;
positionY: number;
moduleId: string;
}> = [];
let colIdx = 0;
for (const rackModules of byRack.values()) {
rackModules.forEach((mod, rowIdx) => {
nodesToCreate.push({
mapId,
label: mod.name,
nodeType: 'DEVICE' as NodeType,
positionX: colIdx * (NODE_W + COL_GAP),
positionY: rowIdx * (NODE_H + ROW_GAP),
moduleId: mod.id,
});
});
colIdx++;
}
await prisma.serviceNode.createMany({ data: nodesToCreate });
return getMap(mapId);
}
export async function updateNode(
id: string,
data: Partial<{
label: string;
positionX: number;
positionY: number;
metadata: string;
color: string;
icon: string;
moduleId: string | null;
}>
) {
const existing = await prisma.serviceNode.findUnique({ where: { id } });
if (!existing) throw new AppError('Node not found', 404, 'NOT_FOUND');
return prisma.serviceNode.update({ where: { id }, data, include: { module: true } });
}
export async function deleteNode(id: string) {
const existing = await prisma.serviceNode.findUnique({ where: { id } });
if (!existing) throw new AppError('Node not found', 404, 'NOT_FOUND');
return prisma.serviceNode.delete({ where: { id } });
}
// ---- Edges ----
export async function addEdge(
mapId: string,
data: {
sourceId: string;
targetId: string;
label?: string;
edgeType?: string;
animated?: boolean;
metadata?: string;
}
) {
await getMap(mapId);
return prisma.serviceEdge.create({ data: { mapId, ...data } });
}
export async function updateEdge(
id: string,
data: Partial<{ label: string; edgeType: string; animated: boolean; metadata: string }>
) {
const existing = await prisma.serviceEdge.findUnique({ where: { id } });
if (!existing) throw new AppError('Edge not found', 404, 'NOT_FOUND');
return prisma.serviceEdge.update({ where: { id }, data });
}
export async function deleteEdge(id: string) {
const existing = await prisma.serviceEdge.findUnique({ where: { id } });
if (!existing) throw new AppError('Edge not found', 404, 'NOT_FOUND');
return prisma.serviceEdge.delete({ where: { id } });
}

View File

@@ -0,0 +1,147 @@
import { prisma } from '../lib/prisma';
import { AppError } from '../types/index';
import { MODULE_PORT_DEFAULTS, MODULE_U_DEFAULTS, type ModuleType, type PortType } from '../lib/constants';
const moduleInclude = {
ports: {
orderBy: { portNumber: 'asc' as const },
include: {
vlans: { include: { vlan: true } },
},
},
};
/** Check whether a U-range is occupied in a rack, optionally excluding one module (for moves). */
async function hasCollision(
rackId: string,
uPosition: number,
uSize: number,
excludeModuleId?: string
): Promise<boolean> {
const modules = await prisma.module.findMany({
where: { rackId, ...(excludeModuleId ? { id: { not: excludeModuleId } } : {}) },
select: { uPosition: true, uSize: true },
});
const occupied = new Set<number>();
for (const m of modules) {
for (let u = m.uPosition; u < m.uPosition + m.uSize; u++) occupied.add(u);
}
for (let u = uPosition; u < uPosition + uSize; u++) {
if (occupied.has(u)) return true;
}
return false;
}
export async function createModule(
rackId: string,
data: {
name: string;
type: ModuleType;
uPosition: number;
uSize?: number;
manufacturer?: string;
model?: string;
ipAddress?: string;
notes?: string;
portCount?: number;
portType?: PortType;
}
) {
const rack = await prisma.rack.findUnique({ where: { id: rackId } });
if (!rack) throw new AppError('Rack not found', 404, 'NOT_FOUND');
const uSize = data.uSize ?? MODULE_U_DEFAULTS[data.type] ?? 1;
if (data.uPosition < 1 || data.uPosition + uSize - 1 > rack.totalU) {
throw new AppError(
`Module does not fit within rack (U1U${rack.totalU})`,
400,
'OUT_OF_BOUNDS'
);
}
if (await hasCollision(rackId, data.uPosition, uSize)) {
throw new AppError('U-slot collision: another module occupies that space', 409, 'COLLISION');
}
const portCount = data.portCount ?? MODULE_PORT_DEFAULTS[data.type] ?? 0;
const portType: PortType = data.portType ?? 'ETHERNET';
return prisma.module.create({
data: {
rackId,
name: data.name,
type: data.type,
uPosition: data.uPosition,
uSize,
manufacturer: data.manufacturer,
model: data.model,
ipAddress: data.ipAddress,
notes: data.notes,
ports: {
create: Array.from({ length: portCount }, (_, i) => ({
portNumber: i + 1,
portType,
})),
},
},
include: moduleInclude,
});
}
export async function updateModule(
id: string,
data: Partial<{
name: string;
uPosition: number;
uSize: number;
manufacturer: string;
model: string;
ipAddress: string;
notes: string;
}>
) {
const existing = await prisma.module.findUnique({ where: { id } });
if (!existing) throw new AppError('Module not found', 404, 'NOT_FOUND');
const newPosition = data.uPosition ?? existing.uPosition;
const newSize = data.uSize ?? existing.uSize;
if (data.uPosition !== undefined || data.uSize !== undefined) {
const rack = await prisma.rack.findUnique({ where: { id: existing.rackId } });
if (!rack) throw new AppError('Rack not found', 404, 'NOT_FOUND');
if (newPosition < 1 || newPosition + newSize - 1 > rack.totalU) {
throw new AppError(
`Module does not fit within rack (U1U${rack.totalU})`,
400,
'OUT_OF_BOUNDS'
);
}
if (await hasCollision(existing.rackId, newPosition, newSize, id)) {
throw new AppError('U-slot collision', 409, 'COLLISION');
}
}
return prisma.module.update({ where: { id }, data, include: moduleInclude });
}
export async function deleteModule(id: string) {
const existing = await prisma.module.findUnique({ where: { id } });
if (!existing) throw new AppError('Module not found', 404, 'NOT_FOUND');
return prisma.module.delete({ where: { id } });
}
export async function getModulePorts(id: string) {
const existing = await prisma.module.findUnique({ where: { id } });
if (!existing) throw new AppError('Module not found', 404, 'NOT_FOUND');
return prisma.port.findMany({
where: { moduleId: id },
orderBy: { portNumber: 'asc' },
include: { vlans: { include: { vlan: true } } },
});
}

View File

@@ -0,0 +1,47 @@
import { prisma } from '../lib/prisma';
import { AppError } from '../types/index';
import type { VlanMode } from '../lib/constants';
const portInclude = {
vlans: { include: { vlan: true } },
};
export async function updatePort(
id: string,
data: {
label?: string;
mode?: VlanMode;
nativeVlan?: number | null;
notes?: string;
vlans?: Array<{ vlanId: string; tagged: boolean }>;
}
) {
const existing = await prisma.port.findUnique({ where: { id } });
if (!existing) throw new AppError('Port not found', 404, 'NOT_FOUND');
const { vlans: vlanAssignments, ...portData } = data;
return prisma.$transaction(async (tx) => {
await tx.port.update({ where: { id }, data: portData });
if (vlanAssignments !== undefined) {
if (vlanAssignments.length > 0) {
const vlanIds = vlanAssignments.map((v) => v.vlanId);
const found = await tx.vlan.findMany({ where: { id: { in: vlanIds } } });
if (found.length !== vlanIds.length) {
throw new AppError('One or more VLANs not found', 404, 'VLAN_NOT_FOUND');
}
}
await tx.portVlan.deleteMany({ where: { portId: id } });
if (vlanAssignments.length > 0) {
await tx.portVlan.createMany({
data: vlanAssignments.map(({ vlanId, tagged }) => ({ portId: id, vlanId, tagged })),
});
}
}
return tx.port.findUnique({ where: { id }, include: portInclude });
});
}

View File

@@ -0,0 +1,59 @@
import { prisma } from '../lib/prisma';
import { AppError } from '../types/index';
// Full include shape used across all rack queries
const rackInclude = {
modules: {
orderBy: { uPosition: 'asc' as const },
include: {
ports: {
orderBy: { portNumber: 'asc' as const },
include: {
vlans: {
include: { vlan: true },
},
},
},
},
},
};
export async function listRacks() {
return prisma.rack.findMany({
orderBy: { displayOrder: 'asc' },
include: rackInclude,
});
}
export async function getRack(id: string) {
const rack = await prisma.rack.findUnique({ where: { id }, include: rackInclude });
if (!rack) throw new AppError('Rack not found', 404, 'NOT_FOUND');
return rack;
}
export async function createRack(data: {
name: string;
totalU?: number;
location?: string;
displayOrder?: number;
}) {
// Auto-assign displayOrder to end of list if not provided
if (data.displayOrder === undefined) {
const last = await prisma.rack.findFirst({ orderBy: { displayOrder: 'desc' } });
data.displayOrder = last ? last.displayOrder + 1 : 0;
}
return prisma.rack.create({ data, include: rackInclude });
}
export async function updateRack(
id: string,
data: Partial<{ name: string; totalU: number; location: string; displayOrder: number }>
) {
await getRack(id); // throws 404 if missing
return prisma.rack.update({ where: { id }, data, include: rackInclude });
}
export async function deleteRack(id: string) {
await getRack(id);
return prisma.rack.delete({ where: { id } });
}

View File

@@ -0,0 +1,37 @@
import { prisma } from '../lib/prisma';
import { AppError } from '../types/index';
export async function listVlans() {
return prisma.vlan.findMany({ orderBy: { vlanId: 'asc' } });
}
export async function createVlan(data: {
vlanId: number;
name: string;
description?: string;
color?: string;
}) {
const existing = await prisma.vlan.findUnique({ where: { vlanId: data.vlanId } });
if (existing) throw new AppError(`VLAN ID ${data.vlanId} already exists`, 409, 'DUPLICATE');
if (data.vlanId < 1 || data.vlanId > 4094) {
throw new AppError('VLAN ID must be between 1 and 4094', 400, 'INVALID_VLAN_ID');
}
return prisma.vlan.create({ data });
}
export async function updateVlan(
id: string,
data: Partial<{ name: string; description: string; color: string }>
) {
const existing = await prisma.vlan.findUnique({ where: { id } });
if (!existing) throw new AppError('VLAN not found', 404, 'NOT_FOUND');
return prisma.vlan.update({ where: { id }, data });
}
export async function deleteVlan(id: string) {
const existing = await prisma.vlan.findUnique({ where: { id } });
if (!existing) throw new AppError('VLAN not found', 404, 'NOT_FOUND');
return prisma.vlan.delete({ where: { id } });
}

37
server/types/index.ts Normal file
View File

@@ -0,0 +1,37 @@
import { Request } from 'express';
// ---- Error handling ----
export class AppError extends Error {
statusCode: number;
code?: string;
constructor(message: string, statusCode: number, code?: string) {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code;
}
}
// ---- API response shape ----
export interface ApiResponse<T = unknown> {
data: T | null;
error: string | null;
meta?: Record<string, unknown>;
}
export function ok<T>(data: T, meta?: Record<string, unknown>): ApiResponse<T> {
return { data, error: null, ...(meta ? { meta } : {}) };
}
export function err(message: string, meta?: Record<string, unknown>): ApiResponse<null> {
return { data: null, error: message, ...(meta ? { meta } : {}) };
}
// ---- Augmented request ----
export interface AuthenticatedRequest extends Request {
user: { sub: string };
}

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"declaration": false
},
"include": ["server/**/*", "scripts/**/*", "prisma/seed.ts"],
"exclude": ["node_modules", "dist", "client"]
}