diff --git a/AGENTS.md b/AGENTS.md index fcd947f..f0a2025 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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=; 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 # 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 # 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. diff --git a/RREADME.md b/RREADME.md index 2fd9f95..b7bed92 100644 --- a/RREADME.md +++ b/RREADME.md @@ -1 +1,102 @@ -TBD \ No newline at end of file +# 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 |