RREADME.md: rewrite from TBD to full user-facing README with features, quick-start, environment variable table, and tech stack. AGENTS.md: - Auth: ADMIN_PASSWORD_HASH → ADMIN_PASSWORD (plain text); remove bcryptjs - Schema: replace enum blocks with String fields + SQLite/Prisma enum warning - Repo structure: add vlans/, ContextMenu.tsx, NodeEditModal.tsx, /vlans route - API routes: add POST /modules/:id/move - Service Mapper canvas features: update to reflect implemented context menus, NodeEditModal, and edge type/animation toggle - Commands: remove hashPassword.ts entry - Docker env block: update to ADMIN_PASSWORD - Key Decision #3: updated to plain-text password rationale Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
24 KiB
AGENTS.md — RackMapper
Project: RackMapper — Web-based Network Rack Planner & Service Mapper
Stack: Node.js · React · TypeScript · SQLite · Prisma ORM · Docker
Audience: This file is the primary briefing for AI coding agents and senior devs onboarding to the project.
Project Overview
RackMapper is a two-module, dark-mode, full-stack web application for network infrastructure management:
-
Rack Planner — A visual, drag-and-drop rack diagram builder. Users populate standard 19" racks (42U default) with modular devices (switches, aggregate switches, modems, routers, NAS units, PDUs, patch panels, blanks, etc.), resize devices to span multiple U-spaces, and configure networking devices through interactive port modals with VLAN assignment. Multiple racks are displayed side-by-side when more than one rack exists.
-
Service Mapper — A node-graph canvas (React Flow) where users visually map service interactions, traffic flows, and dependencies between infrastructure components, applications, and external services. Device nodes can be auto-populated from Rack Module data and then freely repositioned on the canvas.
All data is persisted to SQLite via Prisma ORM with a migration-first workflow. The app is web-facing and protected by a single hardcoded admin account configured via Docker/Unraid environment variables.
Tech Stack
| Layer | Technology |
|---|---|
| Frontend | React 18 + TypeScript + Vite |
| Styling | Tailwind CSS (dark-mode first, darkMode: 'class') |
| State Management | Zustand |
| Node Graph (Service Mapper) | React Flow (@xyflow/react v12+) |
| Drag & Drop (Rack Planner) | @dnd-kit/core + @dnd-kit/sortable |
| Backend | Node.js + Express (REST API) |
| ORM / DB | Prisma ORM + SQLite (better-sqlite3) |
| Auth | JWT via jsonwebtoken, httpOnly cookie strategy |
| Export | html-to-image or dom-to-svg for PNG export |
| Containerization | Docker (single container — Node serves Vite static build + API) |
| Testing | Vitest + React Testing Library |
| Linting/Formatting | ESLint + Prettier |
Repository Structure
/
├── AGENTS.md ← this file
├── README.md
├── docker-compose.yml
├── Dockerfile
├── .env.example
├── prisma/
│ ├── schema.prisma
│ └── migrations/
├── server/
│ ├── index.ts ← Express entry point
│ ├── routes/
│ │ ├── auth.ts ← POST /api/auth/login, POST /api/auth/logout
│ │ ├── racks.ts
│ │ ├── modules.ts
│ │ ├── ports.ts
│ │ ├── vlans.ts
│ │ └── serviceMap.ts
│ ├── middleware/
│ │ ├── authMiddleware.ts ← JWT verification, applied to all /api/* except /api/auth
│ │ └── errorHandler.ts
│ ├── services/ ← Business logic, all Prisma queries live here
│ │ ├── rackService.ts
│ │ ├── moduleService.ts
│ │ ├── portService.ts
│ │ ├── vlanService.ts
│ │ └── mapService.ts
│ └── lib/
│ └── prisma.ts ← PrismaClient singleton
├── client/
│ ├── src/
│ │ ├── main.tsx
│ │ ├── App.tsx ← Router root; / → /rack, /rack, /map, /vlans
│ │ ├── store/
│ │ │ ├── useAuthStore.ts
│ │ │ ├── useRackStore.ts
│ │ │ └── useMapStore.ts
│ │ ├── components/
│ │ │ ├── auth/ ← LoginPage, ProtectedRoute
│ │ │ ├── rack/ ← Rack Planner components
│ │ │ ├── mapper/ ← Service Mapper components
│ │ │ │ ├── nodes/ ← Custom React Flow node components
│ │ │ │ ├── ContextMenu.tsx
│ │ │ │ └── NodeEditModal.tsx
│ │ │ ├── vlans/ ← VlanPage (full CRUD at /vlans)
│ │ │ ├── modals/ ← All modal components
│ │ │ └── ui/ ← Shared primitives (Button, Badge, Tooltip, etc.)
│ │ ├── hooks/
│ │ ├── api/
│ │ │ └── client.ts ← ALL fetch calls go through here, never in components
│ │ ├── types/
│ │ │ └── index.ts ← Shared TypeScript interfaces
│ │ └── lib/
│ │ └── constants.ts ← Device types, U-height defaults, VLAN ranges
│ └── index.html
└── tests/
├── unit/
└── integration/
Authentication
Strategy
- Single admin account — no registration UI, no user table in the database
- Credentials are injected at runtime via Docker environment variables:
ADMIN_USERNAMEandADMIN_PASSWORD - Password is stored as plain text in the environment variable — change it by updating the Docker env var and restarting the container
- JWT issued on login, stored in an
httpOnly,SameSite=Strict,Securecookie — neverlocalStorage - All
/api/*routes except/api/auth/loginare protected byauthMiddleware.ts - Token expiry:
8h(configurable viaJWT_EXPIRYenv var)
Auth Flow
POST /api/auth/login { username, password }
→ verify username === ADMIN_USERNAME && password === ADMIN_PASSWORD
→ sign JWT { sub: 'admin', iat, exp }
→ Set-Cookie: token=<jwt>; HttpOnly; SameSite=Strict; Secure; Path=/
→ 200 OK { success: true }
POST /api/auth/logout
→ Clear cookie
→ 200 OK
GET /api/auth/me
→ Returns { authenticated: true } if valid cookie, else 401
Frontend Auth
ProtectedRoutecomponent wraps all app routes — redirects to/loginif not authenticateduseAuthStore(Zustand) holds{ isAuthenticated, loading }state- On app mount, call
GET /api/auth/meto verify session — show full-screen loader during check - Login page: centered card, dark-mode, RackMapper logo/wordmark, username + password fields, submit button, error toast on failure
- No "forgot password" — admin resets by updating the Docker env var
Environment Variables
ADMIN_USERNAME=admin
ADMIN_PASSWORD=yourpassword # plain text; change by updating env var + restarting container
JWT_SECRET=your-secret-here # min 32 chars, random
JWT_EXPIRY=8h
DATABASE_URL=file:./data/rackmapper.db
PORT=3001
NODE_ENV=production
Database Schema (Prisma)
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 // 1-indexed from top (U1 = topmost slot)
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") // ETHERNET | SFP | SFP_PLUS | QSFP | CONSOLE | UPLINK
mode String @default("ACCESS") // 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])
}
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 // 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?
}
⚠️ SQLite / Prisma limitation: Prisma does not support
enumtypes with the SQLite connector. All enum-like fields (type,portType,mode,nodeType) are stored asString. Valid values are defined as TypeScript string literal unions inserver/lib/constants.tsand mirrored inclient/src/types/index.ts. Do not add Prismaenumdeclarations to this schema.
Migration workflow:
npx prisma migrate dev --name <descriptive_name> # dev: creates + applies migration
npx prisma migrate deploy # production/Docker: applies pending
npx prisma generate # regenerate client after any schema change
npx prisma studio # visual DB browser at localhost:5555
⚠️ Never manually edit migration files after they are applied. Create a new migration for every schema change. Never use
prisma db push— it skips migration history.
Seed
prisma/seed.ts starts blank — no default VLANs, no sample racks. It should only:
- Log a confirmation message that the DB is ready
- Be a no-op safe to run multiple times
Do not pre-seed VLANs, racks, or modules unless the user explicitly requests it.
API Routes
Auth
| Method | Endpoint | Auth Required | Description |
|---|---|---|---|
| POST | /api/auth/login |
No | Login, sets httpOnly cookie |
| POST | /api/auth/logout |
No | Clears cookie |
| GET | /api/auth/me |
Yes | Session check |
Rack Planner
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/racks |
List all racks (ordered by displayOrder) |
| POST | /api/racks |
Create a rack |
| GET | /api/racks/:id |
Get rack with all modules and ports |
| PUT | /api/racks/:id |
Update rack metadata or displayOrder |
| DELETE | /api/racks/:id |
Delete rack (cascades) |
| POST | /api/racks/:id/modules |
Add a module to a rack |
| PUT | /api/modules/:id |
Update module (position, size, metadata) |
| POST | /api/modules/:id/move |
Move module to different rack/position (collision-validated) |
| DELETE | /api/modules/:id |
Remove a module |
| GET | /api/modules/:id/ports |
Get ports for a module |
| PUT | /api/ports/:id |
Update port config (VLAN, mode, label) |
| GET | /api/vlans |
List all VLANs |
| POST | /api/vlans |
Create a VLAN |
| PUT | /api/vlans/:id |
Update VLAN |
| DELETE | /api/vlans/:id |
Delete VLAN |
Service Mapper
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/maps |
List all service maps |
| POST | /api/maps |
Create a new map |
| GET | /api/maps/:id |
Get full map (nodes + edges) |
| PUT | /api/maps/:id |
Update map metadata |
| DELETE | /api/maps/:id |
Delete map |
| POST | /api/maps/:id/nodes |
Add node |
| POST | /api/maps/:id/populate |
Auto-populate nodes from rack modules |
| PUT | /api/nodes/:id |
Update node (position, label, meta) |
| DELETE | /api/nodes/:id |
Remove node |
| POST | /api/maps/:id/edges |
Add edge |
| PUT | /api/edges/:id |
Update edge |
| DELETE | /api/edges/:id |
Remove edge |
Module 1 — Rack Planner
Visual Design
- Background:
#0f1117(deep slate), rack column:#1e2433with silver/chrome border - Each rack rendered as a vertical U-slot grid; U1 is the topmost slot
- Modules render as colored blocks spanning their
uSize; color driven byModuleType - Empty U-slots show faint dashed outline as drop targets
- Each networking module shows: label, type badge, IP (if set), and a compact row of port indicator dots
Multi-Rack Layout
- Single rack: centered full-height column
- 2+ racks: side-by-side horizontal flex layout, each rack same height, scrollable horizontally if > 3
- Rack order controlled by
displayOrderfield; user can reorder via drag handle on rack header - "Add Rack" button always visible in the top toolbar
Interaction Model
- Add module: sidebar device-type palette → drag to U-slot, or click slot to place with type selector
- Resize module: drag handle at bottom edge → expands downward; validate no overlap before commit
- Move module: drag to new U-position in same rack or different rack; validate collision
- Edit module: click → slide-out panel (name, IP, manufacturer, model, notes, uSize)
- Delete module: trash icon on hover → confirm tooltip
Port Configuration Modal
Triggered by clicking any port indicator dot on applicable modules.
Modal fields:
- Port number + label (editable)
- Port type (read-only, set at module creation)
- Mode:
Access/Trunk/Hybrid - Native VLAN: dropdown (populated from VLAN list)
- Tagged VLANs: multi-select (visible only in Trunk/Hybrid mode)
- Notes field
- Inline VLAN quick-create (add new VLAN ID + name without leaving modal)
- Save / Cancel
Devices with interactive ports: SWITCH, AGGREGATE_SWITCH, ROUTER, FIREWALL, PATCH_PANEL, AP, MODEM, NAS, PDU, (SERVER optionally has ports) (Ability to add custom rack items) (Ability to assign number of ports and what type they are to network devices) (Constants are good for rapid deployment bu all devices beed to be fully configurable)
Port Count & U-Size Defaults (constants.ts)
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,
};
Ports are auto-generated when a Module is created based on these defaults. Users should not need to manually add ports.
PNG Export
- Export button in rack toolbar: captures the entire rack view (all side-by-side racks) as a single PNG
- Use
html-to-image(toPng) targeting the rack canvas container ref - Filename:
rackmapper-rack-<timestamp>.png - Show a brief "Exporting..." toast during capture
Module 2 — Service Mapper
Library
Use React Flow (@xyflow/react v12+). Do not substitute with D3, Cytoscape, or Dagre without approval.
Device-to-Module Linking
DeviceNodecarries an optionalmoduleIdfield linking it to a physicalModulein the rack- When
moduleIdis set, the node displays the module's name, type badge, and IP address pulled from the linked record - Clicking a linked
DeviceNodeopens a read-only info panel showing full module detail with a "View in Rack" button that navigates to/rackand highlights that module - Nodes are fully draggable regardless of module linkage — position on the canvas is independent of rack position
Auto-Populate from Rack
POST /api/maps/:id/populate fetches all Modules across all racks and creates a DeviceNode for each one that doesn't already have a node in the map. Nodes are arranged in a grid layout (column per rack, rows per module in U-order). This is a one-way import — subsequent module additions must be manually added or re-triggered.
Custom Node Types
Each NodeType has a custom React Flow node component in client/src/components/mapper/nodes/:
| Node Type | Component | Visual Style |
|---|---|---|
DEVICE |
DeviceNode.tsx |
Rack icon; accent border color by ModuleType; shows IP if linked |
SERVICE |
ServiceNode.tsx |
Rounded card, colored left border, icon + label |
DATABASE |
DatabaseNode.tsx |
Cylinder icon, dark teal accent |
API |
ApiNode.tsx |
Badge style with optional method tag (REST/gRPC/WS) |
EXTERNAL |
ExternalNode.tsx |
Dashed border, cloud icon |
VLAN |
VlanNode.tsx |
Colored square matching VLAN color swatch |
FIREWALL |
FirewallNode.tsx |
Shield icon, red accent |
LOAD_BALANCER |
LBNode.tsx |
Scale/balance icon |
USER |
UserNode.tsx |
Person icon, neutral gray |
NOTE |
NoteNode.tsx |
Sticky note style, no handles, free text |
Canvas Features
- Minimap (bottom-right, dark themed)
- Controls: zoom in/out, fit view, lock toggle
- Background: dot grid pattern (
#1e2433dots) - Snap-to-grid: 15px
- Multi-select: Shift+click or drag-select box
- Edge types:
smoothstep(default),straight,bezier— selectable per edge - Animated edges for "active traffic" flows (toggle per edge)
- Right-click canvas → context menu: Add Node (all 10 types placed at cursor position)
- Right-click node → Edit (label/colour/module link), Duplicate, Delete
- Right-click edge → Toggle Animation, set edge type (bezier/smooth/step/straight), Delete
- Double-click node →
NodeEditModal(label, accent colour swatch + custom picker, rack module link)
Persistence
Debounce all position/data changes by 500ms after drag end — PATCH to API. Never save on every pixel move. Use React Flow's onNodesChange and onEdgesChange callbacks.
PNG Export
- "Export PNG" button in Service Mapper toolbar
- Use
html-to-image(toPng) on the React Flow container - Temporarily hide minimap and controls panel during capture, restore after
- Filename:
rackmapper-map-<mapName>-<timestamp>.png
Code Style & Conventions
TypeScript
"strict": truein tsconfig — no exceptions- No
any— useunknownwith type guards if necessary - All shared types in
client/src/types/index.tsandserver/types/index.ts - Prefer
interfacefor object shapes,typefor unions/aliases
React
- Functional components only — no class components
- Custom hooks for all non-trivial stateful logic (
useprefix, in/hooks) - All API calls go through
client/src/api/client.ts— never rawfetch/axiosin components - Prefer small, focused components; avoid prop drilling beyond 2 levels — lift to Zustand
Styling
- Tailwind CSS only — no inline styles, no CSS modules unless strictly required
- Dark mode is the only supported mode
- Use
cn()utility (clsx + tailwind-merge) for conditional class composition - Color palette:
slate-900bg ·slate-800surface ·slate-700border ·blue-500accent ·red-500destructive
Backend
- Route handlers are thin — all business logic in
server/services/ - All Prisma queries in service files — never inline in route handlers
- Throw typed
AppErrorinstances; catch inerrorHandlermiddleware - Consistent JSON response shape:
{ data, error, meta }
Commands
# Development
npm run dev # Vite + Node (concurrently)
npm run dev:client # Vite only
npm run dev:server # Nodemon server only
# Database
npx prisma migrate dev --name <name> # create + apply dev migration
npx prisma migrate deploy # apply in production / Docker
npx prisma generate # regenerate client after schema change
npx prisma studio # visual DB browser at localhost:5555
npx prisma db seed # run seed (no-op by default)
# Build
npm run build # Vite build + tsc check
npm run typecheck # tsc --noEmit — run before every PR
# Lint / Format
npm run lint
npm run lint:fix
npm run format # Prettier --write
# Tests
npm run test
npm run test:watch
npm run test -- path/to/file.test.ts
# Docker
docker compose up --build -d
docker compose down
docker compose logs -f
Docker / Unraid Configuration
docker-compose.yml environment block:
environment:
- NODE_ENV=production
- PORT=3001
- DATABASE_URL=file:./data/rackmapper.db
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=yourpassword # plain text
- JWT_SECRET=... # min 32 chars, random
- JWT_EXPIRY=8h
volumes:
- ./data:/app/data # persists SQLite file across container restarts
The Dockerfile should run npx prisma migrate deploy && node dist/index.js as the startup command to auto-apply any pending migrations on container start.
Agent Permissions
Allowed without asking
- Read any file
- Run
npm run lint,npm run typecheck,npm run format - Run
viteston a single file - Run
npx prisma generate - Edit files in
client/src/,server/,prisma/schema.prisma
Ask first
npm installor adding new dependenciesnpx prisma migrate dev(schema-changing migration)- Deleting files
- Modifying
docker-compose.ymlorDockerfile - Running
npm run build - Any
git pushor commit
Never do
- Edit migration files that have already been applied
- Commit secrets or
.envfiles - Hard-code credentials, IP addresses, or VLAN IDs
- Run
prisma db push— migrations only - Rewrite the entire repo in one diff — prefer targeted, minimal changes
Key Design Decisions
- SQLite over PostgreSQL — intentional for single-container Unraid deployment. No external DB process. Do not suggest migrating unless asked.
- httpOnly cookie auth — chosen over
localStoragefor XSS resistance on a web-facing deployment. Do not change tolocalStorage. - Single admin account via env vars — no user table, no registration. Password is plain text in
ADMIN_PASSWORD. Admin changes it by updating the Docker/Unraid env var and restarting the container. No bcrypt dependency. - U1 = top of rack — all U-position logic is 1-indexed from the top. Validate and render accordingly.
@dnd-kitoverreact-beautiful-dnd—react-beautiful-dndis unmaintained.- React Flow for Service Mapper — first-class TypeScript, custom node API, active maintenance. Do not swap.
- Zustand over Redux — intentional for this app's scope. Do not introduce Redux or Context API for global state.
- Ports auto-generated on Module creation — use
MODULE_PORT_DEFAULTS. Do not require manual port addition. - DeviceNode position is canvas-independent — linking a node to a Module does not constrain its canvas position.
- VLAN seed is blank — the user creates all VLANs manually. Do not pre-seed VLAN records.
Accessibility & UX Standards
- All modals: keyboard navigable (Tab order, Escape to close, Enter to confirm)
- All interactive elements:
aria-labelor visible label - Drag-and-drop: keyboard fallback (click-to-select, arrow key movement)
- Toast notifications via
sonnerfor all async success/error states — no silent failures - Destructive actions (delete rack, module, map) require a confirmation dialog, not just a toast
- Skeleton loaders (not spinners) for layout-heavy async views
- Login page must show a clear error message on failed auth — no vague "something went wrong"