Initial scaffold: full-stack RackMapper application

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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