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>
This commit is contained in:
2026-03-21 22:07:58 -05:00
parent bcb8a95fae
commit 84cd94a0f5
2 changed files with 152 additions and 103 deletions

152
AGENTS.md
View File

@@ -29,7 +29,7 @@ All data is persisted to SQLite via Prisma ORM with a migration-first workflow.
| 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` + `bcryptjs`, `httpOnly` cookie strategy |
| 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 |
@@ -72,7 +72,7 @@ All data is persisted to SQLite via Prisma ORM with a migration-first workflow.
├── client/
│ ├── src/
│ │ ├── main.tsx
│ │ ├── App.tsx ← Router root; / = login, /rack = planner, /map = mapper
│ │ ├── App.tsx ← Router root; / → /rack, /rack, /map, /vlans
│ │ ├── store/
│ │ │ ├── useAuthStore.ts
│ │ │ ├── useRackStore.ts
@@ -81,7 +81,10 @@ All data is persisted to SQLite via Prisma ORM with a migration-first workflow.
│ │ │ ├── auth/ ← LoginPage, ProtectedRoute
│ │ │ ├── rack/ ← Rack Planner components
│ │ │ ├── mapper/ ← Service Mapper components
│ │ │ │ ── nodes/ ← Custom React Flow node 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/
@@ -104,28 +107,17 @@ All data is persisted to SQLite via Prisma ORM with a migration-first workflow.
### 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_HASH`
- Password is stored as a `bcryptjs` hash (never plaintext) — generate the hash once with a seed script and store it in Unraid's Docker template as an environment variable
- 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)
### Hash Generation Utility
Provide a one-time script at `scripts/hashPassword.ts`:
```ts
// Usage: npx ts-node scripts/hashPassword.ts mypassword
import bcrypt from 'bcryptjs';
const hash = await bcrypt.hash(process.argv[2], 12);
console.log(hash); // paste this into ADMIN_PASSWORD_HASH env var
```
### Auth Flow
```
POST /api/auth/login { username, password }
→ verify username === ADMIN_USERNAME
→ bcrypt.compare(password, ADMIN_PASSWORD_HASH)
→ 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 }
@@ -150,7 +142,7 @@ GET /api/auth/me
```env
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=$2a$12$... # bcrypt hash, generated via scripts/hashPassword.ts
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
@@ -164,47 +156,32 @@ NODE_ENV=production
```prisma
model Rack {
id String @id @default(cuid())
name String
totalU Int @default(42)
location String?
displayOrder Int @default(0) // controls left-to-right order in side-by-side view
modules Module[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
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())
id String @id @default(cuid())
rackId String
rack Rack @relation(fields: [rackId], references: [id], onDelete: Cascade)
rack Rack @relation(fields: [rackId], references: [id], onDelete: Cascade)
name String
type ModuleType
uPosition Int // 1-indexed from top (U1 = topmost slot)
uSize Int @default(1)
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[] // reverse relation — nodes in maps that reference this module
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum ModuleType {
SWITCH
AGGREGATE_SWITCH
MODEM
ROUTER
NAS
PDU
PATCH_PANEL
SERVER
FIREWALL
AP
BLANK
OTHER
serviceNodes ServiceNode[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Port {
@@ -213,49 +190,32 @@ model Port {
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
portNumber Int
label String?
portType PortType @default(ETHERNET)
mode VlanMode @default(ACCESS)
portType String @default("ETHERNET") // ETHERNET | SFP | SFP_PLUS | QSFP | CONSOLE | UPLINK
mode String @default("ACCESS") // ACCESS | TRUNK | HYBRID
nativeVlan Int?
vlans PortVlan[]
notes String?
}
enum PortType {
ETHERNET
SFP
SFP_PLUS
QSFP
CONSOLE
UPLINK
}
enum VlanMode {
ACCESS
TRUNK
HYBRID
}
model Vlan {
id String @id @default(cuid())
vlanId Int @unique
name String
description String?
color String? // hex color for UI display
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)
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
@@ -267,35 +227,22 @@ model ServiceMap {
}
model ServiceNode {
id String @id @default(cuid())
id String @id @default(cuid())
mapId String
map ServiceMap @relation(fields: [mapId], references: [id], onDelete: Cascade)
map ServiceMap @relation(fields: [mapId], references: [id], onDelete: Cascade)
label String
nodeType NodeType
nodeType String // SERVICE | DATABASE | API | DEVICE | EXTERNAL | USER | VLAN | FIREWALL | LOAD_BALANCER | NOTE
positionX Float
positionY Float
metadata String? // JSON blob for arbitrary node-specific data
metadata String?
color String?
icon String?
moduleId String? // optional link to a physical Rack Module
module Module? @relation(fields: [moduleId], references: [id], onDelete: SetNull)
moduleId String?
module Module? @relation(fields: [moduleId], references: [id], onDelete: SetNull)
sourceEdges ServiceEdge[] @relation("EdgeSource")
targetEdges ServiceEdge[] @relation("EdgeTarget")
}
enum NodeType {
SERVICE
DATABASE
API
DEVICE // links to a Module via moduleId
EXTERNAL
USER
VLAN
FIREWALL
LOAD_BALANCER
NOTE
}
model ServiceEdge {
id String @id @default(cuid())
mapId String
@@ -311,6 +258,8 @@ model ServiceEdge {
}
```
> ⚠️ **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:**
```bash
npx prisma migrate dev --name <descriptive_name> # dev: creates + applies migration
@@ -354,6 +303,7 @@ Do not pre-seed VLANs, racks, or modules unless the user explicitly requests it.
| 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) |
@@ -510,9 +460,10 @@ Each `NodeType` has a custom React Flow node component in `client/src/components
- 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 (type picker with icons)
- Right-click node → Edit, Duplicate, Delete, Link to Module
- Right-click edge → Edit Label, Change Type, Toggle Animation, Delete
- 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
@@ -567,9 +518,6 @@ npm run dev # Vite + Node (concurrently)
npm run dev:client # Vite only
npm run dev:server # Nodemon server only
# Auth
npx ts-node scripts/hashPassword.ts mypassword # generate bcrypt hash for env var
# Database
npx prisma migrate dev --name <name> # create + apply dev migration
npx prisma migrate deploy # apply in production / Docker
@@ -608,8 +556,8 @@ environment:
- PORT=3001
- DATABASE_URL=file:./data/rackmapper.db
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD_HASH=$2a$12$... # bcrypt hash
- JWT_SECRET=... # min 32 chars
- ADMIN_PASSWORD=yourpassword # plain text
- JWT_SECRET=... # min 32 chars, random
- JWT_EXPIRY=8h
volumes:
- ./data:/app/data # persists SQLite file across container restarts
@@ -652,7 +600,7 @@ The Dockerfile should run `npx prisma migrate deploy && node dist/index.js` as t
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. Admin resets password by updating the Unraid Docker template env var and restarting the container.
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-dnd`** — `react-beautiful-dnd` is unmaintained.
6. **React Flow for Service Mapper** — first-class TypeScript, custom node API, active maintenance. Do not swap.

View File

@@ -1 +1,102 @@
TBD
# RackMapper
A self-hosted, dark-mode web app for visualising and managing network rack infrastructure. Built for Unraid / Docker single-container deployment.
## Features
### Rack Planner (`/rack`)
- Drag-and-drop module placement from a device palette onto U-slots
- Drag modules between racks or reorder racks via header grip
- Resize modules by dragging the bottom handle
- Click any module to edit name, IP, manufacturer, model, notes, uSize
- Port indicator dots — click any dot to open the port configuration modal
- Set mode (Access / Trunk / Hybrid), native VLAN, tagged VLANs
- Quick-create VLANs without leaving the modal
- Export the full rack view as PNG
### Service Mapper (`/map`)
- React Flow canvas for mapping service dependencies and traffic flows
- Right-click canvas → add any node type at cursor position
- Right-click node → Edit, Duplicate, Delete
- Right-click edge → Toggle animation, change edge type, Delete
- Double-click a node → edit label, accent colour, and rack module link
- Auto-populate nodes from all rack modules ("Import Rack" button)
- Connect nodes by dragging from handles; Delete key removes selected items
- Minimap, zoom controls, snap-to-grid (15px), PNG export
### VLAN Management (`/vlans`)
- Create, edit, and delete VLANs with ID, name, description, and colour
- VLANs defined here are available in all port configuration modals
---
## Quick Start (Docker Compose)
**1. Create a `docker-compose.yml`:**
```yaml
version: '3.8'
services:
rackmapper:
image: rackmapper
build: .
container_name: rackmapper
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- PORT=3001
- DATABASE_URL=file:./data/rackmapper.db
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=yourpassword
- JWT_SECRET=your-random-secret-min-32-chars
- JWT_EXPIRY=8h
volumes:
- ./data:/app/data
restart: unless-stopped
```
**2. Build and run:**
```bash
docker compose up --build -d
```
**3. Open** `http://localhost:3001` and log in with the credentials above.
---
## Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
| `ADMIN_USERNAME` | Yes | `admin` | Login username |
| `ADMIN_PASSWORD` | Yes | — | Login password (plain text) |
| `JWT_SECRET` | Yes | — | Secret for signing JWTs (min 32 chars) |
| `JWT_EXPIRY` | No | `8h` | Session token lifetime |
| `DATABASE_URL` | No | `file:./data/rackmapper.db` | SQLite file path |
| `PORT` | No | `3001` | HTTP port |
| `NODE_ENV` | No | — | Set to `production` in Docker |
To change the password, update `ADMIN_PASSWORD` in your Docker environment and restart the container.
---
## Data Persistence
The SQLite database is stored at `./data/rackmapper.db` inside the container. Mount `./data:/app/data` to persist it across container restarts (already included in the compose file above).
---
## Tech Stack
| Layer | Technology |
|---|---|
| Frontend | React 18 + TypeScript + Vite |
| Styling | Tailwind CSS (dark-mode only) |
| State | Zustand |
| Node Graph | React Flow (`@xyflow/react` v12+) |
| Drag & Drop | `@dnd-kit/core` + `@dnd-kit/sortable` |
| Backend | Node.js + Express |
| Database | SQLite via Prisma ORM (`better-sqlite3`) |
| Auth | JWT in `httpOnly` cookie |
| Containerisation | Docker — single container serves API + static build |