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

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,
},
});