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:
17
.env.example
Normal file
17
.env.example
Normal 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
43
.gitignore
vendored
Normal 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
7
.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
31
Dockerfile
Normal file
31
Dockerfile
Normal 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
13
client/index.html
Normal 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
3079
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
client/package.json
Normal file
37
client/package.json
Normal 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
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
53
client/src/App.tsx
Normal file
53
client/src/App.tsx
Normal 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
196
client/src/api/client.ts
Normal 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 };
|
||||
107
client/src/components/auth/LoginPage.tsx
Normal file
107
client/src/components/auth/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
client/src/components/auth/ProtectedRoute.tsx
Normal file
17
client/src/components/auth/ProtectedRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
142
client/src/components/mapper/MapToolbar.tsx
Normal file
142
client/src/components/mapper/MapToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
270
client/src/components/mapper/ServiceMapper.tsx
Normal file
270
client/src/components/mapper/ServiceMapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
client/src/components/mapper/nodes/ApiNode.tsx
Normal file
24
client/src/components/mapper/nodes/ApiNode.tsx
Normal 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';
|
||||
18
client/src/components/mapper/nodes/DatabaseNode.tsx
Normal file
18
client/src/components/mapper/nodes/DatabaseNode.tsx
Normal 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';
|
||||
56
client/src/components/mapper/nodes/DeviceNode.tsx
Normal file
56
client/src/components/mapper/nodes/DeviceNode.tsx
Normal 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';
|
||||
18
client/src/components/mapper/nodes/ExternalNode.tsx
Normal file
18
client/src/components/mapper/nodes/ExternalNode.tsx
Normal 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';
|
||||
18
client/src/components/mapper/nodes/FirewallNode.tsx
Normal file
18
client/src/components/mapper/nodes/FirewallNode.tsx
Normal 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';
|
||||
18
client/src/components/mapper/nodes/LBNode.tsx
Normal file
18
client/src/components/mapper/nodes/LBNode.tsx
Normal 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';
|
||||
21
client/src/components/mapper/nodes/NoteNode.tsx
Normal file
21
client/src/components/mapper/nodes/NoteNode.tsx
Normal 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';
|
||||
25
client/src/components/mapper/nodes/ServiceNode.tsx
Normal file
25
client/src/components/mapper/nodes/ServiceNode.tsx
Normal 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';
|
||||
18
client/src/components/mapper/nodes/UserNode.tsx
Normal file
18
client/src/components/mapper/nodes/UserNode.tsx
Normal 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';
|
||||
21
client/src/components/mapper/nodes/VlanNode.tsx
Normal file
21
client/src/components/mapper/nodes/VlanNode.tsx
Normal 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';
|
||||
227
client/src/components/modals/AddModuleModal.tsx
Normal file
227
client/src/components/modals/AddModuleModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
client/src/components/modals/AddRackModal.tsx
Normal file
109
client/src/components/modals/AddRackModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
client/src/components/modals/ModuleEditPanel.tsx
Normal file
146
client/src/components/modals/ModuleEditPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
267
client/src/components/modals/PortConfigModal.tsx
Normal file
267
client/src/components/modals/PortConfigModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
client/src/components/rack/DevicePalette.tsx
Normal file
57
client/src/components/rack/DevicePalette.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
client/src/components/rack/ModuleBlock.tsx
Normal file
148
client/src/components/rack/ModuleBlock.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
106
client/src/components/rack/RackColumn.tsx
Normal file
106
client/src/components/rack/RackColumn.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
client/src/components/rack/RackPlanner.tsx
Normal file
56
client/src/components/rack/RackPlanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
client/src/components/rack/RackSlot.tsx
Normal file
46
client/src/components/rack/RackSlot.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
client/src/components/rack/RackToolbar.tsx
Normal file
84
client/src/components/rack/RackToolbar.tsx
Normal 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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
client/src/components/ui/Badge.tsx
Normal file
32
client/src/components/ui/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
client/src/components/ui/Button.tsx
Normal file
61
client/src/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
client/src/components/ui/ConfirmDialog.tsx
Normal file
36
client/src/components/ui/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
client/src/components/ui/Modal.tsx
Normal file
90
client/src/components/ui/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
client/src/components/ui/Skeleton.tsx
Normal file
29
client/src/components/ui/Skeleton.tsx
Normal 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
30
client/src/index.css
Normal 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 */
|
||||
}
|
||||
}
|
||||
76
client/src/lib/constants.ts
Normal file
76
client/src/lib/constants.ts
Normal 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
25
client/src/lib/utils.ts
Normal 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
15
client/src/main.tsx
Normal 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>
|
||||
);
|
||||
37
client/src/store/useAuthStore.ts
Normal file
37
client/src/store/useAuthStore.ts
Normal 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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
58
client/src/store/useMapStore.ts
Normal file
58
client/src/store/useMapStore.ts
Normal 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 }),
|
||||
}));
|
||||
90
client/src/store/useRackStore.ts
Normal file
90
client/src/store/useRackStore.ts
Normal 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
129
client/src/types/index.ts
Normal 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
18
client/tailwind.config.ts
Normal 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
21
client/tsconfig.json
Normal 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
11
client/tsconfig.node.json
Normal 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
19
client/vite.config.ts
Normal 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
26
docker-compose.yml
Normal 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
5262
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
package.json
Normal file
50
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
103
prisma/migrations/20260322024601_init/migration.sql
Normal file
103
prisma/migrations/20260322024601_init/migration.sql
Normal 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");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
116
prisma/schema.prisma
Normal 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
19
prisma/seed.ts
Normal 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
20
scripts/hashPassword.ts
Normal 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
63
server/index.ts
Normal 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
44
server/lib/constants.ts
Normal 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
14
server/lib/prisma.ts
Normal 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;
|
||||
}
|
||||
26
server/middleware/authMiddleware.ts
Normal file
26
server/middleware/authMiddleware.ts
Normal 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'));
|
||||
}
|
||||
}
|
||||
22
server/middleware/errorHandler.ts
Normal file
22
server/middleware/errorHandler.ts
Normal 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
60
server/routes/auth.ts
Normal 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
28
server/routes/edges.ts
Normal 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
51
server/routes/modules.ts
Normal 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
33
server/routes/nodes.ts
Normal 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
21
server/routes/ports.ts
Normal 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
83
server/routes/racks.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
97
server/routes/serviceMap.ts
Normal file
97
server/routes/serviceMap.ts
Normal 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
49
server/routes/vlans.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
170
server/services/mapService.ts
Normal file
170
server/services/mapService.ts
Normal 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 } });
|
||||
}
|
||||
147
server/services/moduleService.ts
Normal file
147
server/services/moduleService.ts
Normal 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 (U1–U${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 (U1–U${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 } } },
|
||||
});
|
||||
}
|
||||
47
server/services/portService.ts
Normal file
47
server/services/portService.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
59
server/services/rackService.ts
Normal file
59
server/services/rackService.ts
Normal 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 } });
|
||||
}
|
||||
37
server/services/vlanService.ts
Normal file
37
server/services/vlanService.ts
Normal 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
37
server/types/index.ts
Normal 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
18
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user