Files
rack-planner/AGENTS.md
jason 84cd94a0f5 Update documentation to reflect current build state
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>
2026-03-21 22:07:58 -05:00

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:

  1. 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.

  2. 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_USERNAME and ADMIN_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, Secure cookie — never localStorage
  • All /api/* routes except /api/auth/login are protected by authMiddleware.ts
  • Token expiry: 8h (configurable via JWT_EXPIRY env 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

  • ProtectedRoute component wraps all app routes — redirects to /login if not authenticated
  • useAuthStore (Zustand) holds { isAuthenticated, loading } state
  • On app mount, call GET /api/auth/me to 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 enum types with the SQLite connector. All enum-like fields (type, portType, mode, nodeType) are stored as String. Valid values are defined as TypeScript string literal unions in server/lib/constants.ts and mirrored in client/src/types/index.ts. Do not add Prisma enum declarations 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: #1e2433 with 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 by ModuleType
  • 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 displayOrder field; 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

  • DeviceNode carries an optional moduleId field linking it to a physical Module in the rack
  • When moduleId is set, the node displays the module's name, type badge, and IP address pulled from the linked record
  • Clicking a linked DeviceNode opens a read-only info panel showing full module detail with a "View in Rack" button that navigates to /rack and 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 (#1e2433 dots)
  • 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": true in tsconfig — no exceptions
  • No any — use unknown with type guards if necessary
  • All shared types in client/src/types/index.ts and server/types/index.ts
  • Prefer interface for object shapes, type for unions/aliases

React

  • Functional components only — no class components
  • Custom hooks for all non-trivial stateful logic (use prefix, in /hooks)
  • All API calls go through client/src/api/client.ts — never raw fetch/axios in 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-900 bg · slate-800 surface · slate-700 border · blue-500 accent · red-500 destructive

Backend

  • Route handlers are thin — all business logic in server/services/
  • All Prisma queries in service files — never inline in route handlers
  • Throw typed AppError instances; catch in errorHandler middleware
  • 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 vitest on a single file
  • Run npx prisma generate
  • Edit files in client/src/, server/, prisma/schema.prisma

Ask first

  • npm install or adding new dependencies
  • npx prisma migrate dev (schema-changing migration)
  • Deleting files
  • Modifying docker-compose.yml or Dockerfile
  • Running npm run build
  • Any git push or commit

Never do

  • Edit migration files that have already been applied
  • Commit secrets or .env files
  • 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

  1. SQLite over PostgreSQL — intentional for single-container Unraid deployment. No external DB process. Do not suggest migrating unless asked.
  2. httpOnly cookie auth — chosen over localStorage for XSS resistance on a web-facing deployment. Do not change to localStorage.
  3. 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.
  4. U1 = top of rack — all U-position logic is 1-indexed from the top. Validate and render accordingly.
  5. @dnd-kit over react-beautiful-dndreact-beautiful-dnd is unmaintained.
  6. React Flow for Service Mapper — first-class TypeScript, custom node API, active maintenance. Do not swap.
  7. Zustand over Redux — intentional for this app's scope. Do not introduce Redux or Context API for global state.
  8. Ports auto-generated on Module creation — use MODULE_PORT_DEFAULTS. Do not require manual port addition.
  9. DeviceNode position is canvas-independent — linking a node to a Module does not constrain its canvas position.
  10. 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-label or visible label
  • Drag-and-drop: keyboard fallback (click-to-select, arrow key movement)
  • Toast notifications via sonner for 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"