Compare commits

..

7 Commits

Author SHA1 Message Date
jason 7ea358e66a Add UNRAID.md with GUI and CLI installation instructions
- GUI method: Docker tab field-by-field walkthrough (name, port, volume, env vars)
- CLI method: docker run one-liner with all required flags
- Building the image: local build on Unraid terminal + push-to-registry option
- JWT_SECRET generation tip using /proc/sys/kernel/random/uuid
- Updating, password change, backup, and troubleshooting sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:09:30 -05:00
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
jason bcb8a95fae Switch auth to plain-text password env var (remove bcrypt)
- Replace ADMIN_PASSWORD_HASH with ADMIN_PASSWORD in auth route and docker-compose
- Remove bcryptjs / @types/bcryptjs dependencies
- Delete scripts/hashPassword.ts (no longer needed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:05:42 -05:00
jason 7ef0509f2b Add module resize handle to ModuleBlock
- Drag handle at bottom edge of each module (GripHorizontal icon)
- Pointer capture tracks vertical drag delta → U-size delta
- Clamped to: minimum 1U, rack bounds, first module below
- Shows current U-size label during active resize
- On release: PUT /modules/:id with new uSize (server validates collision)
- Optimistic store update via updateModuleLocal on success

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:03:42 -05:00
jason f4e139972e Add VLAN management page at /vlans
- Full CRUD: create, inline-edit, delete with confirm dialog
- Table shows VLAN ID, name, description, color swatch
- Add-VLAN form at top; hover shows edit/delete actions per row
- Route registered in App.tsx under ProtectedRoute
- VLANs nav button added to RackToolbar and MapToolbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:02:16 -05:00
jason 0b4e9ea1e5 Add Service Mapper context menus, node edit modal, and edge type toggle
- Right-click on canvas → add any node type at cursor position
- Right-click on node → edit, duplicate, or delete
- Right-click on edge → toggle animation, set edge type (bezier/smooth/step/straight), delete
- Double-click a node → NodeEditModal (label, accent color, rack module link)
- ContextMenu component: viewport-clamped, closes on outside click or Escape
- All actions persist to API; React Flow state updated optimistically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:00:27 -05:00
jason 231de3d005 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>
2026-03-21 21:48:56 -05:00
85 changed files with 14754 additions and 103 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(npm install:*)",
"Bash(mkdir -p data)",
"Bash(DATABASE_URL=\"file:./data/rackmapper.db\" npx prisma migrate dev --name init)",
"Bash(npx prisma:*)",
"Bash(npx tsc:*)",
"Bash(git commit:*)",
"Bash(npm uninstall:*)",
"Bash(git add:*)"
]
}
}
+17
View File
@@ -0,0 +1,17 @@
# Copy this file to .env and fill in values before running locally
# In Docker/Unraid, set these as container environment variables instead
# Admin credentials
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH= # Generate with: npx ts-node scripts/hashPassword.ts yourpassword
# JWT
JWT_SECRET= # Min 32 random chars — generate with: openssl rand -hex 32
JWT_EXPIRY=8h
# Database (relative path inside container; bind-mounted to ./data/)
DATABASE_URL=file:./data/rackmapper.db
# Server
PORT=3001
NODE_ENV=development
+43
View File
@@ -0,0 +1,43 @@
# Dependencies
node_modules/
client/node_modules/
# Environment - never commit secrets
.env
.env.local
.env.*.local
# Build output
dist/
client/dist/
# Database - persisted via Docker volume
data/
*.db
*.db-journal
*.db-wal
*.db-shm
# Prisma generated client (regenerated on build)
node_modules/.prisma/
# Logs
logs/
*.log
npm-debug.log*
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
*.swo
# Test coverage
coverage/
# TypeScript incremental build info
*.tsbuildinfo
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 100,
"tabWidth": 2
}
+50 -102
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` | | Drag & Drop (Rack Planner) | `@dnd-kit/core` + `@dnd-kit/sortable` |
| Backend | Node.js + Express (REST API) | | Backend | Node.js + Express (REST API) |
| ORM / DB | Prisma ORM + SQLite (`better-sqlite3`) | | 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 | | Export | `html-to-image` or `dom-to-svg` for PNG export |
| Containerization | Docker (single container — Node serves Vite static build + API) | | Containerization | Docker (single container — Node serves Vite static build + API) |
| Testing | Vitest + React Testing Library | | Testing | Vitest + React Testing Library |
@@ -72,7 +72,7 @@ All data is persisted to SQLite via Prisma ORM with a migration-first workflow.
├── client/ ├── client/
│ ├── src/ │ ├── src/
│ │ ├── main.tsx │ │ ├── main.tsx
│ │ ├── App.tsx ← Router root; / = login, /rack = planner, /map = mapper │ │ ├── App.tsx ← Router root; / → /rack, /rack, /map, /vlans
│ │ ├── store/ │ │ ├── store/
│ │ │ ├── useAuthStore.ts │ │ │ ├── useAuthStore.ts
│ │ │ ├── useRackStore.ts │ │ │ ├── useRackStore.ts
@@ -81,7 +81,10 @@ All data is persisted to SQLite via Prisma ORM with a migration-first workflow.
│ │ │ ├── auth/ ← LoginPage, ProtectedRoute │ │ │ ├── auth/ ← LoginPage, ProtectedRoute
│ │ │ ├── rack/ ← Rack Planner components │ │ │ ├── rack/ ← Rack Planner components
│ │ │ ├── mapper/ ← Service Mapper 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 │ │ │ ├── modals/ ← All modal components
│ │ │ └── ui/ ← Shared primitives (Button, Badge, Tooltip, etc.) │ │ │ └── ui/ ← Shared primitives (Button, Badge, Tooltip, etc.)
│ │ ├── hooks/ │ │ ├── hooks/
@@ -104,28 +107,17 @@ All data is persisted to SQLite via Prisma ORM with a migration-first workflow.
### Strategy ### Strategy
- **Single admin account** — no registration UI, no user table in the database - **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` - Credentials are injected at runtime via Docker environment variables: `ADMIN_USERNAME` and `ADMIN_PASSWORD`
- 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 - 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` - 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` - All `/api/*` routes except `/api/auth/login` are protected by `authMiddleware.ts`
- Token expiry: `8h` (configurable via `JWT_EXPIRY` env var) - 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 ### Auth Flow
``` ```
POST /api/auth/login { username, password } POST /api/auth/login { username, password }
→ verify username === ADMIN_USERNAME → verify username === ADMIN_USERNAME && password === ADMIN_PASSWORD
→ bcrypt.compare(password, ADMIN_PASSWORD_HASH)
→ sign JWT { sub: 'admin', iat, exp } → sign JWT { sub: 'admin', iat, exp }
→ Set-Cookie: token=<jwt>; HttpOnly; SameSite=Strict; Secure; Path=/ → Set-Cookie: token=<jwt>; HttpOnly; SameSite=Strict; Secure; Path=/
→ 200 OK { success: true } → 200 OK { success: true }
@@ -150,7 +142,7 @@ GET /api/auth/me
```env ```env
ADMIN_USERNAME=admin 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_SECRET=your-secret-here # min 32 chars, random
JWT_EXPIRY=8h JWT_EXPIRY=8h
DATABASE_URL=file:./data/rackmapper.db DATABASE_URL=file:./data/rackmapper.db
@@ -164,47 +156,32 @@ NODE_ENV=production
```prisma ```prisma
model Rack { model Rack {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
totalU Int @default(42) totalU Int @default(42)
location String? location String?
displayOrder Int @default(0) // controls left-to-right order in side-by-side view displayOrder Int @default(0)
modules Module[] modules Module[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Module { model Module {
id String @id @default(cuid()) id String @id @default(cuid())
rackId String rackId String
rack Rack @relation(fields: [rackId], references: [id], onDelete: Cascade) rack Rack @relation(fields: [rackId], references: [id], onDelete: Cascade)
name String name String
type ModuleType 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) uPosition Int // 1-indexed from top (U1 = topmost slot)
uSize Int @default(1) uSize Int @default(1)
manufacturer String? manufacturer String?
model String? model String?
ipAddress String? ipAddress String?
notes String? notes String?
ports Port[] ports Port[]
serviceNodes ServiceNode[] // reverse relation — nodes in maps that reference this module serviceNodes ServiceNode[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
}
enum ModuleType {
SWITCH
AGGREGATE_SWITCH
MODEM
ROUTER
NAS
PDU
PATCH_PANEL
SERVER
FIREWALL
AP
BLANK
OTHER
} }
model Port { model Port {
@@ -213,49 +190,32 @@ model Port {
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade) module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
portNumber Int portNumber Int
label String? label String?
portType PortType @default(ETHERNET) portType String @default("ETHERNET") // ETHERNET | SFP | SFP_PLUS | QSFP | CONSOLE | UPLINK
mode VlanMode @default(ACCESS) mode String @default("ACCESS") // ACCESS | TRUNK | HYBRID
nativeVlan Int? nativeVlan Int?
vlans PortVlan[] vlans PortVlan[]
notes String? notes String?
} }
enum PortType {
ETHERNET
SFP
SFP_PLUS
QSFP
CONSOLE
UPLINK
}
enum VlanMode {
ACCESS
TRUNK
HYBRID
}
model Vlan { model Vlan {
id String @id @default(cuid()) id String @id @default(cuid())
vlanId Int @unique vlanId Int @unique
name String name String
description String? description String?
color String? // hex color for UI display color String?
ports PortVlan[] ports PortVlan[]
} }
model PortVlan { model PortVlan {
portId String portId String
port Port @relation(fields: [portId], references: [id], onDelete: Cascade) port Port @relation(fields: [portId], references: [id], onDelete: Cascade)
vlanId String vlanId String
vlan Vlan @relation(fields: [vlanId], references: [id], onDelete: Cascade) vlan Vlan @relation(fields: [vlanId], references: [id], onDelete: Cascade)
tagged Boolean @default(false) tagged Boolean @default(false)
@@id([portId, vlanId]) @@id([portId, vlanId])
} }
// --- Service Mapper ---
model ServiceMap { model ServiceMap {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@@ -267,35 +227,22 @@ model ServiceMap {
} }
model ServiceNode { model ServiceNode {
id String @id @default(cuid()) id String @id @default(cuid())
mapId String mapId String
map ServiceMap @relation(fields: [mapId], references: [id], onDelete: Cascade) map ServiceMap @relation(fields: [mapId], references: [id], onDelete: Cascade)
label String label String
nodeType NodeType nodeType String // SERVICE | DATABASE | API | DEVICE | EXTERNAL | USER | VLAN | FIREWALL | LOAD_BALANCER | NOTE
positionX Float positionX Float
positionY Float positionY Float
metadata String? // JSON blob for arbitrary node-specific data metadata String?
color String? color String?
icon String? icon String?
moduleId String? // optional link to a physical Rack Module moduleId String?
module Module? @relation(fields: [moduleId], references: [id], onDelete: SetNull) module Module? @relation(fields: [moduleId], references: [id], onDelete: SetNull)
sourceEdges ServiceEdge[] @relation("EdgeSource") sourceEdges ServiceEdge[] @relation("EdgeSource")
targetEdges ServiceEdge[] @relation("EdgeTarget") 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 { model ServiceEdge {
id String @id @default(cuid()) id String @id @default(cuid())
mapId String 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:** **Migration workflow:**
```bash ```bash
npx prisma migrate dev --name <descriptive_name> # dev: creates + applies migration 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) | | DELETE | `/api/racks/:id` | Delete rack (cascades) |
| POST | `/api/racks/:id/modules` | Add a module to a rack | | POST | `/api/racks/:id/modules` | Add a module to a rack |
| PUT | `/api/modules/:id` | Update module (position, size, metadata) | | 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 | | DELETE | `/api/modules/:id` | Remove a module |
| GET | `/api/modules/:id/ports` | Get ports for a module | | GET | `/api/modules/:id/ports` | Get ports for a module |
| PUT | `/api/ports/:id` | Update port config (VLAN, mode, label) | | 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 - Multi-select: Shift+click or drag-select box
- Edge types: `smoothstep` (default), `straight`, `bezier` — selectable per edge - Edge types: `smoothstep` (default), `straight`, `bezier` — selectable per edge
- Animated edges for "active traffic" flows (toggle per edge) - Animated edges for "active traffic" flows (toggle per edge)
- Right-click canvas → context menu: Add Node (type picker with icons) - Right-click canvas → context menu: Add Node (all 10 types placed at cursor position)
- Right-click node → Edit, Duplicate, Delete, Link to Module - Right-click node → Edit (label/colour/module link), Duplicate, Delete
- Right-click edge → Edit Label, Change Type, Toggle Animation, 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 ### Persistence
@@ -567,9 +518,6 @@ npm run dev # Vite + Node (concurrently)
npm run dev:client # Vite only npm run dev:client # Vite only
npm run dev:server # Nodemon server only npm run dev:server # Nodemon server only
# Auth
npx ts-node scripts/hashPassword.ts mypassword # generate bcrypt hash for env var
# Database # Database
npx prisma migrate dev --name <name> # create + apply dev migration npx prisma migrate dev --name <name> # create + apply dev migration
npx prisma migrate deploy # apply in production / Docker npx prisma migrate deploy # apply in production / Docker
@@ -608,8 +556,8 @@ environment:
- PORT=3001 - PORT=3001
- DATABASE_URL=file:./data/rackmapper.db - DATABASE_URL=file:./data/rackmapper.db
- ADMIN_USERNAME=admin - ADMIN_USERNAME=admin
- ADMIN_PASSWORD_HASH=$2a$12$... # bcrypt hash - ADMIN_PASSWORD=yourpassword # plain text
- JWT_SECRET=... # min 32 chars - JWT_SECRET=... # min 32 chars, random
- JWT_EXPIRY=8h - JWT_EXPIRY=8h
volumes: volumes:
- ./data:/app/data # persists SQLite file across container restarts - ./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. 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`. 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. 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. 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. 6. **React Flow for Service Mapper** — first-class TypeScript, custom node API, active maintenance. Do not swap.
+31
View File
@@ -0,0 +1,31 @@
FROM node:20-alpine
WORKDIR /app
# Install build tools needed for better-sqlite3 native bindings
RUN apk add --no-cache python3 make g++
# Copy package manifests
COPY package*.json ./
COPY client/package*.json ./client/
# Install all dependencies (dev deps needed for prisma CLI + tsc build)
RUN npm install
RUN cd client && npm install
# Copy source
COPY . .
# Generate Prisma client for target platform (must happen before tsc)
RUN npx prisma generate
# Build server (tsc) + client (vite)
RUN npm run build
# Ensure data directory exists for SQLite bind mount
RUN mkdir -p /app/data
EXPOSE 3001
# Apply pending migrations then start
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/server/index.js"]
+102 -1
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 |
+251
View File
@@ -0,0 +1,251 @@
# RackMapper — Unraid Installation Guide
Two methods are covered: **GUI (Community Applications / Docker template)** and **CLI**.
---
## Prerequisites
- Unraid 6.10 or later
- The RackMapper image built and available (either pushed to a registry or built locally — see [Building the Image](#building-the-image))
- A share to store the persistent database (e.g. `/mnt/user/appdata/rackmapper`)
---
## Building the Image
RackMapper is not on Docker Hub. You must build it from source on your Unraid server or on another machine and push it to a registry (e.g. a local Gitea registry, Docker Hub private repo, or GHCR).
### Option A — Build directly on Unraid via Unraid Terminal
```bash
# 1. Clone the repo (or upload the source to a share)
cd /mnt/user/appdata
git clone https://github.com/YOUR_USERNAME/rack-planner rackmapper-src
# 2. Build the image
cd /mnt/user/appdata/rackmapper-src
docker build -t rackmapper:latest .
# 3. Verify the image exists
docker images | grep rackmapper
```
### Option B — Build on another machine and push to a registry
```bash
# Build and push to Docker Hub (replace with your username)
docker build -t yourdockerhubuser/rackmapper:latest .
docker push yourdockerhubuser/rackmapper:latest
```
Then use `yourdockerhubuser/rackmapper:latest` as the image name in the steps below.
---
## Method 1 — GUI (Docker tab)
### Step 1 — Create the data directory
Open the Unraid terminal (**Tools → Terminal**) and run:
```bash
mkdir -p /mnt/user/appdata/rackmapper/data
```
### Step 2 — Add the container
1. Go to the **Docker** tab
2. At the bottom, click **Add Container**
3. Fill in the fields as follows:
---
#### Basic
| Field | Value |
|---|---|
| **Name** | `rackmapper` |
| **Repository** | `rackmapper:latest` (local build) or `yourdockerhubuser/rackmapper:latest` |
| **Docker Hub URL** | *(leave blank for local image)* |
| **Network Type** | `bridge` |
| **Console shell command** | `sh` |
| **Privileged** | Off |
| **Restart Policy** | Unless stopped |
---
#### Port Mapping
Click **Add another Path, Port, Variable, Label or Device** → select **Port**
| Field | Value |
|---|---|
| **Name** | Web UI |
| **Container Port** | `3001` |
| **Host Port** | `3001` *(change if port is taken)* |
| **Protocol** | TCP |
---
#### Volume Mapping
Click **Add another Path, Port, Variable, Label or Device** → select **Path**
| Field | Value |
|---|---|
| **Name** | AppData |
| **Container Path** | `/app/data` |
| **Host Path** | `/mnt/user/appdata/rackmapper/data` |
| **Access Mode** | Read/Write |
---
#### Environment Variables
Click **Add another Path, Port, Variable, Label or Device** → select **Variable** for each row below:
| Name | Key | Value |
|---|---|---|
| Node Environment | `NODE_ENV` | `production` |
| Port | `PORT` | `3001` |
| Database URL | `DATABASE_URL` | `file:./data/rackmapper.db` |
| Admin Username | `ADMIN_USERNAME` | `admin` *(or your preferred username)* |
| Admin Password | `ADMIN_PASSWORD` | `yourpassword` |
| JWT Secret | `JWT_SECRET` | `a-long-random-string-min-32-chars` |
| JWT Expiry | `JWT_EXPIRY` | `8h` *(optional — defaults to 8h)* |
> **JWT_SECRET** should be a random string of at least 32 characters. You can generate one in the Unraid terminal:
> ```bash
> cat /proc/sys/kernel/random/uuid | tr -d '-' && cat /proc/sys/kernel/random/uuid | tr -d '-'
> ```
---
### Step 3 — Apply
Click **Apply**. Unraid will pull/use the image and start the container.
Open `http://YOUR-UNRAID-IP:3001` in your browser.
---
## Method 2 — CLI
### Step 1 — Create the data directory
```bash
mkdir -p /mnt/user/appdata/rackmapper/data
```
### Step 2 — Run the container
```bash
docker run -d \
--name rackmapper \
--restart unless-stopped \
-p 3001:3001 \
-v /mnt/user/appdata/rackmapper/data:/app/data \
-e NODE_ENV=production \
-e PORT=3001 \
-e DATABASE_URL="file:./data/rackmapper.db" \
-e ADMIN_USERNAME=admin \
-e ADMIN_PASSWORD=yourpassword \
-e JWT_SECRET=a-long-random-string-min-32-chars \
-e JWT_EXPIRY=8h \
rackmapper:latest
```
### Step 3 — Verify
```bash
# Check container is running
docker ps | grep rackmapper
# Tail startup logs (migrations run automatically on first start)
docker logs -f rackmapper
```
You should see output ending with something like:
```
Applied 1 migration.
Server listening on port 3001
```
Open `http://YOUR-UNRAID-IP:3001` in your browser.
---
## Updating
When a new version of the source is available:
```bash
# Rebuild the image
cd /mnt/user/appdata/rackmapper-src
git pull
docker build -t rackmapper:latest .
# Restart the container (migrations run automatically on startup)
docker restart rackmapper
```
From the GUI: **Docker tab → rackmapper → Force Update** (if using a remote registry), or restart after rebuilding locally.
---
## Changing the Password
1. Go to **Docker tab → rackmapper → Edit**
2. Update the `ADMIN_PASSWORD` variable
3. Click **Apply** — Unraid will recreate the container with the new value
Or via CLI:
```bash
docker stop rackmapper
docker rm rackmapper
# Re-run the docker run command above with the new ADMIN_PASSWORD value
```
---
## Data & Backups
The SQLite database file lives at:
```
/mnt/user/appdata/rackmapper/data/rackmapper.db
```
Back it up like any other appdata file — Unraid's built-in **Appdata Backup** plugin (CA Appdata Backup) will cover it automatically if your appdata share is included.
To manually back up:
```bash
cp /mnt/user/appdata/rackmapper/data/rackmapper.db \
/mnt/user/backups/rackmapper-$(date +%Y%m%d).db
```
---
## Troubleshooting
**Container exits immediately**
```bash
docker logs rackmapper
```
Most commonly caused by a missing or malformed `JWT_SECRET` or `ADMIN_PASSWORD`.
**Port 3001 already in use**
Change the **Host Port** to any free port (e.g. `3002`). The Container Port must stay `3001`.
**"Server not configured: admin credentials missing" on login**
One or both of `ADMIN_USERNAME` / `ADMIN_PASSWORD` environment variables is not set. Check the container's environment in the Docker tab.
**Database not persisting across restarts**
Verify the volume mapping — the host path `/mnt/user/appdata/rackmapper/data` must exist before starting the container and must be mapped to `/app/data` inside the container.
**Migrations not running**
The container runs `npx prisma migrate deploy` automatically on startup. Check `docker logs rackmapper` for any migration errors.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RackMapper</title>
<meta name="description" content="Network rack planner and service mapper" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3079
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "rackmapper-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@xyflow/react": "^12.3.4",
"clsx": "^2.1.1",
"html-to-image": "^1.11.11",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"sonner": "^1.7.0",
"tailwind-merge": "^2.5.4",
"zustand": "^5.0.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+62
View File
@@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './store/useAuthStore';
import { LoginPage } from './components/auth/LoginPage';
import { ProtectedRoute } from './components/auth/ProtectedRoute';
import { RackPlanner } from './components/rack/RackPlanner';
import { ServiceMapper } from './components/mapper/ServiceMapper';
import { VlanPage } from './components/vlans/VlanPage';
import { Skeleton } from './components/ui/Skeleton';
export default function App() {
const { checkAuth, loading } = useAuthStore();
useEffect(() => {
checkAuth();
}, [checkAuth]);
if (loading) {
return (
<div className="min-h-screen bg-[#0f1117] flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="text-2xl font-bold text-slate-300 tracking-widest">RACKMAPPER</div>
<Skeleton className="w-48 h-1" />
</div>
</div>
);
}
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<Navigate to="/rack" replace />} />
<Route
path="/rack"
element={
<ProtectedRoute>
<RackPlanner />
</ProtectedRoute>
}
/>
<Route
path="/map"
element={
<ProtectedRoute>
<ServiceMapper />
</ProtectedRoute>
}
/>
<Route
path="/vlans"
element={
<ProtectedRoute>
<VlanPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/rack" replace />} />
</Routes>
</BrowserRouter>
);
}
+198
View File
@@ -0,0 +1,198 @@
import type {
Rack,
Module,
Port,
Vlan,
ServiceMap,
ServiceMapSummary,
ServiceNode,
ServiceEdge,
ModuleType,
PortType,
VlanMode,
NodeType,
} from '../types';
// ---- Core fetch wrapper ----
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`/api${endpoint}`, {
...options,
headers: { 'Content-Type': 'application/json', ...options.headers },
credentials: 'include',
});
const body = await res.json().catch(() => ({ data: null, error: `HTTP ${res.status}` }));
if (!res.ok) {
throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`);
}
return (body as { data: T }).data;
}
function get<T>(path: string) {
return request<T>(path);
}
function post<T>(path: string, data?: unknown) {
return request<T>(path, { method: 'POST', body: JSON.stringify(data) });
}
function put<T>(path: string, data?: unknown) {
return request<T>(path, { method: 'PUT', body: JSON.stringify(data) });
}
function del<T>(path: string) {
return request<T>(path, { method: 'DELETE' });
}
// ---- Auth ----
const auth = {
me: () => get<{ authenticated: boolean }>('/auth/me'),
login: (username: string, password: string) =>
post<{ success: boolean }>('/auth/login', { username, password }),
logout: () => post<{ success: boolean }>('/auth/logout'),
};
// ---- Racks ----
const racks = {
list: () => get<Rack[]>('/racks'),
get: (id: string) => get<Rack>(`/racks/${id}`),
create: (data: { name: string; totalU?: number; location?: string; displayOrder?: number }) =>
post<Rack>('/racks', data),
update: (
id: string,
data: Partial<{ name: string; totalU: number; location: string; displayOrder: number }>
) => put<Rack>(`/racks/${id}`, data),
delete: (id: string) => del<null>(`/racks/${id}`),
addModule: (
rackId: string,
data: {
name: string;
type: ModuleType;
uPosition: number;
uSize?: number;
manufacturer?: string;
model?: string;
ipAddress?: string;
notes?: string;
portCount?: number;
portType?: PortType;
}
) => post<Module>(`/racks/${rackId}/modules`, data),
};
// ---- Modules ----
const modules = {
update: (
id: string,
data: Partial<{
name: string;
uPosition: number;
uSize: number;
manufacturer: string;
model: string;
ipAddress: string;
notes: string;
}>
) => put<Module>(`/modules/${id}`, data),
delete: (id: string) => del<null>(`/modules/${id}`),
move: (id: string, rackId: string, uPosition: number) =>
post<Module>(`/modules/${id}/move`, { rackId, uPosition }),
getPorts: (id: string) => get<Port[]>(`/modules/${id}/ports`),
};
// ---- Ports ----
const ports = {
update: (
id: string,
data: {
label?: string;
mode?: VlanMode;
nativeVlan?: number | null;
notes?: string;
vlans?: Array<{ vlanId: string; tagged: boolean }>;
}
) => put<Port>(`/ports/${id}`, data),
};
// ---- VLANs ----
const vlans = {
list: () => get<Vlan[]>('/vlans'),
create: (data: { vlanId: number; name: string; description?: string; color?: string }) =>
post<Vlan>('/vlans', data),
update: (id: string, data: Partial<{ name: string; description: string; color: string }>) =>
put<Vlan>(`/vlans/${id}`, data),
delete: (id: string) => del<null>(`/vlans/${id}`),
};
// ---- Service Maps ----
const maps = {
list: () => get<ServiceMapSummary[]>('/maps'),
get: (id: string) => get<ServiceMap>(`/maps/${id}`),
create: (data: { name: string; description?: string }) => post<ServiceMap>('/maps', data),
update: (id: string, data: Partial<{ name: string; description: string }>) =>
put<ServiceMap>(`/maps/${id}`, data),
delete: (id: string) => del<null>(`/maps/${id}`),
addNode: (
mapId: string,
data: {
label: string;
nodeType: NodeType;
positionX: number;
positionY: number;
metadata?: string;
color?: string;
icon?: string;
moduleId?: string;
}
) => post<ServiceNode>(`/maps/${mapId}/nodes`, data),
populate: (mapId: string) => post<ServiceMap>(`/maps/${mapId}/populate`),
addEdge: (
mapId: string,
data: {
sourceId: string;
targetId: string;
label?: string;
edgeType?: string;
animated?: boolean;
}
) => post<ServiceEdge>(`/maps/${mapId}/edges`, data),
};
// ---- Nodes ----
const nodes = {
update: (
id: string,
data: Partial<{
label: string;
positionX: number;
positionY: number;
metadata: string;
color: string;
icon: string;
moduleId: string | null;
}>
) => put<ServiceNode>(`/nodes/${id}`, data),
delete: (id: string) => del<null>(`/nodes/${id}`),
};
// ---- Edges ----
const edges = {
update: (
id: string,
data: Partial<{ label: string; edgeType: string; animated: boolean; metadata: string }>
) => put<ServiceEdge>(`/edges/${id}`, data),
delete: (id: string) => del<null>(`/edges/${id}`),
};
export const apiClient = { auth, racks, modules, ports, vlans, maps, nodes, edges };
+107
View File
@@ -0,0 +1,107 @@
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { useAuthStore } from '../../store/useAuthStore';
import { Button } from '../ui/Button';
export function LoginPage() {
const navigate = useNavigate();
const { login } = useAuthStore();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!username.trim() || !password) return;
setLoading(true);
try {
await login(username.trim(), password);
navigate('/rack', { replace: true });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-[#0f1117] flex items-center justify-center p-4">
<div className="w-full max-w-sm">
{/* Logo / wordmark */}
<div className="text-center mb-8">
<div className="inline-flex items-center gap-2 mb-2">
<div className="w-8 h-8 bg-blue-500 rounded flex items-center justify-center">
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<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-xl font-bold text-white tracking-wider">RACKMAPPER</span>
</div>
<p className="text-slate-500 text-sm">Network infrastructure management</p>
</div>
{/* Card */}
<form
onSubmit={handleSubmit}
className="bg-slate-800 border border-slate-700 rounded-xl p-6 space-y-4 shadow-2xl"
>
<div className="space-y-1">
<label htmlFor="username" className="block text-sm font-medium text-slate-300">
Username
</label>
<input
id="username"
type="text"
autoComplete="username"
autoFocus
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-100 text-sm placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
placeholder="admin"
/>
</div>
<div className="space-y-1">
<label htmlFor="password" className="block text-sm font-medium text-slate-300">
Password
</label>
<input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-100 text-sm placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
placeholder="••••••••"
/>
</div>
<Button
type="submit"
disabled={loading || !username.trim() || !password}
loading={loading}
className="w-full mt-2"
>
Sign in
</Button>
</form>
<p className="text-center text-xs text-slate-600 mt-4">
Credentials are set via Docker environment variables.
</p>
</div>
</div>
);
}
@@ -0,0 +1,17 @@
import type { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
interface ProtectedRouteProps {
children: ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated } = useAuthStore();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
@@ -0,0 +1,87 @@
import { useEffect, useRef, type ReactNode } from 'react';
interface MenuItem {
label: string;
icon?: ReactNode;
onClick: () => void;
variant?: 'default' | 'danger';
checked?: boolean;
separator?: false;
}
interface SeparatorItem {
separator: true;
}
export type ContextMenuEntry = MenuItem | SeparatorItem;
interface ContextMenuProps {
x: number;
y: number;
items: ContextMenuEntry[];
onClose: () => void;
}
export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleMouseDown(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);
// Clamp so menu doesn't overflow right/bottom edge
const menuWidth = 192;
const menuHeight = items.length * 34;
const clampedX = Math.min(x, window.innerWidth - menuWidth - 8);
const clampedY = Math.min(y, window.innerHeight - menuHeight - 8);
return (
<div
ref={menuRef}
style={{ top: clampedY, left: clampedX, zIndex: 9999 }}
className="fixed min-w-[192px] bg-slate-800 border border-slate-600 rounded-lg shadow-2xl py-1 text-sm"
>
{items.map((item, i) => {
if ('separator' in item && item.separator) {
return <div key={i} className="my-1 border-t border-slate-700" />;
}
const mi = item as MenuItem;
return (
<button
key={i}
className={`w-full text-left flex items-center gap-2 px-3 py-1.5 transition-colors hover:bg-slate-700 ${
mi.variant === 'danger'
? 'text-red-400 hover:text-red-300'
: 'text-slate-200'
}`}
onClick={() => {
mi.onClick();
onClose();
}}
>
{mi.icon && <span className="shrink-0 w-4 text-slate-400">{mi.icon}</span>}
<span className="flex-1">{mi.label}</span>
{mi.checked !== undefined && (
<span className={`text-xs ${mi.checked ? 'text-blue-400' : 'text-slate-600'}`}>
{mi.checked ? '✓' : ''}
</span>
)}
</button>
);
})}
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Download, Server, LogOut, ChevronDown, Tag } from 'lucide-react';
import { toast } from 'sonner';
import { toPng } from 'html-to-image';
import { useReactFlow } from '@xyflow/react';
import { Button } from '../ui/Button';
import { useAuthStore } from '../../store/useAuthStore';
import type { ServiceMapSummary } from '../../types';
import { apiClient } from '../../api/client';
interface MapToolbarProps {
maps: ServiceMapSummary[];
activeMapId: string | null;
activeMapName: string;
onSelectMap: (id: string) => void;
onCreateMap: () => void;
onPopulate: () => void;
flowContainerRef: React.RefObject<HTMLDivElement | null>;
}
export function MapToolbar({
maps,
activeMapId,
activeMapName,
onSelectMap,
onCreateMap,
onPopulate,
flowContainerRef,
}: MapToolbarProps) {
const navigate = useNavigate();
const { logout } = useAuthStore();
const { fitView } = useReactFlow();
const [exporting, setExporting] = useState(false);
const [mapDropdownOpen, setMapDropdownOpen] = useState(false);
async function handleExport() {
if (!flowContainerRef.current) return;
setExporting(true);
const toastId = toast.loading('Exporting…');
// Temporarily hide React Flow UI chrome
const minimap = flowContainerRef.current.querySelector('.react-flow__minimap') as HTMLElement | null;
const controls = flowContainerRef.current.querySelector('.react-flow__controls') as HTMLElement | null;
if (minimap) minimap.style.display = 'none';
if (controls) controls.style.display = 'none';
try {
const dataUrl = await toPng(flowContainerRef.current, { cacheBust: true });
const link = document.createElement('a');
link.download = `rackmapper-map-${activeMapName.replace(/\s+/g, '-')}-${Date.now()}.png`;
link.href = dataUrl;
link.click();
toast.success('Exported', { id: toastId });
} catch {
toast.error('Export failed', { id: toastId });
} finally {
if (minimap) minimap.style.display = '';
if (controls) controls.style.display = '';
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 z-10">
{/* Left */}
<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">Service Mapper</span>
{/* Map selector */}
<div className="relative">
<button
onClick={() => setMapDropdownOpen((v) => !v)}
className="flex items-center gap-1 px-2 py-1 rounded bg-slate-700 border border-slate-600 text-sm text-slate-200 hover:bg-slate-600 transition-colors"
>
<span className="max-w-[140px] truncate">{activeMapId ? activeMapName : 'Select map…'}</span>
<ChevronDown size={12} />
</button>
{mapDropdownOpen && (
<div className="absolute top-full left-0 mt-1 w-52 bg-slate-800 border border-slate-700 rounded-lg shadow-xl z-50 overflow-hidden">
{maps.map((m) => (
<button
key={m.id}
className={`w-full text-left px-3 py-2 text-sm hover:bg-slate-700 transition-colors ${m.id === activeMapId ? 'text-blue-400' : 'text-slate-200'}`}
onClick={() => { onSelectMap(m.id); setMapDropdownOpen(false); }}
>
{m.name}
</button>
))}
<div className="border-t border-slate-700">
<button
className="w-full text-left px-3 py-2 text-sm text-blue-400 hover:bg-slate-700 transition-colors"
onClick={() => { onCreateMap(); setMapDropdownOpen(false); }}
>
+ New map
</button>
</div>
</div>
)}
</div>
</div>
{/* Right */}
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => navigate('/rack')}>
<Server size={14} />
Rack Planner
</Button>
<Button size="sm" variant="secondary" onClick={() => navigate('/vlans')}>
<Tag size={14} />
VLANs
</Button>
{activeMapId && (
<>
<Button size="sm" variant="secondary" onClick={onPopulate} title="Import all rack modules as nodes">
Import Rack
</Button>
<Button size="sm" variant="secondary" onClick={() => fitView({ padding: 0.1 })}>
Fit View
</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>
);
}
@@ -0,0 +1,153 @@
import { useState, useEffect, type FormEvent } from 'react';
import { toast } from 'sonner';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/Button';
import { apiClient } from '../../api/client';
import { useRackStore } from '../../store/useRackStore';
const COLOR_SWATCHES = [
'#3b82f6', // blue
'#10b981', // emerald
'#f59e0b', // amber
'#ef4444', // red
'#8b5cf6', // violet
'#ec4899', // pink
'#06b6d4', // cyan
'#84cc16', // lime
'#f97316', // orange
'#6b7280', // gray
];
export interface NodeEditModalProps {
open: boolean;
onClose: () => void;
nodeId: string;
initialLabel: string;
initialColor?: string;
initialModuleId?: string | null;
onSaved: (updated: { label: string; color: string; moduleId: string | null }) => void;
}
export function NodeEditModal({
open,
onClose,
nodeId,
initialLabel,
initialColor,
initialModuleId,
onSaved,
}: NodeEditModalProps) {
const { racks } = useRackStore();
const [label, setLabel] = useState(initialLabel);
const [color, setColor] = useState(initialColor ?? '#3b82f6');
const [moduleId, setModuleId] = useState<string>(initialModuleId ?? '');
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
setLabel(initialLabel);
setColor(initialColor ?? '#3b82f6');
setModuleId(initialModuleId ?? '');
}
}, [open, initialLabel, initialColor, initialModuleId]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!label.trim()) return;
setLoading(true);
try {
await apiClient.nodes.update(nodeId, {
label: label.trim(),
color,
moduleId: moduleId || null,
});
onSaved({ label: label.trim(), color, moduleId: moduleId || null });
toast.success('Node updated');
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Save failed');
} finally {
setLoading(false);
}
}
// All modules across all racks, flat list
const allModules = racks.flatMap((r) =>
r.modules.map((m) => ({ id: m.id, label: `[${r.name}] ${m.name}` }))
);
return (
<Modal open={open} onClose={onClose} title="Edit Node" size="sm">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Label */}
<div className="space-y-1">
<label className="block text-sm text-slate-300">Label</label>
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
disabled={loading}
placeholder="Node label"
autoFocus
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
{/* Color */}
<div className="space-y-1">
<label className="block text-sm text-slate-300">Accent color</label>
<div className="flex flex-wrap items-center gap-2">
{COLOR_SWATCHES.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`w-6 h-6 rounded-full border-2 transition-transform ${
color === c
? 'border-white scale-125'
: 'border-transparent hover:border-slate-400'
}`}
style={{ backgroundColor: c }}
aria-label={`Color ${c}`}
/>
))}
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-7 h-7 rounded cursor-pointer bg-transparent border border-slate-600 p-0"
title="Custom color"
/>
<span className="text-xs text-slate-500 font-mono">{color}</span>
</div>
</div>
{/* Module link */}
<div className="space-y-1">
<label className="block text-sm text-slate-300">Link to rack module</label>
<select
value={moduleId}
onChange={(e) => setModuleId(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
>
<option value=""> None </option>
{allModules.map((m) => (
<option key={m.id} value={m.id}>
{m.label}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-3 pt-1">
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button size="sm" type="submit" loading={loading} disabled={!label.trim()}>
Save
</Button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,621 @@
/**
* ServiceMapper — React Flow canvas for service/infrastructure mapping.
*
* SCAFFOLD STATUS:
* ✅ Canvas renders with all node types
* ✅ Map list, select, create via toolbar
* ✅ Auto-populate from rack modules
* ✅ Node drag + debounced position save
* ✅ Edge creation by connecting handles
* ✅ Minimap, controls, dot background
* ✅ PNG export
* ✅ Node/edge delete persisted to DB (Delete key)
* ✅ Right-click context menus (canvas + node + edge)
* ✅ Node edit modal (label, color, link to module) — double-click or context menu
* ✅ Edge type/animation toggle via context menu
* ⚠️ Multi-select operations — functional but no toolbar actions
*/
import { useEffect, useRef, useCallback, useState } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
addEdge,
useNodesState,
useEdgesState,
useReactFlow,
type Node,
type Edge,
type OnConnect,
type NodeChange,
type EdgeChange,
BackgroundVariant,
ReactFlowProvider,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { toast } from 'sonner';
import { Edit2, Copy, Trash2, Zap, ZapOff, ArrowRight, Plus } from 'lucide-react';
import { DeviceNode } from './nodes/DeviceNode';
import { ServiceNode as ServiceNodeComponent } from './nodes/ServiceNode';
import { DatabaseNode } from './nodes/DatabaseNode';
import { ApiNode } from './nodes/ApiNode';
import { ExternalNode } from './nodes/ExternalNode';
import { VlanNode } from './nodes/VlanNode';
import { FirewallNode } from './nodes/FirewallNode';
import { LBNode } from './nodes/LBNode';
import { UserNode } from './nodes/UserNode';
import { NoteNode } from './nodes/NoteNode';
import { MapToolbar } from './MapToolbar';
import { ContextMenu, type ContextMenuEntry } from './ContextMenu';
import { NodeEditModal } from './NodeEditModal';
import { useMapStore } from '../../store/useMapStore';
import { apiClient } from '../../api/client';
import type { ServiceMap, NodeType } from '../../types';
const NODE_TYPES = {
DEVICE: DeviceNode,
SERVICE: ServiceNodeComponent,
DATABASE: DatabaseNode,
API: ApiNode,
EXTERNAL: ExternalNode,
VLAN: VlanNode,
FIREWALL: FirewallNode,
LOAD_BALANCER: LBNode,
USER: UserNode,
NOTE: NoteNode,
};
const ADD_NODE_OPTIONS: { type: NodeType; label: string }[] = [
{ type: 'DEVICE', label: 'Device' },
{ type: 'SERVICE', label: 'Service' },
{ type: 'DATABASE', label: 'Database' },
{ type: 'API', label: 'API' },
{ type: 'EXTERNAL', label: 'External' },
{ type: 'USER', label: 'User' },
{ type: 'VLAN', label: 'VLAN' },
{ type: 'FIREWALL', label: 'Firewall' },
{ type: 'LOAD_BALANCER', label: 'Load Balancer' },
{ type: 'NOTE', label: 'Note' },
];
const EDGE_TYPES_CYCLE = ['default', 'smoothstep', 'step', 'straight'] as const;
const EDGE_TYPE_LABELS: Record<string, string> = {
default: 'Default (bezier)',
smoothstep: 'Smooth step',
step: 'Step',
straight: 'Straight',
};
function toFlowNodes(map: ServiceMap): Node[] {
return map.nodes.map((n) => ({
id: n.id,
type: n.nodeType,
position: { x: n.positionX, y: n.positionY },
data: {
label: n.label,
color: n.color,
icon: n.icon,
metadata: n.metadata,
module: n.module,
},
}));
}
function toFlowEdges(map: ServiceMap): Edge[] {
return map.edges.map((e) => ({
id: e.id,
source: e.sourceId,
target: e.targetId,
label: e.label,
type: e.edgeType,
animated: e.animated,
}));
}
// ---- Context menu state ----
type CtxMenu =
| { kind: 'canvas'; x: number; y: number; flowX: number; flowY: number }
| {
kind: 'node';
x: number;
y: number;
nodeId: string;
label: string;
color?: string;
moduleId?: string | null;
}
| { kind: 'edge'; x: number; y: number; edgeId: string; animated: boolean; edgeType: string }
| null;
type NodeEditState = {
nodeId: string;
label: string;
color?: string;
moduleId?: string | null;
} | null;
function ServiceMapperInner() {
const { maps, activeMap, fetchMaps, loadMap, createMap, setActiveMap } = useMapStore();
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const flowContainerRef = useRef<HTMLDivElement>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { screenToFlowPosition } = useReactFlow();
const [ctxMenu, setCtxMenu] = useState<CtxMenu>(null);
const [nodeEditState, setNodeEditState] = useState<NodeEditState>(null);
// Load maps list on mount
useEffect(() => {
fetchMaps().catch(() => toast.error('Failed to load maps'));
}, [fetchMaps]);
// When active map changes, update flow state
useEffect(() => {
if (activeMap) {
setNodes(toFlowNodes(activeMap));
setEdges(toFlowEdges(activeMap));
} else {
setNodes([]);
setEdges([]);
}
}, [activeMap, setNodes, setEdges]);
// Debounced node position save (500ms after drag end)
const handleNodesChange = useCallback(
(changes: NodeChange<Node>[]) => {
onNodesChange(changes);
const positionChanges = changes.filter(
(c): c is NodeChange<Node> & { type: 'position'; dragging: false } =>
c.type === 'position' && !(c as { dragging?: boolean }).dragging
);
if (positionChanges.length === 0) return;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => {
for (const change of positionChanges) {
const nodeId = (change as { id: string }).id;
const position = (change as { position?: { x: number; y: number } }).position;
if (!position) continue;
try {
await apiClient.nodes.update(nodeId, {
positionX: position.x,
positionY: position.y,
});
} catch {
// Silent — position drift on failure is acceptable
}
}
}, 500);
},
[onNodesChange]
);
// Persist node deletions
const handleNodesDelete = useCallback(async (deleted: Node[]) => {
for (const node of deleted) {
try {
await apiClient.nodes.delete(node.id);
} catch {
toast.error(`Failed to delete node "${(node.data as { label?: string }).label ?? node.id}"`);
}
}
}, []);
// Persist edge deletions
const handleEdgesDelete = useCallback(async (deleted: Edge[]) => {
for (const edge of deleted) {
try {
await apiClient.edges.delete(edge.id);
} catch {
toast.error('Failed to delete connection');
}
}
}, []);
const handleEdgesChange = useCallback(
(changes: EdgeChange<Edge>[]) => {
onEdgesChange(changes);
},
[onEdgesChange]
);
const onConnect: OnConnect = useCallback(
async (connection) => {
if (!activeMap) return;
try {
const edge = await apiClient.maps.addEdge(activeMap.id, {
sourceId: connection.source,
targetId: connection.target,
});
setEdges((eds) =>
addEdge(
{
id: edge.id,
source: edge.sourceId,
target: edge.targetId,
type: edge.edgeType,
animated: edge.animated,
},
eds
)
);
} catch {
toast.error('Failed to create connection');
}
},
[activeMap, setEdges]
);
// ---- Context menu handlers ----
const onPaneContextMenu = useCallback(
(event: MouseEvent | React.MouseEvent) => {
event.preventDefault();
if (!activeMap) return;
const flowPos = screenToFlowPosition({ x: event.clientX, y: event.clientY });
setCtxMenu({ kind: 'canvas', x: event.clientX, y: event.clientY, flowX: flowPos.x, flowY: flowPos.y });
},
[activeMap, screenToFlowPosition]
);
const onNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => {
event.preventDefault();
const d = node.data as { label?: string; color?: string; module?: { id: string } };
setCtxMenu({
kind: 'node',
x: event.clientX,
y: event.clientY,
nodeId: node.id,
label: d.label ?? '',
color: d.color,
moduleId: d.module?.id ?? null,
});
}, []);
const onEdgeContextMenu = useCallback((event: React.MouseEvent, edge: Edge) => {
event.preventDefault();
setCtxMenu({
kind: 'edge',
x: event.clientX,
y: event.clientY,
edgeId: edge.id,
animated: edge.animated ?? false,
edgeType: edge.type ?? 'default',
});
}, []);
const onNodeDoubleClick = useCallback((_event: React.MouseEvent, node: Node) => {
const d = node.data as { label?: string; color?: string; module?: { id: string } };
setNodeEditState({
nodeId: node.id,
label: d.label ?? '',
color: d.color,
moduleId: d.module?.id ?? null,
});
}, []);
// ---- Canvas context menu: add node ----
async function handleAddNode(nodeType: NodeType, flowX: number, flowY: number) {
if (!activeMap) return;
try {
const created = await apiClient.maps.addNode(activeMap.id, {
label: nodeType.charAt(0) + nodeType.slice(1).toLowerCase().replace(/_/g, ' '),
nodeType,
positionX: flowX,
positionY: flowY,
});
setNodes((nds) => [
...nds,
{
id: created.id,
type: created.nodeType,
position: { x: created.positionX, y: created.positionY },
data: {
label: created.label,
color: created.color,
icon: created.icon,
metadata: created.metadata,
module: created.module,
},
},
]);
} catch {
toast.error('Failed to add node');
}
}
// ---- Node context menu: duplicate ----
async function handleDuplicateNode(nodeId: string) {
if (!activeMap) return;
const source = nodes.find((n) => n.id === nodeId);
if (!source) return;
const d = source.data as { label?: string; color?: string; module?: { id: string } };
try {
const created = await apiClient.maps.addNode(activeMap.id, {
label: `${d.label ?? 'Node'} copy`,
nodeType: (source.type ?? 'SERVICE') as NodeType,
positionX: source.position.x + 40,
positionY: source.position.y + 40,
color: d.color,
moduleId: d.module?.id,
});
setNodes((nds) => [
...nds,
{
id: created.id,
type: created.nodeType,
position: { x: created.positionX, y: created.positionY },
data: {
label: created.label,
color: created.color,
icon: created.icon,
metadata: created.metadata,
module: created.module,
},
},
]);
toast.success('Node duplicated');
} catch {
toast.error('Failed to duplicate node');
}
}
// ---- Node context menu: delete ----
async function handleDeleteNode(nodeId: string) {
try {
await apiClient.nodes.delete(nodeId);
setNodes((nds) => nds.filter((n) => n.id !== nodeId));
} catch {
toast.error('Failed to delete node');
}
}
// ---- Edge context menu: toggle animation ----
async function handleToggleEdgeAnimation(edgeId: string, currentAnimated: boolean) {
const newAnimated = !currentAnimated;
try {
await apiClient.edges.update(edgeId, { animated: newAnimated });
setEdges((eds) =>
eds.map((e) => (e.id === edgeId ? { ...e, animated: newAnimated } : e))
);
} catch {
toast.error('Failed to update edge');
}
}
// ---- Edge context menu: change type ----
async function handleSetEdgeType(edgeId: string, edgeType: string) {
try {
await apiClient.edges.update(edgeId, { edgeType });
setEdges((eds) =>
eds.map((e) => (e.id === edgeId ? { ...e, type: edgeType } : e))
);
} catch {
toast.error('Failed to update edge');
}
}
// ---- Edge context menu: delete ----
async function handleDeleteEdge(edgeId: string) {
try {
await apiClient.edges.delete(edgeId);
setEdges((eds) => eds.filter((e) => e.id !== edgeId));
} catch {
toast.error('Failed to delete connection');
}
}
// ---- Node edit modal save ----
function handleNodeEditSaved(updated: { label: string; color: string; moduleId: string | null }) {
if (!nodeEditState) return;
setNodes((nds) =>
nds.map((n) =>
n.id === nodeEditState.nodeId
? { ...n, data: { ...n.data, label: updated.label, color: updated.color } }
: n
)
);
}
// ---- Build context menu items ----
function buildContextMenuItems(): ContextMenuEntry[] {
if (!ctxMenu) return [];
if (ctxMenu.kind === 'canvas') {
const { flowX, flowY } = ctxMenu;
return ADD_NODE_OPTIONS.map((opt) => ({
label: `Add ${opt.label}`,
icon: <Plus size={12} />,
onClick: () => handleAddNode(opt.type, flowX, flowY),
}));
}
if (ctxMenu.kind === 'node') {
const { nodeId, label, color, moduleId } = ctxMenu;
return [
{
label: 'Edit node',
icon: <Edit2 size={12} />,
onClick: () => setNodeEditState({ nodeId, label, color, moduleId }),
},
{
label: 'Duplicate',
icon: <Copy size={12} />,
onClick: () => handleDuplicateNode(nodeId),
},
{ separator: true as const },
{
label: 'Delete node',
icon: <Trash2 size={12} />,
variant: 'danger' as const,
onClick: () => handleDeleteNode(nodeId),
},
];
}
if (ctxMenu.kind === 'edge') {
const { edgeId, animated, edgeType } = ctxMenu;
const typeItems: ContextMenuEntry[] = EDGE_TYPES_CYCLE.map((t) => ({
label: EDGE_TYPE_LABELS[t],
icon: <ArrowRight size={12} />,
checked: edgeType === t,
onClick: () => handleSetEdgeType(edgeId, t),
}));
return [
{
label: animated ? 'Stop animation' : 'Animate edge',
icon: animated ? <ZapOff size={12} /> : <Zap size={12} />,
onClick: () => handleToggleEdgeAnimation(edgeId, animated),
},
{ separator: true as const },
...typeItems,
{ separator: true as const },
{
label: 'Delete connection',
icon: <Trash2 size={12} />,
variant: 'danger' as const,
onClick: () => handleDeleteEdge(edgeId),
},
];
}
return [];
}
// ---- Map management ----
async function handleSelectMap(id: string) {
try {
await loadMap(id);
} catch {
toast.error('Failed to load map');
}
}
async function handleCreateMap() {
const name = prompt('Map name:');
if (!name?.trim()) return;
try {
const map = await createMap(name.trim());
setActiveMap(map);
} catch {
toast.error('Failed to create map');
}
}
async function handlePopulate() {
if (!activeMap) return;
try {
const updated = await apiClient.maps.populate(activeMap.id);
setActiveMap(updated);
toast.success('Rack modules imported');
} catch {
toast.error('Failed to import rack modules');
}
}
return (
<div className="flex flex-col h-screen bg-[#0f1117]">
<MapToolbar
maps={maps}
activeMapId={activeMap?.id ?? null}
activeMapName={activeMap?.name ?? ''}
onSelectMap={handleSelectMap}
onCreateMap={handleCreateMap}
onPopulate={handlePopulate}
flowContainerRef={flowContainerRef}
/>
<div ref={flowContainerRef} className="flex-1 relative">
{!activeMap ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
<p className="text-slate-300 font-medium">No map selected</p>
<p className="text-slate-500 text-sm">
Select a map from the toolbar or create a new one.
</p>
</div>
) : (
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={onConnect}
onNodesDelete={handleNodesDelete}
onEdgesDelete={handleEdgesDelete}
onPaneContextMenu={onPaneContextMenu}
onNodeContextMenu={onNodeContextMenu}
onEdgeContextMenu={onEdgeContextMenu}
onNodeDoubleClick={onNodeDoubleClick}
onPaneClick={() => setCtxMenu(null)}
snapToGrid
snapGrid={[15, 15]}
fitView
deleteKeyCode="Delete"
className="bg-[#1e2433]"
>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="#2d3748"
/>
<Controls className="!bg-slate-800 !border-slate-700 !shadow-xl" />
<MiniMap
className="!bg-slate-900 !border-slate-700"
nodeColor="#475569"
maskColor="rgba(15,17,23,0.7)"
/>
</ReactFlow>
)}
</div>
{/* Context menu portal */}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
items={buildContextMenuItems()}
onClose={() => setCtxMenu(null)}
/>
)}
{/* Node edit modal */}
{nodeEditState && (
<NodeEditModal
open={true}
onClose={() => setNodeEditState(null)}
nodeId={nodeEditState.nodeId}
initialLabel={nodeEditState.label}
initialColor={nodeEditState.color}
initialModuleId={nodeEditState.moduleId}
onSaved={handleNodeEditSaved}
/>
)}
</div>
);
}
export function ServiceMapper() {
return (
<ReactFlowProvider>
<ServiceMapperInner />
</ReactFlowProvider>
);
}
@@ -0,0 +1,24 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Zap } from 'lucide-react';
export const ApiNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'API';
const method = (data as { method?: string }).method;
return (
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-yellow-500 border-yellow-500' : 'border-yellow-700'}`}>
<Handle type="target" position={Position.Top} className="!bg-yellow-400 !border-yellow-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Zap size={13} className="text-yellow-400 shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate flex-1">{label}</span>
{method && (
<span className="text-[10px] px-1 py-0.5 rounded bg-yellow-900/60 text-yellow-300 border border-yellow-700/50 font-mono shrink-0">
{method}
</span>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-yellow-400 !border-yellow-600" />
</div>
);
});
ApiNode.displayName = 'ApiNode';
@@ -0,0 +1,18 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Database } from 'lucide-react';
export const DatabaseNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Database';
return (
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-teal-500 border-teal-500' : 'border-teal-700'}`}>
<Handle type="target" position={Position.Top} className="!bg-teal-400 !border-teal-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Database size={13} className="text-teal-400 shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-teal-400 !border-teal-600" />
</div>
);
});
DatabaseNode.displayName = 'DatabaseNode';
@@ -0,0 +1,56 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import type { Module } from '../../../types';
import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS } from '../../../lib/constants';
import { Badge } from '../../ui/Badge';
export interface DeviceNodeData {
label: string;
module?: Module;
[key: string]: unknown;
}
export const DeviceNode = memo(({ data, selected }: NodeProps) => {
const nodeData = data as DeviceNodeData;
const mod = nodeData.module;
const colors = mod ? MODULE_TYPE_COLORS[mod.type] : null;
return (
<div
className={`min-w-[160px] max-w-[200px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden transition-all ${
selected ? 'ring-2 ring-blue-500 border-blue-500' : 'border-slate-600'
} ${colors ? colors.border : ''}`}
>
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
{/* Colored accent strip */}
{colors && <div className={`h-1 w-full ${colors.bg}`} />}
<div className="px-3 py-2">
<div className="flex items-center gap-1.5 mb-1">
<svg width="12" height="12" viewBox="0 0 18 18" fill="none" className="shrink-0 opacity-60">
<rect x="1" y="2" width="16" height="3" rx="1" fill="currentColor" />
<rect x="1" y="7" width="16" height="3" rx="1" fill="currentColor" opacity="0.7" />
<rect x="1" y="12" width="16" height="3" rx="1" fill="currentColor" opacity="0.4" />
</svg>
<span className="text-xs font-semibold text-slate-100 truncate">{nodeData.label}</span>
</div>
{mod && (
<div className="flex flex-wrap gap-1">
<Badge variant="slate" className="text-[10px]">{MODULE_TYPE_LABELS[mod.type]}</Badge>
{mod.ipAddress && (
<span className="text-[10px] text-slate-400 font-mono">{mod.ipAddress}</span>
)}
</div>
)}
{!mod && (
<span className="text-[10px] text-slate-500">Unlinked device</span>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div>
);
});
DeviceNode.displayName = 'DeviceNode';
@@ -0,0 +1,18 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Cloud } from 'lucide-react';
export const ExternalNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'External';
return (
<div className={`min-w-[140px] bg-slate-800 border-2 border-dashed rounded-lg shadow-lg ${selected ? 'ring-2 ring-slate-400 border-slate-400' : 'border-slate-500'}`}>
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Cloud size={13} className="text-slate-400 shrink-0" />
<span className="text-xs font-medium text-slate-300 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div>
);
});
ExternalNode.displayName = 'ExternalNode';
@@ -0,0 +1,18 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Shield } from 'lucide-react';
export const FirewallNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Firewall';
return (
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-red-500 border-red-500' : 'border-red-700'}`}>
<Handle type="target" position={Position.Top} className="!bg-red-400 !border-red-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Shield size={13} className="text-red-400 shrink-0" />
<span className="text-xs font-semibold text-slate-100 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-red-400 !border-red-600" />
</div>
);
});
FirewallNode.displayName = 'FirewallNode';
@@ -0,0 +1,18 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Scale } from 'lucide-react';
export const LBNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Load Balancer';
return (
<div className={`min-w-[140px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-orange-500 border-orange-500' : 'border-orange-700'}`}>
<Handle type="target" position={Position.Top} className="!bg-orange-400 !border-orange-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Scale size={13} className="text-orange-400 shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-orange-400 !border-orange-600" />
</div>
);
});
LBNode.displayName = 'LBNode';
@@ -0,0 +1,21 @@
import { memo } from 'react';
import { type NodeProps } from '@xyflow/react';
import { StickyNote } from 'lucide-react';
/** NoteNode has no handles — it's a free-floating annotation. */
export const NoteNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Note';
return (
<div
className={`min-w-[140px] max-w-[240px] bg-yellow-900/40 border rounded-lg shadow-lg ${
selected ? 'ring-2 ring-yellow-400 border-yellow-400' : 'border-yellow-700/60'
}`}
>
<div className="px-3 py-2 flex items-start gap-2">
<StickyNote size={12} className="text-yellow-400 shrink-0 mt-0.5" />
<span className="text-xs text-yellow-100/90 whitespace-pre-wrap break-words">{label}</span>
</div>
</div>
);
});
NoteNode.displayName = 'NoteNode';
@@ -0,0 +1,25 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Layers } from 'lucide-react';
export const ServiceNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Service';
const color = (data as { color?: string }).color ?? '#3b82f6';
return (
<div
className={`min-w-[140px] bg-slate-800 rounded-lg shadow-lg border overflow-hidden ${
selected ? 'ring-2 ring-blue-500 border-blue-500' : 'border-slate-600'
}`}
style={{ borderLeftColor: color, borderLeftWidth: 3 }}
>
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
<div className="px-3 py-2 flex items-center gap-2">
<Layers size={13} style={{ color }} className="shrink-0" />
<span className="text-xs font-medium text-slate-100 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div>
);
});
ServiceNode.displayName = 'ServiceNode';
@@ -0,0 +1,18 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { User } from 'lucide-react';
export const UserNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'User';
return (
<div className={`min-w-[120px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden ${selected ? 'ring-2 ring-slate-400 border-slate-400' : 'border-slate-600'}`}>
<Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
<div className="px-3 py-2 flex items-center gap-2">
<User size={13} className="text-slate-400 shrink-0" />
<span className="text-xs font-medium text-slate-300 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div>
);
});
UserNode.displayName = 'UserNode';
@@ -0,0 +1,21 @@
import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
export const VlanNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'VLAN';
const color = (data as { color?: string }).color ?? '#6366f1';
return (
<div
className={`min-w-[120px] rounded-lg shadow-lg border-2 ${selected ? 'ring-2 ring-offset-1 ring-offset-slate-900' : ''}`}
style={{ backgroundColor: `${color}22`, borderColor: color }}
>
<Handle type="target" position={Position.Top} style={{ borderColor: color }} />
<div className="px-3 py-2 flex items-center gap-2">
<div className="w-3 h-3 rounded-sm shrink-0" style={{ backgroundColor: color }} />
<span className="text-xs font-semibold text-slate-100 truncate">{label}</span>
</div>
<Handle type="source" position={Position.Bottom} style={{ borderColor: color }} />
</div>
);
});
VlanNode.displayName = 'VlanNode';
@@ -0,0 +1,241 @@
import { useState, useEffect, type FormEvent } from 'react';
import { toast } from 'sonner';
import type { ModuleType } from '../../types';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/Button';
import { useRackStore } from '../../store/useRackStore';
import {
MODULE_TYPE_LABELS,
MODULE_TYPE_COLORS,
MODULE_U_DEFAULTS,
MODULE_PORT_DEFAULTS,
} from '../../lib/constants';
import { cn } from '../../lib/utils';
interface AddModuleModalProps {
open: boolean;
onClose: () => void;
rackId: string;
uPosition: number;
/** Pre-select a type (e.g. from a palette drag) — skips the type picker step. */
initialType?: ModuleType;
}
const ALL_TYPES: ModuleType[] = [
'SWITCH', 'AGGREGATE_SWITCH', 'ROUTER', 'FIREWALL', 'PATCH_PANEL',
'MODEM', 'SERVER', 'NAS', 'PDU', 'AP', 'BLANK', 'OTHER',
];
export function AddModuleModal({ open, onClose, rackId, uPosition, initialType }: AddModuleModalProps) {
const { addModule } = useRackStore();
const [selectedType, setSelectedType] = useState<ModuleType | null>(initialType ?? null);
const [name, setName] = useState(initialType ? MODULE_TYPE_LABELS[initialType] : '');
const [uSize, setUSize] = useState(initialType ? MODULE_U_DEFAULTS[initialType] : 1);
const [portCount, setPortCount] = useState(initialType ? MODULE_PORT_DEFAULTS[initialType] : 0);
const [ipAddress, setIpAddress] = useState('');
const [manufacturer, setManufacturer] = useState('');
const [model, setModel] = useState('');
const [loading, setLoading] = useState(false);
// Sync state when modal opens with a new initialType (e.g. drag-drop reuse)
useEffect(() => {
if (open && initialType) {
setSelectedType(initialType);
setName(MODULE_TYPE_LABELS[initialType]);
setUSize(MODULE_U_DEFAULTS[initialType]);
setPortCount(MODULE_PORT_DEFAULTS[initialType]);
} else if (!open) {
reset();
}
}, [open, initialType]); // eslint-disable-line react-hooks/exhaustive-deps
function handleTypeSelect(type: ModuleType) {
setSelectedType(type);
setName(MODULE_TYPE_LABELS[type]);
setUSize(MODULE_U_DEFAULTS[type]);
setPortCount(MODULE_PORT_DEFAULTS[type]);
}
function reset() {
setSelectedType(null);
setName('');
setUSize(1);
setPortCount(0);
setIpAddress('');
setManufacturer('');
setModel('');
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!selectedType || !name.trim()) return;
setLoading(true);
try {
await addModule(rackId, {
name: name.trim(),
type: selectedType,
uPosition,
uSize,
portCount,
ipAddress: ipAddress.trim() || undefined,
manufacturer: manufacturer.trim() || undefined,
model: model.trim() || undefined,
});
toast.success(`${name.trim()} added at U${uPosition}`);
reset();
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to add module');
} finally {
setLoading(false);
}
}
function handleClose() {
if (!loading) {
reset();
onClose();
}
}
return (
<Modal open={open} onClose={handleClose} title={`Add Module — U${uPosition}`} size="md">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Type selector */}
{!selectedType ? (
<div>
<p className="text-xs text-slate-400 mb-2">Select device type</p>
<div className="grid grid-cols-3 gap-1.5">
{ALL_TYPES.map((type) => {
const colors = MODULE_TYPE_COLORS[type];
return (
<button
key={type}
type="button"
onClick={() => handleTypeSelect(type)}
className={cn(
'flex flex-col items-center gap-1 px-2 py-2 rounded border text-center hover:brightness-125 transition-all',
colors.bg,
colors.border
)}
>
<span className="text-[11px] font-medium text-white leading-tight">
{MODULE_TYPE_LABELS[type]}
</span>
<span className="text-[10px] text-slate-400">
{MODULE_U_DEFAULTS[type]}U
</span>
</button>
);
})}
</div>
</div>
) : (
<>
<div className="flex items-center gap-2">
<div
className={cn(
'px-2 py-0.5 rounded text-xs font-medium border',
MODULE_TYPE_COLORS[selectedType].bg,
MODULE_TYPE_COLORS[selectedType].border,
'text-white'
)}
>
{MODULE_TYPE_LABELS[selectedType]}
</div>
<button
type="button"
onClick={() => setSelectedType(null)}
className="text-xs text-slate-500 hover:text-slate-300 underline"
>
Change type
</button>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">
Name <span className="text-red-400">*</span>
</label>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<label className="block text-sm text-slate-300">Size (U)</label>
<input
type="number"
min={1}
max={20}
value={uSize}
onChange={(e) => setUSize(Number(e.target.value))}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Port count</label>
<input
type="number"
min={0}
max={128}
value={portCount}
onChange={(e) => setPortCount(Number(e.target.value))}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">IP Address</label>
<input
value={ipAddress}
onChange={(e) => setIpAddress(e.target.value)}
disabled={loading}
placeholder="192.168.1.1"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-sm text-slate-300">Manufacturer</label>
<input
value={manufacturer}
onChange={(e) => setManufacturer(e.target.value)}
disabled={loading}
placeholder="Cisco, Ubiquiti…"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Model</label>
<input
value={model}
onChange={(e) => setModel(e.target.value)}
disabled={loading}
placeholder="SG300-28…"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-1">
<Button variant="secondary" size="sm" type="button" onClick={handleClose} disabled={loading}>
Cancel
</Button>
<Button size="sm" type="submit" loading={loading} disabled={!name.trim()}>
Add Module
</Button>
</div>
</>
)}
</form>
</Modal>
);
}
@@ -0,0 +1,109 @@
import { useState, type FormEvent } from 'react';
import { toast } from 'sonner';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/Button';
import { useRackStore } from '../../store/useRackStore';
interface AddRackModalProps {
open: boolean;
onClose: () => void;
}
export function AddRackModal({ open, onClose }: AddRackModalProps) {
const { addRack } = useRackStore();
const [name, setName] = useState('');
const [totalU, setTotalU] = useState(42);
const [location, setLocation] = useState('');
const [loading, setLoading] = useState(false);
function reset() {
setName('');
setTotalU(42);
setLocation('');
}
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!name.trim()) return;
setLoading(true);
try {
await addRack(name.trim(), totalU, location.trim() || undefined);
toast.success(`Rack "${name.trim()}" created`);
reset();
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create rack');
} finally {
setLoading(false);
}
}
function handleClose() {
if (!loading) {
reset();
onClose();
}
}
return (
<Modal open={open} onClose={handleClose} title="Add Rack" size="sm">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label htmlFor="rack-name" className="block text-sm text-slate-300">
Name <span className="text-red-400">*</span>
</label>
<input
id="rack-name"
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
disabled={loading}
placeholder="e.g. Main Rack"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label htmlFor="rack-u" className="block text-sm text-slate-300">
Size (U)
</label>
<input
id="rack-u"
type="number"
min={1}
max={100}
value={totalU}
onChange={(e) => setTotalU(Number(e.target.value))}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label htmlFor="rack-location" className="block text-sm text-slate-300">
Location
</label>
<input
id="rack-location"
value={location}
onChange={(e) => setLocation(e.target.value)}
disabled={loading}
placeholder="e.g. Server Room"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-1">
<Button variant="secondary" size="sm" type="button" onClick={handleClose} disabled={loading}>
Cancel
</Button>
<Button size="sm" type="submit" loading={loading} disabled={!name.trim()}>
Create Rack
</Button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,146 @@
import { useState, useEffect, type FormEvent } from 'react';
import { toast } from 'sonner';
import type { Module } from '../../types';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/Button';
import { Badge } from '../ui/Badge';
import { useRackStore } from '../../store/useRackStore';
import { apiClient } from '../../api/client';
import { MODULE_TYPE_LABELS } from '../../lib/constants';
interface ModuleEditPanelProps {
module: Module;
open: boolean;
onClose: () => void;
}
export function ModuleEditPanel({ module, open, onClose }: ModuleEditPanelProps) {
const { updateModuleLocal } = useRackStore();
const [name, setName] = useState(module.name);
const [ipAddress, setIpAddress] = useState(module.ipAddress ?? '');
const [manufacturer, setManufacturer] = useState(module.manufacturer ?? '');
const [modelVal, setModelVal] = useState(module.model ?? '');
const [notes, setNotes] = useState(module.notes ?? '');
const [uSize, setUSize] = useState(module.uSize);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
setName(module.name);
setIpAddress(module.ipAddress ?? '');
setManufacturer(module.manufacturer ?? '');
setModelVal(module.model ?? '');
setNotes(module.notes ?? '');
setUSize(module.uSize);
}
}, [open, module]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setLoading(true);
try {
const updated = await apiClient.modules.update(module.id, {
name: name.trim(),
uSize,
ipAddress: ipAddress.trim() || undefined,
manufacturer: manufacturer.trim() || undefined,
model: modelVal.trim() || undefined,
notes: notes.trim() || undefined,
});
updateModuleLocal(module.id, updated);
toast.success('Module updated');
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Update failed');
} finally {
setLoading(false);
}
}
return (
<Modal open={open} onClose={onClose} title="Edit Module" size="md">
<form onSubmit={handleSubmit} className="space-y-3">
{/* Type (read-only) */}
<div className="flex items-center gap-2 pb-1">
<Badge variant="slate">{MODULE_TYPE_LABELS[module.type]}</Badge>
<span className="text-xs text-slate-500">U{module.uPosition} · rack position</span>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Name <span className="text-red-400">*</span></label>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-sm text-slate-300">Size (U)</label>
<input
type="number" min={1} max={20}
value={uSize}
onChange={(e) => setUSize(Number(e.target.value))}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">IP Address</label>
<input
value={ipAddress}
onChange={(e) => setIpAddress(e.target.value)}
disabled={loading}
placeholder="192.168.1.1"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-sm text-slate-300">Manufacturer</label>
<input
value={manufacturer}
onChange={(e) => setManufacturer(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Model</label>
<input
value={modelVal}
onChange={(e) => setModelVal(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Notes</label>
<textarea
rows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 resize-none"
/>
</div>
<div className="flex justify-end gap-3 pt-1">
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button size="sm" type="submit" loading={loading} disabled={!name.trim()}>
Save Changes
</Button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,263 @@
import { useState, useEffect, type FormEvent } from 'react';
import { toast } from 'sonner';
import type { Port, Vlan, VlanMode } from '../../types';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/Button';
import { Badge } from '../ui/Badge';
import { apiClient } from '../../api/client';
import { useRackStore } from '../../store/useRackStore';
interface PortConfigModalProps {
portId: string;
open: boolean;
onClose: () => void;
}
export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps) {
const { racks, fetchRacks } = useRackStore();
const [port, setPort] = useState<Port | null>(null);
const [vlans, setVlans] = useState<Vlan[]>([]);
const [label, setLabel] = useState('');
const [mode, setMode] = useState<VlanMode>('ACCESS');
const [nativeVlanId, setNativeVlanId] = useState<string>('');
const [taggedVlanIds, setTaggedVlanIds] = useState<string[]>([]);
const [notes, setNotes] = useState('');
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
// Quick-create VLAN
const [newVlanId, setNewVlanId] = useState('');
const [newVlanName, setNewVlanName] = useState('');
const [creatingVlan, setCreatingVlan] = useState(false);
// Find the port from store
useEffect(() => {
if (!open) return;
let found: Port | undefined;
for (const rack of racks) {
for (const mod of rack.modules) {
found = mod.ports.find((p) => p.id === portId);
if (found) break;
}
if (found) break;
}
if (found) {
setPort(found);
setLabel(found.label ?? '');
setMode(found.mode);
setNativeVlanId(found.nativeVlan?.toString() ?? '');
setTaggedVlanIds(found.vlans.filter((v) => v.tagged).map((v) => v.vlanId));
setNotes(found.notes ?? '');
}
}, [open, portId, racks]);
// Load VLAN list
useEffect(() => {
if (!open) return;
setFetching(true);
apiClient.vlans
.list()
.then(setVlans)
.catch(() => toast.error('Failed to load VLANs'))
.finally(() => setFetching(false));
}, [open]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setLoading(true);
try {
const vlanAssignments = [
...(mode === 'ACCESS' && nativeVlanId
? [{ vlanId: vlans.find((v) => v.vlanId === Number(nativeVlanId))?.id ?? '', tagged: false }]
: []),
...(mode !== 'ACCESS'
? taggedVlanIds.map((id) => ({ vlanId: id, tagged: true }))
: []),
].filter((v) => v.vlanId);
await apiClient.ports.update(portId, {
label: label.trim() || undefined,
mode,
nativeVlan: nativeVlanId ? Number(nativeVlanId) : null,
notes: notes.trim() || undefined,
vlans: vlanAssignments,
});
// Refresh all rack state so port dots and VLAN assignments are current
await fetchRacks();
toast.success('Port saved');
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Save failed');
} finally {
setLoading(false);
}
}
async function handleCreateVlan() {
const id = Number(newVlanId);
if (!id || !newVlanName.trim()) return;
setCreatingVlan(true);
try {
const created = await apiClient.vlans.create({ vlanId: id, name: newVlanName.trim() });
setVlans((v) => [...v, created].sort((a, b) => a.vlanId - b.vlanId));
setNewVlanId('');
setNewVlanName('');
toast.success(`VLAN ${id} created`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create VLAN');
} finally {
setCreatingVlan(false);
}
}
function toggleTaggedVlan(vlanDbId: string) {
setTaggedVlanIds((prev) =>
prev.includes(vlanDbId) ? prev.filter((id) => id !== vlanDbId) : [...prev, vlanDbId]
);
}
if (!port) return null;
return (
<Modal open={open} onClose={onClose} title={`Port ${port.portNumber} Configuration`} size="md">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Port info */}
<div className="flex items-center gap-2">
<Badge variant="slate">{port.portType}</Badge>
<span className="text-xs text-slate-500">Port #{port.portNumber}</span>
</div>
{/* Label */}
<div className="space-y-1">
<label className="block text-sm text-slate-300">Label</label>
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
disabled={loading}
placeholder="e.g. Server 1 uplink"
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
{/* Mode */}
<div className="space-y-1">
<label className="block text-sm text-slate-300">Mode</label>
<div className="flex gap-2">
{(['ACCESS', 'TRUNK', 'HYBRID'] as VlanMode[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setMode(m)}
className={`px-3 py-1.5 rounded text-xs font-medium border transition-colors ${
mode === m
? 'bg-blue-600 border-blue-500 text-white'
: 'bg-slate-900 border-slate-600 text-slate-400 hover:border-slate-500'
}`}
>
{m}
</button>
))}
</div>
</div>
{/* Native VLAN */}
<div className="space-y-1">
<label className="block text-sm text-slate-300">Native VLAN</label>
<select
value={nativeVlanId}
onChange={(e) => setNativeVlanId(e.target.value)}
disabled={loading || fetching}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
>
<option value=""> Untagged </option>
{vlans.map((v) => (
<option key={v.id} value={v.vlanId.toString()}>
VLAN {v.vlanId} {v.name}
</option>
))}
</select>
</div>
{/* Tagged VLANs — Trunk/Hybrid only */}
{mode !== 'ACCESS' && (
<div className="space-y-1">
<label className="block text-sm text-slate-300">Tagged VLANs</label>
<div className="max-h-32 overflow-y-auto bg-slate-900 border border-slate-600 rounded-lg p-2 flex flex-wrap gap-1.5">
{vlans.length === 0 && (
<span className="text-xs text-slate-600">No VLANs defined yet</span>
)}
{vlans.map((v) => (
<button
key={v.id}
type="button"
onClick={() => toggleTaggedVlan(v.id)}
className={`px-2 py-0.5 rounded text-xs border transition-colors ${
taggedVlanIds.includes(v.id)
? 'bg-blue-700 border-blue-500 text-white'
: 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-400'
}`}
>
{v.vlanId} {v.name}
</button>
))}
</div>
</div>
)}
{/* Quick-create VLAN */}
<div className="border border-slate-700 rounded-lg p-3 space-y-2">
<p className="text-xs font-medium text-slate-400">Quick-create VLAN</p>
<div className="flex gap-2">
<input
type="number"
min={1}
max={4094}
value={newVlanId}
onChange={(e) => setNewVlanId(e.target.value)}
placeholder="VLAN ID"
className="w-24 bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<input
value={newVlanName}
onChange={(e) => setNewVlanName(e.target.value)}
placeholder="Name"
className="flex-1 bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<Button
type="button"
size="sm"
variant="secondary"
onClick={handleCreateVlan}
loading={creatingVlan}
disabled={!newVlanId || !newVlanName.trim()}
>
Add
</Button>
</div>
</div>
{/* Notes */}
<div className="space-y-1">
<label className="block text-sm text-slate-300">Notes</label>
<textarea
rows={2}
value={notes}
onChange={(e) => setNotes(e.target.value)}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 resize-none"
/>
</div>
<div className="flex justify-end gap-3">
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button size="sm" type="submit" loading={loading}>
Save Port
</Button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,64 @@
import { useDraggable } from '@dnd-kit/core';
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',
];
function PaletteItem({ type }: { type: ModuleType }) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `palette-${type}`,
data: { type },
});
const colors = MODULE_TYPE_COLORS[type];
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
className={cn(
'flex items-center gap-2 px-2 py-1.5 rounded border text-left w-full cursor-grab active:cursor-grabbing transition-all select-none',
colors.bg,
colors.border,
isDragging ? 'opacity-40' : 'hover:brightness-125'
)}
aria-label={`Drag ${MODULE_TYPE_LABELS[type]} onto a rack slot`}
>
<div className={cn('w-2 h-2 rounded-sm shrink-0 brightness-150', colors.bg)} />
<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>
</div>
);
}
export function DevicePalette() {
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">Drag onto a slot or click a slot</p>
</div>
<div className="flex flex-col gap-1 p-2">
{ALL_TYPES.map((type) => (
<PaletteItem key={type} type={type} />
))}
</div>
</aside>
);
}
+258
View File
@@ -0,0 +1,258 @@
import { useState, useCallback, useRef } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { Trash2, GripVertical, GripHorizontal } 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 { racks, removeModuleLocal, updateModuleLocal } = 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);
// Resize state
const [previewUSize, setPreviewUSize] = useState<number | null>(null);
const isResizing = useRef(false);
const resizeStartY = useRef(0);
const resizeStartUSize = useRef(0);
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `module-${module.id}`,
disabled: isResizing.current,
data: {
dragType: 'module',
moduleId: module.id,
fromRackId: module.rackId,
fromUPosition: module.uPosition,
uSize: module.uSize,
label: module.name,
},
});
const colors = MODULE_TYPE_COLORS[module.type];
const displayUSize = previewUSize ?? module.uSize;
const height = displayUSize * U_HEIGHT_PX;
const hasPorts = module.ports.length > 0;
// Compute the maximum allowed uSize for this module (rack bounds + collision)
const maxResizeU = useCallback((): number => {
const rack = racks.find((r) => r.id === module.rackId);
if (!rack) return module.uSize;
// Bound by rack totalU
const rackMax = rack.totalU - module.uPosition + 1;
// Find the first module that starts at uPosition >= module.uPosition + 1 (anything below us)
const nextStart = rack.modules
.filter((m) => m.id !== module.id && m.uPosition > module.uPosition)
.reduce((min, m) => Math.min(min, m.uPosition), rack.totalU + 1);
const collisionMax = nextStart - module.uPosition;
return Math.min(rackMax, collisionMax);
}, [racks, module]);
function handleResizePointerDown(e: React.PointerEvent<HTMLDivElement>) {
e.preventDefault();
e.stopPropagation();
isResizing.current = true;
resizeStartY.current = e.clientY;
resizeStartUSize.current = module.uSize;
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
}
function handleResizePointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!isResizing.current) return;
const deltaY = e.clientY - resizeStartY.current;
const deltaU = Math.round(deltaY / U_HEIGHT_PX);
const max = maxResizeU();
const newU = Math.max(1, Math.min(resizeStartUSize.current + deltaU, max));
setPreviewUSize(newU);
}
async function handleResizePointerUp(e: React.PointerEvent<HTMLDivElement>) {
if (!isResizing.current) return;
isResizing.current = false;
(e.currentTarget as HTMLDivElement).releasePointerCapture(e.pointerId);
const finalU = previewUSize ?? module.uSize;
setPreviewUSize(null);
if (finalU === module.uSize) return;
try {
await apiClient.modules.update(module.id, { uSize: finalU });
updateModuleLocal(module.id, { uSize: finalU });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Resize failed');
}
}
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
ref={setNodeRef}
className={cn(
'relative w-full border-l-2 select-none flex flex-col justify-between px-2 py-0.5 overflow-hidden transition-opacity',
colors.bg,
colors.border,
isDragging ? 'opacity-0' : 'cursor-pointer',
!isDragging && hovered && 'brightness-110',
previewUSize !== null && 'ring-1 ring-white/30'
)}
style={{ height }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => !isDragging && !isResizing.current && setEditOpen(true)}
role="button"
tabIndex={0}
aria-label={`Edit ${module.name}`}
onKeyDown={(e) => e.key === 'Enter' && setEditOpen(true)}
>
{/* Drag handle */}
<div
{...listeners}
{...attributes}
className="absolute left-0 top-0 bottom-6 w-4 flex items-start justify-center pt-1.5 cursor-grab active:cursor-grabbing text-white/30 hover:text-white/70 transition-colors z-10 touch-none"
onClick={(e) => e.stopPropagation()}
aria-label={`Drag ${module.name}`}
>
<GripVertical size={10} />
</div>
{/* Main content */}
<div className="flex items-center gap-1 min-w-0 pl-3">
<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>
)}
{/* U-size preview label during resize */}
{previewUSize !== null && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="text-xs font-bold text-white/70 bg-black/30 px-1.5 py-0.5 rounded">
{previewUSize}U
</span>
</div>
)}
{/* Port dots — only if module has ports and enough height */}
{hasPorts && height >= 28 && previewUSize === null && (
<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 && previewUSize === null && (
<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>
)}
{/* Resize handle — bottom edge */}
<div
className={cn(
'absolute bottom-0 left-0 right-0 h-3 flex items-center justify-center z-20',
'cursor-ns-resize touch-none',
hovered || previewUSize !== null
? 'opacity-100'
: 'opacity-0 hover:opacity-100',
'transition-opacity'
)}
onPointerDown={handleResizePointerDown}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
onClick={(e) => e.stopPropagation()}
aria-label="Resize module"
title="Drag to resize"
>
<GripHorizontal size={10} className="text-white/50 pointer-events-none" />
</div>
</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);
}}
/>
)}
</>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { useState } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Trash2, MapPin, GripVertical } 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;
/** ID of the module currently being dragged — render its slots as droppable ghosts. */
draggingModuleId?: string | null;
}
export function RackColumn({ rack, draggingModuleId }: RackColumnProps) {
const { deleteRack } = useRackStore();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
// Sortable for rack reorder
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: rack.id,
data: { dragType: 'rack' },
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
const occupancy = buildOccupancyMap(rack.modules);
const renderedModuleIds = new Set<string>();
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);
}
}
return (
<>
<div ref={setNodeRef} style={style} className="flex flex-col min-w-[200px] w-48 shrink-0">
{/* Rack header — drag handle for reorder */}
<div className="flex items-center gap-1 bg-slate-700 border border-slate-600 rounded-t-lg px-2 py-1.5 group">
{/* Drag handle */}
<div
{...listeners}
{...attributes}
className="cursor-grab active:cursor-grabbing text-slate-500 hover:text-slate-300 transition-colors shrink-0 touch-none"
aria-label="Drag to reorder rack"
>
<GripVertical size={13} />
</div>
<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">
{Array.from({ length: rack.totalU }, (_, i) => i + 1).map((u) => {
const moduleId = occupancy.get(u) ?? null;
if (moduleId) {
const module = rack.modules.find((m) => m.id === moduleId);
if (!module) return null;
// Only render ModuleBlock at its top U
if (module.uPosition !== u) return null;
if (renderedModuleIds.has(moduleId)) return null;
renderedModuleIds.add(moduleId);
// If this module is being dragged, show empty droppable slot(s) instead
if (moduleId === draggingModuleId) {
return (
<RackSlot key={`ghost-${u}`} rackId={rack.id} uPosition={u} />
);
}
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}
/>
</>
);
}
+205
View File
@@ -0,0 +1,205 @@
import { useEffect, useRef, useState } from 'react';
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
type DragStartEvent,
type DragEndEvent,
} from '@dnd-kit/core';
import { SortableContext, horizontalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
import { toast } from 'sonner';
import { useRackStore } from '../../store/useRackStore';
import { apiClient } from '../../api/client';
import { RackToolbar } from './RackToolbar';
import { RackColumn } from './RackColumn';
import { DevicePalette } from './DevicePalette';
import { AddModuleModal } from '../modals/AddModuleModal';
import { RackSkeleton } from '../ui/Skeleton';
import type { ModuleType } from '../../types';
import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS } from '../../lib/constants';
import { cn } from '../../lib/utils';
interface PendingDrop {
rackId: string;
uPosition: number;
type: ModuleType;
}
function DragOverlayItem({ type }: { type: ModuleType }) {
const colors = MODULE_TYPE_COLORS[type];
return (
<div
className={cn(
'px-3 py-1.5 rounded border text-xs font-semibold text-white shadow-2xl opacity-90 pointer-events-none',
colors.bg,
colors.border
)}
>
{MODULE_TYPE_LABELS[type]}
</div>
);
}
function ModuleDragOverlay({ label }: { label: string }) {
return (
<div className="px-3 py-1.5 rounded border bg-slate-600 border-slate-400 text-xs font-semibold text-white shadow-2xl opacity-90 pointer-events-none">
{label}
</div>
);
}
export function RackPlanner() {
const { racks, loading, fetchRacks, moveModule, updateRack } = useRackStore();
const canvasRef = useRef<HTMLDivElement>(null);
// Drag state
const [activePaletteType, setActivePaletteType] = useState<ModuleType | null>(null);
const [activeDragModuleLabel, setActiveDragModuleLabel] = useState<string | null>(null);
const [draggingModuleId, setDraggingModuleId] = useState<string | null>(null);
const [pendingDrop, setPendingDrop] = useState<PendingDrop | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } })
);
useEffect(() => {
fetchRacks().catch(() => toast.error('Failed to load racks'));
}, [fetchRacks]);
function handleDragStart(event: DragStartEvent) {
const data = event.active.data.current as Record<string, unknown>;
if (data?.dragType === 'palette') {
setActivePaletteType(data.type as ModuleType);
} else if (data?.dragType === 'module') {
setDraggingModuleId(data.moduleId as string);
setActiveDragModuleLabel(data.label as string);
}
}
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
setActivePaletteType(null);
setActiveDragModuleLabel(null);
setDraggingModuleId(null);
if (!over) return;
const dragData = active.data.current as Record<string, unknown>;
const dropData = over.data.current as Record<string, unknown> | undefined;
// --- Palette → slot: open AddModuleModal pre-filled ---
if (dragData?.dragType === 'palette' && dropData?.dropType === 'slot') {
setPendingDrop({
type: dragData.type as ModuleType,
rackId: dropData.rackId as string,
uPosition: dropData.uPosition as number,
});
return;
}
// --- Module → slot: move the module ---
if (dragData?.dragType === 'module' && dropData?.dropType === 'slot') {
const moduleId = dragData.moduleId as string;
const targetRackId = dropData.rackId as string;
const targetUPosition = dropData.uPosition as number;
// No-op if dropped on own position
if (dragData.fromRackId === targetRackId && dragData.fromUPosition === targetUPosition) return;
try {
await moveModule(moduleId, targetRackId, targetUPosition);
toast.success('Module moved');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Move failed');
}
return;
}
// --- Rack header → rack header: reorder racks ---
if (dragData?.dragType === 'rack' && over.data.current?.dragType === 'rack') {
const oldIndex = racks.findIndex((r) => r.id === active.id);
const newIndex = racks.findIndex((r) => r.id === over.id);
if (oldIndex === newIndex) return;
const reordered = arrayMove(racks, oldIndex, newIndex);
// Persist new displayOrder values
try {
await Promise.all(
reordered.map((rack, idx) =>
rack.displayOrder !== idx ? apiClient.racks.update(rack.id, { displayOrder: idx }) : Promise.resolve(rack)
)
);
// Refresh store to sync
await fetchRacks();
} catch {
toast.error('Failed to save rack order');
await fetchRacks(); // rollback
}
}
}
const rackIds = racks.map((r) => r.id);
return (
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="flex flex-col h-screen bg-[#0f1117]">
<RackToolbar rackCanvasRef={canvasRef} />
<div className="flex flex-1 overflow-hidden">
<DevicePalette />
<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>
) : (
<SortableContext items={rackIds} strategy={horizontalListSortingStrategy}>
<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} draggingModuleId={draggingModuleId} />
))}
</div>
</SortableContext>
)}
</div>
</div>
</div>
<DragOverlay dropAnimation={null}>
{activePaletteType && <DragOverlayItem type={activePaletteType} />}
{activeDragModuleLabel && <ModuleDragOverlay label={activeDragModuleLabel} />}
</DragOverlay>
{pendingDrop && (
<AddModuleModal
open={true}
onClose={() => setPendingDrop(null)}
rackId={pendingDrop.rackId}
uPosition={pendingDrop.uPosition}
initialType={pendingDrop.type}
/>
)}
</DndContext>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { Plus } from 'lucide-react';
import { U_HEIGHT_PX } from '../../lib/constants';
import { cn } from '../../lib/utils';
import { AddModuleModal } from '../modals/AddModuleModal';
interface RackSlotProps {
rackId: string;
uPosition: number;
}
export function RackSlot({ rackId, uPosition }: RackSlotProps) {
const [addModuleOpen, setAddModuleOpen] = useState(false);
const { setNodeRef, isOver } = useDroppable({
id: `slot-${rackId}-${uPosition}`,
data: { dropType: 'slot', rackId, uPosition },
});
return (
<>
<div
ref={setNodeRef}
className={cn(
'w-full border border-dashed transition-colors group cursor-pointer flex items-center justify-between px-2',
isOver
? 'border-blue-400 bg-blue-500/15'
: 'border-slate-700/50 hover:border-blue-500/50 hover:bg-blue-500/5'
)}
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={cn(
'text-[10px] font-mono transition-colors',
isOver ? 'text-blue-400' : 'text-slate-600 group-hover:text-slate-500'
)}
>
U{uPosition}
</span>
<Plus
size={10}
className={cn(
'transition-opacity',
isOver
? 'text-blue-400 opacity-100'
: 'text-slate-700 group-hover:text-blue-500 opacity-0 group-hover:opacity-100'
)}
/>
</div>
<AddModuleModal
open={addModuleOpen}
onClose={() => setAddModuleOpen(false)}
rackId={rackId}
uPosition={uPosition}
/>
</>
);
}
@@ -0,0 +1,88 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Download, Map, LogOut, Tag } 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" variant="secondary" onClick={() => navigate('/vlans')}>
<Tag size={14} />
VLANs
</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)} />
</>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react';
import { cn } from '../../lib/utils';
interface BadgeProps {
children: ReactNode;
variant?: 'default' | 'blue' | 'green' | 'red' | 'yellow' | 'purple' | 'slate';
className?: string;
}
const variants = {
default: 'bg-slate-700 text-slate-300',
blue: 'bg-blue-900/60 text-blue-300 border border-blue-700/50',
green: 'bg-green-900/60 text-green-300 border border-green-700/50',
red: 'bg-red-900/60 text-red-300 border border-red-700/50',
yellow: 'bg-yellow-900/60 text-yellow-300 border border-yellow-700/50',
purple: 'bg-purple-900/60 text-purple-300 border border-purple-700/50',
slate: 'bg-slate-800 text-slate-400 border border-slate-700',
};
export function Badge({ children, variant = 'default', className }: BadgeProps) {
return (
<span
className={cn(
'inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium',
variants[variant],
className
)}
>
{children}
</span>
);
}
+61
View File
@@ -0,0 +1,61 @@
import type { ButtonHTMLAttributes } from 'react';
import { cn } from '../../lib/utils';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'sm' | 'md' | 'lg';
loading?: boolean;
}
export function Button({
variant = 'primary',
size = 'md',
loading = false,
disabled,
className,
children,
...props
}: ButtonProps) {
const base =
'inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-50 disabled:pointer-events-none';
const variants = {
primary: 'bg-blue-600 hover:bg-blue-500 text-white focus:ring-blue-500',
secondary:
'bg-slate-700 hover:bg-slate-600 text-slate-100 border border-slate-600 focus:ring-slate-500',
ghost: 'hover:bg-slate-700 text-slate-300 hover:text-slate-100 focus:ring-slate-500',
danger: 'bg-red-700 hover:bg-red-600 text-white focus:ring-red-500',
};
const sizes = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-4 py-2 text-sm',
lg: 'px-5 py-2.5 text-base',
};
return (
<button
disabled={disabled || loading}
className={cn(base, variants[variant], sizes[size], className)}
{...props}
>
{loading && (
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{children}
</button>
);
}
@@ -0,0 +1,36 @@
import { Modal } from './Modal';
import { Button } from './Button';
interface ConfirmDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => void | Promise<void>;
title: string;
message: string;
confirmLabel?: string;
loading?: boolean;
}
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
message,
confirmLabel = 'Delete',
loading = false,
}: ConfirmDialogProps) {
return (
<Modal open={open} onClose={onClose} title={title} size="sm">
<p className="text-sm text-slate-300 mb-5">{message}</p>
<div className="flex justify-end gap-3">
<Button variant="secondary" size="sm" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button variant="danger" size="sm" onClick={onConfirm} loading={loading}>
{confirmLabel}
</Button>
</div>
</Modal>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { type ReactNode, useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { cn } from '../../lib/utils';
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
}
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-2xl',
};
export function Modal({ open, onClose, title, children, size = 'md', className }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
// Close on Escape
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onClose]);
// Trap focus — scroll lock
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Panel */}
<div
ref={dialogRef}
className={cn(
'relative w-full bg-slate-800 border border-slate-700 rounded-xl shadow-2xl',
sizeClasses[size],
className
)}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700">
<h2 id="modal-title" className="text-base font-semibold text-slate-100">
{title}
</h2>
<button
onClick={onClose}
aria-label="Close modal"
className="p-1 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors"
>
<X size={16} />
</button>
</div>
{/* Body */}
<div className="px-5 py-4">{children}</div>
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { cn } from '../../lib/utils';
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className }: SkeletonProps) {
return (
<div
className={cn('animate-pulse rounded bg-slate-700/60', className)}
aria-hidden="true"
/>
);
}
export function RackSkeleton() {
return (
<div className="flex gap-4 p-4">
{[1, 2].map((i) => (
<div key={i} className="w-48 flex flex-col gap-1">
<Skeleton className="h-6 w-full mb-2" />
{Array.from({ length: 12 }).map((_, j) => (
<Skeleton key={j} className="h-7 w-full" />
))}
</div>
))}
</div>
);
}
+399
View File
@@ -0,0 +1,399 @@
import { useState, useEffect, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Pencil, Trash2, Check, X, Server, Map, LogOut, Tag } from 'lucide-react';
import { toast } from 'sonner';
import type { Vlan } from '../../types';
import { apiClient } from '../../api/client';
import { useAuthStore } from '../../store/useAuthStore';
import { Button } from '../ui/Button';
import { ConfirmDialog } from '../ui/ConfirmDialog';
const DEFAULT_COLOR = '#3b82f6';
// ---- Add VLAN form ----
function AddVlanForm({ onCreated }: { onCreated: (v: Vlan) => void }) {
const [vlanId, setVlanId] = useState('');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [color, setColor] = useState(DEFAULT_COLOR);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
const id = Number(vlanId);
if (!id || !name.trim()) return;
setLoading(true);
try {
const created = await apiClient.vlans.create({ vlanId: id, name: name.trim(), description: description.trim() || undefined, color });
onCreated(created);
setVlanId('');
setName('');
setDescription('');
setColor(DEFAULT_COLOR);
toast.success(`VLAN ${id} created`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create VLAN');
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="flex items-end gap-3 p-4 bg-slate-800/50 border border-slate-700 rounded-xl">
<div className="flex flex-col gap-1">
<label className="text-xs text-slate-400">VLAN ID</label>
<input
type="number"
min={1}
max={4094}
value={vlanId}
onChange={(e) => setVlanId(e.target.value)}
placeholder="e.g. 100"
required
className="w-24 bg-slate-900 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex flex-col gap-1 flex-1">
<label className="text-xs text-slate-400">Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Management"
required
className="bg-slate-900 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex flex-col gap-1 flex-1 hidden sm:flex">
<label className="text-xs text-slate-400">Description</label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional"
className="bg-slate-900 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-slate-400">Color</label>
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-9 h-9 rounded-lg cursor-pointer bg-transparent border border-slate-600 p-0.5"
/>
</div>
<Button type="submit" size="sm" loading={loading} disabled={!vlanId || !name.trim()}>
<Plus size={14} />
Add VLAN
</Button>
</form>
);
}
// ---- Inline editable row ----
interface VlanRowProps {
vlan: Vlan;
onUpdated: (v: Vlan) => void;
onDeleted: (id: string) => void;
}
function VlanRow({ vlan, onUpdated, onDeleted }: VlanRowProps) {
const [editing, setEditing] = useState(false);
const [name, setName] = useState(vlan.name);
const [description, setDescription] = useState(vlan.description ?? '');
const [color, setColor] = useState(vlan.color ?? DEFAULT_COLOR);
const [saving, setSaving] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
function startEdit() {
setName(vlan.name);
setDescription(vlan.description ?? '');
setColor(vlan.color ?? DEFAULT_COLOR);
setEditing(true);
}
function cancelEdit() {
setEditing(false);
}
async function handleSave() {
if (!name.trim()) return;
setSaving(true);
try {
const updated = await apiClient.vlans.update(vlan.id, {
name: name.trim(),
description: description.trim() || undefined,
color,
});
onUpdated(updated);
setEditing(false);
toast.success('VLAN updated');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Update failed');
} finally {
setSaving(false);
}
}
async function handleDelete() {
setDeleting(true);
try {
await apiClient.vlans.delete(vlan.id);
onDeleted(vlan.id);
toast.success(`VLAN ${vlan.vlanId} deleted`);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed');
} finally {
setDeleting(false);
setConfirmDelete(false);
}
}
if (editing) {
return (
<>
<tr className="bg-slate-800/60">
<td className="px-4 py-2 font-mono text-sm text-slate-300 whitespace-nowrap">
{vlan.vlanId}
</td>
<td className="px-4 py-2">
<input
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
className="w-full bg-slate-900 border border-slate-600 rounded px-2 py-1 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</td>
<td className="px-4 py-2 hidden sm:table-cell">
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description"
className="w-full bg-slate-900 border border-slate-600 rounded px-2 py-1 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-7 h-7 rounded cursor-pointer bg-transparent border border-slate-600 p-0.5"
/>
<span className="text-xs text-slate-500 font-mono hidden md:inline">{color}</span>
</div>
</td>
<td className="px-4 py-2">
<div className="flex items-center gap-1">
<button
onClick={handleSave}
disabled={saving || !name.trim()}
className="p-1.5 rounded bg-blue-600 hover:bg-blue-500 text-white disabled:opacity-50 transition-colors"
aria-label="Save"
>
<Check size={13} />
</button>
<button
onClick={cancelEdit}
className="p-1.5 rounded bg-slate-700 hover:bg-slate-600 text-slate-300 transition-colors"
aria-label="Cancel"
>
<X size={13} />
</button>
</div>
</td>
</tr>
</>
);
}
return (
<>
<tr className="border-t border-slate-700/50 hover:bg-slate-800/40 transition-colors group">
<td className="px-4 py-3 font-mono text-sm text-slate-300 whitespace-nowrap">
{vlan.vlanId}
</td>
<td className="px-4 py-3 text-sm text-slate-100">{vlan.name}</td>
<td className="px-4 py-3 text-sm text-slate-400 hidden sm:table-cell">
{vlan.description ?? <span className="text-slate-600"></span>}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div
className="w-5 h-5 rounded-full border border-slate-600 shrink-0"
style={{ backgroundColor: vlan.color ?? DEFAULT_COLOR }}
/>
<span className="text-xs text-slate-500 font-mono hidden md:inline">
{vlan.color ?? DEFAULT_COLOR}
</span>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={startEdit}
className="p-1.5 rounded hover:bg-slate-700 text-slate-400 hover:text-slate-200 transition-colors"
aria-label={`Edit VLAN ${vlan.vlanId}`}
>
<Pencil size={13} />
</button>
<button
onClick={() => setConfirmDelete(true)}
className="p-1.5 rounded hover:bg-red-800/50 text-slate-400 hover:text-red-400 transition-colors"
aria-label={`Delete VLAN ${vlan.vlanId}`}
>
<Trash2 size={13} />
</button>
</div>
</td>
</tr>
<ConfirmDialog
open={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={handleDelete}
title="Delete VLAN"
message={`Delete VLAN ${vlan.vlanId} "${vlan.name}"? Port assignments using this VLAN will be removed.`}
confirmLabel="Delete"
loading={deleting}
/>
</>
);
}
// ---- Main page ----
export function VlanPage() {
const navigate = useNavigate();
const { logout } = useAuthStore();
const [vlans, setVlans] = useState<Vlan[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
apiClient.vlans
.list()
.then((v) => setVlans(v.sort((a, b) => a.vlanId - b.vlanId)))
.catch(() => toast.error('Failed to load VLANs'))
.finally(() => setLoading(false));
}, []);
function handleCreated(v: Vlan) {
setVlans((prev) => [...prev, v].sort((a, b) => a.vlanId - b.vlanId));
}
function handleUpdated(v: Vlan) {
setVlans((prev) => prev.map((x) => (x.id === v.id ? v : x)));
}
function handleDeleted(id: string) {
setVlans((prev) => prev.filter((x) => x.id !== id));
}
async function handleLogout() {
await logout();
navigate('/login', { replace: true });
}
return (
<div className="min-h-screen bg-[#0f1117] flex flex-col">
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700">
<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">VLAN Management</span>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => navigate('/rack')}>
<Server size={14} />
Rack Planner
</Button>
<Button size="sm" variant="secondary" onClick={() => navigate('/map')}>
<Map size={14} />
Service Map
</Button>
<Button size="sm" variant="ghost" onClick={handleLogout} aria-label="Sign out">
<LogOut size={14} />
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 p-6 max-w-4xl w-full mx-auto">
<div className="mb-6 flex items-center gap-3">
<div className="w-8 h-8 bg-violet-600 rounded-lg flex items-center justify-center">
<Tag size={16} className="text-white" />
</div>
<div>
<h1 className="text-lg font-bold text-slate-100">VLANs</h1>
<p className="text-xs text-slate-500">
Define and manage VLAN labels for port configuration
</p>
</div>
<div className="ml-auto">
<span className="text-sm text-slate-500">{vlans.length} VLANs</span>
</div>
</div>
<div className="space-y-4">
<AddVlanForm onCreated={handleCreated} />
{loading ? (
<div className="text-center py-12 text-slate-500 text-sm">Loading</div>
) : vlans.length === 0 ? (
<div className="text-center py-12 text-slate-600 text-sm">
No VLANs defined yet. Add one above.
</div>
) : (
<div className="bg-slate-800 border border-slate-700 rounded-xl overflow-hidden">
<table className="w-full text-left">
<thead>
<tr className="bg-slate-700/50">
<th className="px-4 py-2.5 text-xs font-semibold text-slate-400 uppercase tracking-wider w-20">
ID
</th>
<th className="px-4 py-2.5 text-xs font-semibold text-slate-400 uppercase tracking-wider">
Name
</th>
<th className="px-4 py-2.5 text-xs font-semibold text-slate-400 uppercase tracking-wider hidden sm:table-cell">
Description
</th>
<th className="px-4 py-2.5 text-xs font-semibold text-slate-400 uppercase tracking-wider w-36">
Color
</th>
<th className="px-4 py-2.5 w-20" />
</tr>
</thead>
<tbody>
{vlans.map((v) => (
<VlanRow
key={v.id}
vlan={v}
onUpdated={handleUpdated}
onDeleted={handleDeleted}
/>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-[#0f1117] text-slate-100 antialiased;
}
/* Custom scrollbar — dark themed */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-slate-900;
}
::-webkit-scrollbar-thumb {
@apply bg-slate-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-slate-500;
}
}
@layer utilities {
.rack-slot-height {
height: 1.75rem; /* 28px per U */
}
}
+76
View File
@@ -0,0 +1,76 @@
import type { ModuleType } from '../types';
// ---- Port count defaults per module type ----
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,
};
// ---- U-height defaults per module type ----
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,
};
// ---- Module type display labels ----
export const MODULE_TYPE_LABELS: Record<ModuleType, string> = {
SWITCH: 'Switch',
AGGREGATE_SWITCH: 'Agg Switch',
MODEM: 'Modem',
ROUTER: 'Router',
NAS: 'NAS',
PDU: 'PDU',
PATCH_PANEL: 'Patch Panel',
SERVER: 'Server',
FIREWALL: 'Firewall',
AP: 'Access Point',
BLANK: 'Blank',
OTHER: 'Other',
};
// ---- Tailwind bg+border color per module type ----
export const MODULE_TYPE_COLORS: Record<ModuleType, { bg: string; border: string; text: string }> =
{
SWITCH: { bg: 'bg-blue-700', border: 'border-blue-500', text: 'text-blue-100' },
AGGREGATE_SWITCH: {
bg: 'bg-indigo-700',
border: 'border-indigo-500',
text: 'text-indigo-100',
},
MODEM: { bg: 'bg-green-700', border: 'border-green-500', text: 'text-green-100' },
ROUTER: { bg: 'bg-teal-700', border: 'border-teal-500', text: 'text-teal-100' },
NAS: { bg: 'bg-purple-700', border: 'border-purple-500', text: 'text-purple-100' },
PDU: { bg: 'bg-yellow-700', border: 'border-yellow-500', text: 'text-yellow-100' },
PATCH_PANEL: { bg: 'bg-slate-600', border: 'border-slate-400', text: 'text-slate-100' },
SERVER: { bg: 'bg-slate-700', border: 'border-slate-500', text: 'text-slate-100' },
FIREWALL: { bg: 'bg-red-700', border: 'border-red-500', text: 'text-red-100' },
AP: { bg: 'bg-cyan-700', border: 'border-cyan-500', text: 'text-cyan-100' },
BLANK: { bg: 'bg-slate-800', border: 'border-slate-700', text: 'text-slate-500' },
OTHER: { bg: 'bg-slate-600', border: 'border-slate-500', text: 'text-slate-100' },
};
// ---- U-slot height in px (used for layout calculations) ----
export const U_HEIGHT_PX = 28;
// ---- Default rack size ----
export const DEFAULT_RACK_U = 42;
+25
View File
@@ -0,0 +1,25 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/** Conditional className composition — Tailwind-aware merge. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Returns all U-slot numbers occupied by a module. */
export function occupiedSlots(uPosition: number, uSize: number): number[] {
return Array.from({ length: uSize }, (_, i) => uPosition + i);
}
/** Build a Set of occupied U-slots from a list of modules. */
export function buildOccupancyMap(
modules: Array<{ id: string; uPosition: number; uSize: number }>
): Map<number, string> {
const map = new Map<number, string>();
for (const m of modules) {
for (let u = m.uPosition; u < m.uPosition + m.uSize; u++) {
map.set(u, m.id);
}
}
return map;
}
+15
View File
@@ -0,0 +1,15 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Toaster } from 'sonner';
import App from './App';
import './index.css';
const root = document.getElementById('root');
if (!root) throw new Error('Root element not found');
createRoot(root).render(
<StrictMode>
<App />
<Toaster theme="dark" position="bottom-right" richColors closeButton />
</StrictMode>
);
+37
View File
@@ -0,0 +1,37 @@
import { create } from 'zustand';
import { apiClient } from '../api/client';
interface AuthState {
isAuthenticated: boolean;
loading: boolean;
checkAuth: () => Promise<void>;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
isAuthenticated: false,
loading: true,
checkAuth: async () => {
try {
await apiClient.auth.me();
set({ isAuthenticated: true, loading: false });
} catch {
set({ isAuthenticated: false, loading: false });
}
},
login: async (username, password) => {
await apiClient.auth.login(username, password);
set({ isAuthenticated: true });
},
logout: async () => {
try {
await apiClient.auth.logout();
} finally {
set({ isAuthenticated: false });
}
},
}));
+58
View File
@@ -0,0 +1,58 @@
import { create } from 'zustand';
import type { ServiceMap, ServiceMapSummary } from '../types';
import { apiClient } from '../api/client';
interface MapState {
maps: ServiceMapSummary[];
activeMap: ServiceMap | null;
loading: boolean;
fetchMaps: () => Promise<void>;
loadMap: (id: string) => Promise<void>;
createMap: (name: string, description?: string) => Promise<ServiceMap>;
deleteMap: (id: string) => Promise<void>;
setActiveMap: (map: ServiceMap | null) => void;
}
export const useMapStore = create<MapState>((set) => ({
maps: [],
activeMap: null,
loading: false,
fetchMaps: async () => {
set({ loading: true });
try {
const maps = await apiClient.maps.list();
set({ maps, loading: false });
} catch {
set({ loading: false });
throw new Error('Failed to load maps');
}
},
loadMap: async (id) => {
set({ loading: true });
try {
const map = await apiClient.maps.get(id);
set({ activeMap: map, loading: false });
} catch {
set({ loading: false });
throw new Error('Failed to load map');
}
},
createMap: async (name, description) => {
const map = await apiClient.maps.create({ name, description });
set((s) => ({ maps: [{ id: map.id, name: map.name, description: map.description, createdAt: map.createdAt, updatedAt: map.updatedAt }, ...s.maps] }));
return map;
},
deleteMap: async (id) => {
await apiClient.maps.delete(id);
set((s) => ({
maps: s.maps.filter((m) => m.id !== id),
activeMap: s.activeMap?.id === id ? null : s.activeMap,
}));
},
setActiveMap: (map) => set({ activeMap: map }),
}));
+109
View File
@@ -0,0 +1,109 @@
import { create } from 'zustand';
import type { Rack, Module } from '../types';
import { apiClient } from '../api/client';
interface RackState {
racks: Rack[];
loading: boolean;
selectedModuleId: string | null;
// Fetch
fetchRacks: () => Promise<void>;
// Rack CRUD
addRack: (name: string, totalU?: number, location?: string) => Promise<Rack>;
updateRack: (id: string, data: Partial<{ name: string; totalU: number; location: string; displayOrder: number }>) => Promise<void>;
deleteRack: (id: string) => Promise<void>;
// Module CRUD (optimistic update helpers)
addModule: (rackId: string, data: Parameters<typeof apiClient.racks.addModule>[1]) => Promise<Module>;
moveModule: (moduleId: string, targetRackId: string, targetUPosition: number) => Promise<void>;
updateModuleLocal: (moduleId: string, data: Partial<Module>) => void;
removeModuleLocal: (moduleId: string) => void;
// Selection
setSelectedModule: (id: string | null) => void;
}
export const useRackStore = create<RackState>((set, get) => ({
racks: [],
loading: false,
selectedModuleId: null,
fetchRacks: async () => {
set({ loading: true });
try {
const racks = await apiClient.racks.list();
set({ racks, loading: false });
} catch {
set({ loading: false });
throw new Error('Failed to load racks');
}
},
addRack: async (name, totalU = 42, location) => {
const rack = await apiClient.racks.create({ name, totalU, location });
set((s) => ({ racks: [...s.racks, rack].sort((a, b) => a.displayOrder - b.displayOrder) }));
return rack;
},
updateRack: async (id, data) => {
const updated = await apiClient.racks.update(id, data);
set((s) => ({
racks: s.racks
.map((r) => (r.id === id ? updated : r))
.sort((a, b) => a.displayOrder - b.displayOrder),
}));
},
deleteRack: async (id) => {
await apiClient.racks.delete(id);
set((s) => ({ racks: s.racks.filter((r) => r.id !== id) }));
},
addModule: async (rackId, data) => {
const module = await apiClient.racks.addModule(rackId, data);
set((s) => ({
racks: s.racks.map((r) =>
r.id === rackId
? { ...r, modules: [...r.modules, module].sort((a, b) => a.uPosition - b.uPosition) }
: r
),
}));
return module;
},
moveModule: async (moduleId, targetRackId, targetUPosition) => {
const updated = await apiClient.modules.move(moduleId, targetRackId, targetUPosition);
set((s) => {
// Remove from source rack, insert into target rack
const racks = s.racks.map((r) => ({
...r,
modules: r.modules.filter((m) => m.id !== moduleId),
}));
return {
racks: racks.map((r) =>
r.id === targetRackId
? { ...r, modules: [...r.modules, updated].sort((a, b) => a.uPosition - b.uPosition) }
: r
),
};
});
},
updateModuleLocal: (moduleId, data) => {
set((s) => ({
racks: s.racks.map((r) => ({
...r,
modules: r.modules.map((m) => (m.id === moduleId ? { ...m, ...data } : m)),
})),
}));
},
removeModuleLocal: (moduleId) => {
set((s) => ({
racks: s.racks.map((r) => ({
...r,
modules: r.modules.filter((m) => m.id !== moduleId),
})),
}));
},
setSelectedModule: (id) => set({ selectedModuleId: id }),
}));
+129
View File
@@ -0,0 +1,129 @@
// ---- Enums (mirror Prisma enums) ----
export type ModuleType =
| 'SWITCH'
| 'AGGREGATE_SWITCH'
| 'MODEM'
| 'ROUTER'
| 'NAS'
| 'PDU'
| 'PATCH_PANEL'
| 'SERVER'
| 'FIREWALL'
| 'AP'
| 'BLANK'
| 'OTHER';
export type PortType = 'ETHERNET' | 'SFP' | 'SFP_PLUS' | 'QSFP' | 'CONSOLE' | 'UPLINK';
export type VlanMode = 'ACCESS' | 'TRUNK' | 'HYBRID';
export type NodeType =
| 'SERVICE'
| 'DATABASE'
| 'API'
| 'DEVICE'
| 'EXTERNAL'
| 'USER'
| 'VLAN'
| 'FIREWALL'
| 'LOAD_BALANCER'
| 'NOTE';
// ---- Domain models ----
export interface Vlan {
id: string;
vlanId: number;
name: string;
description?: string;
color?: string;
}
export interface PortVlanAssignment {
vlanId: string;
vlan: Vlan;
tagged: boolean;
}
export interface Port {
id: string;
moduleId: string;
portNumber: number;
label?: string;
portType: PortType;
mode: VlanMode;
nativeVlan?: number;
vlans: PortVlanAssignment[];
notes?: string;
}
export interface Module {
id: string;
rackId: string;
name: string;
type: ModuleType;
uPosition: number;
uSize: number;
manufacturer?: string;
model?: string;
ipAddress?: string;
notes?: string;
ports: Port[];
createdAt: string;
updatedAt: string;
}
export interface Rack {
id: string;
name: string;
totalU: number;
location?: string;
displayOrder: number;
modules: Module[];
createdAt: string;
updatedAt: string;
}
export interface ServiceNode {
id: string;
mapId: string;
label: string;
nodeType: NodeType;
positionX: number;
positionY: number;
metadata?: string;
color?: string;
icon?: string;
moduleId?: string;
module?: Module;
}
export interface ServiceEdge {
id: string;
mapId: string;
sourceId: string;
targetId: string;
label?: string;
edgeType: string;
animated: boolean;
metadata?: string;
}
export interface ServiceMap {
id: string;
name: string;
description?: string;
nodes: ServiceNode[];
edges: ServiceEdge[];
createdAt: string;
updatedAt: string;
}
export interface ServiceMapSummary {
id: string;
name: string;
description?: string;
createdAt: string;
updatedAt: string;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
// RackMapper palette aliases
surface: 'rgb(30 36 51)', // slate-800 equivalent
border: 'rgb(51 65 85)', // slate-700
accent: 'rgb(59 130 246)', // blue-500
danger: 'rgb(239 68 68)', // red-500
},
},
},
plugins: [],
} satisfies Config;
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
});
+26
View File
@@ -0,0 +1,26 @@
version: '3.8'
services:
rackmapper:
build: .
container_name: rackmapper
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- PORT=3001
- DATABASE_URL=file:./data/rackmapper.db
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRY=${JWT_EXPIRY:-8h}
volumes:
# Persists SQLite database across container restarts
- ./data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/auth/me"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
+5247
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"name": "rackmapper",
"version": "1.0.0",
"private": true,
"description": "Web-based network rack planner and service mapper",
"scripts": {
"dev": "concurrently -n server,client -c cyan,magenta \"npm run dev:server\" \"npm run dev:client\"",
"dev:server": "nodemon --exec tsx server/index.ts --watch server --ext ts",
"dev:client": "cd client && npm run dev",
"build": "npm run build:server && cd client && npm run build",
"build:server": "tsc -p tsconfig.json",
"start": "node dist/server/index.js",
"typecheck": "tsc --noEmit && cd client && tsc --noEmit",
"lint": "eslint \"server/**/*.ts\" \"scripts/**/*.ts\"",
"lint:fix": "eslint \"server/**/*.ts\" \"scripts/**/*.ts\" --fix",
"format": "prettier --write \"**/*.{ts,tsx,json,md}\" --ignore-path .gitignore",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@prisma/client": "^5.22.0",
"better-sqlite3": "^11.5.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.9.0",
"concurrently": "^9.1.0",
"eslint": "^9.14.0",
"nodemon": "^3.1.7",
"prettier": "^3.3.3",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3",
"vitest": "^2.1.5"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
@@ -0,0 +1,103 @@
-- CreateTable
CREATE TABLE "Rack" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"totalU" INTEGER NOT NULL DEFAULT 42,
"location" TEXT,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Module" (
"id" TEXT NOT NULL PRIMARY KEY,
"rackId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"uPosition" INTEGER NOT NULL,
"uSize" INTEGER NOT NULL DEFAULT 1,
"manufacturer" TEXT,
"model" TEXT,
"ipAddress" TEXT,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Module_rackId_fkey" FOREIGN KEY ("rackId") REFERENCES "Rack" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Port" (
"id" TEXT NOT NULL PRIMARY KEY,
"moduleId" TEXT NOT NULL,
"portNumber" INTEGER NOT NULL,
"label" TEXT,
"portType" TEXT NOT NULL DEFAULT 'ETHERNET',
"mode" TEXT NOT NULL DEFAULT 'ACCESS',
"nativeVlan" INTEGER,
"notes" TEXT,
CONSTRAINT "Port_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "Module" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Vlan" (
"id" TEXT NOT NULL PRIMARY KEY,
"vlanId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"color" TEXT
);
-- CreateTable
CREATE TABLE "PortVlan" (
"portId" TEXT NOT NULL,
"vlanId" TEXT NOT NULL,
"tagged" BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY ("portId", "vlanId"),
CONSTRAINT "PortVlan_portId_fkey" FOREIGN KEY ("portId") REFERENCES "Port" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "PortVlan_vlanId_fkey" FOREIGN KEY ("vlanId") REFERENCES "Vlan" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ServiceMap" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "ServiceNode" (
"id" TEXT NOT NULL PRIMARY KEY,
"mapId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"nodeType" TEXT NOT NULL,
"positionX" REAL NOT NULL,
"positionY" REAL NOT NULL,
"metadata" TEXT,
"color" TEXT,
"icon" TEXT,
"moduleId" TEXT,
CONSTRAINT "ServiceNode_mapId_fkey" FOREIGN KEY ("mapId") REFERENCES "ServiceMap" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ServiceNode_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "Module" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ServiceEdge" (
"id" TEXT NOT NULL PRIMARY KEY,
"mapId" TEXT NOT NULL,
"sourceId" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"label" TEXT,
"edgeType" TEXT NOT NULL DEFAULT 'smoothstep',
"animated" BOOLEAN NOT NULL DEFAULT false,
"metadata" TEXT,
CONSTRAINT "ServiceEdge_mapId_fkey" FOREIGN KEY ("mapId") REFERENCES "ServiceMap" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ServiceEdge_sourceId_fkey" FOREIGN KEY ("sourceId") REFERENCES "ServiceNode" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ServiceEdge_targetId_fkey" FOREIGN KEY ("targetId") REFERENCES "ServiceNode" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Vlan_vlanId_key" ON "Vlan"("vlanId");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"
+116
View File
@@ -0,0 +1,116 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// NOTE: SQLite does not support Prisma enums.
// All enum-like fields are stored as String with validation enforced in the service layer.
// Valid values are documented in server/lib/constants.ts and client/src/types/index.ts
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
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") // PortType: ETHERNET | SFP | SFP_PLUS | QSFP | CONSOLE | UPLINK
mode String @default("ACCESS") // VlanMode: 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])
}
// --- Service Mapper ---
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 // NodeType: 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?
}
+19
View File
@@ -0,0 +1,19 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// No default seed data — all content is created by the user.
// This script is safe to run multiple times (idempotent no-op).
console.log('RackMapper database is ready. No seed data configured.');
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
+63
View File
@@ -0,0 +1,63 @@
import 'dotenv/config';
import express from 'express';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import path from 'path';
import { authRouter } from './routes/auth';
import { racksRouter } from './routes/racks';
import { modulesRouter } from './routes/modules';
import { portsRouter } from './routes/ports';
import { vlansRouter } from './routes/vlans';
import { serviceMapRouter } from './routes/serviceMap';
import { nodesRouter } from './routes/nodes';
import { edgesRouter } from './routes/edges';
import { authMiddleware } from './middleware/authMiddleware';
import { errorHandler } from './middleware/errorHandler';
const app = express();
const PORT = process.env.PORT ?? 3001;
// ---- Core middleware ----
app.use(express.json());
app.use(cookieParser());
// CORS only needed in local dev (Vite :5173 → Node :3001)
if (process.env.NODE_ENV !== 'production') {
app.use(
cors({
origin: 'http://localhost:5173',
credentials: true,
})
);
}
// ---- Auth routes (no JWT required) ----
app.use('/api/auth', authRouter);
// ---- Protected API routes ----
app.use('/api', authMiddleware);
app.use('/api/racks', racksRouter);
app.use('/api/modules', modulesRouter);
app.use('/api/ports', portsRouter);
app.use('/api/vlans', vlansRouter);
app.use('/api/maps', serviceMapRouter);
app.use('/api/nodes', nodesRouter);
app.use('/api/edges', edgesRouter);
// ---- Serve Vite build in production ----
if (process.env.NODE_ENV === 'production') {
const clientDist = path.join(process.cwd(), 'client', 'dist');
app.use(express.static(clientDist));
// SPA fallback — always serve index.html for non-API routes
app.get(/^(?!\/api).*/, (_req, res) => {
res.sendFile(path.join(clientDist, 'index.html'));
});
}
// ---- Error handler (must be last) ----
app.use(errorHandler);
app.listen(PORT, () => {
console.log(`RackMapper running on port ${PORT} [${process.env.NODE_ENV ?? 'development'}]`);
});
+44
View File
@@ -0,0 +1,44 @@
// SQLite doesn't support Prisma enums — use string literals throughout the server.
// These types mirror client/src/types/index.ts
export type ModuleType =
| 'SWITCH' | 'AGGREGATE_SWITCH' | 'MODEM' | 'ROUTER' | 'NAS'
| 'PDU' | 'PATCH_PANEL' | 'SERVER' | 'FIREWALL' | 'AP' | 'BLANK' | 'OTHER';
export type PortType = 'ETHERNET' | 'SFP' | 'SFP_PLUS' | 'QSFP' | 'CONSOLE' | 'UPLINK';
export type VlanMode = 'ACCESS' | 'TRUNK' | 'HYBRID';
export type NodeType =
| 'SERVICE' | 'DATABASE' | 'API' | 'DEVICE' | 'EXTERNAL'
| 'USER' | 'VLAN' | 'FIREWALL' | 'LOAD_BALANCER' | 'NOTE';
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,
};
+14
View File
@@ -0,0 +1,14 @@
import { PrismaClient } from '@prisma/client';
// Singleton pattern prevents multiple PrismaClient instances in dev (hot reload)
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
+26
View File
@@ -0,0 +1,26 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { AppError, AuthenticatedRequest } from '../types/index';
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const token = (req.cookies as Record<string, string | undefined>)?.token;
if (!token) {
next(new AppError('Unauthorized', 401, 'NO_TOKEN'));
return;
}
const secret = process.env.JWT_SECRET;
if (!secret) {
next(new AppError('Server misconfiguration: JWT_SECRET not set', 500, 'CONFIG_ERROR'));
return;
}
try {
const payload = jwt.verify(token, secret) as { sub: string };
(req as AuthenticatedRequest).user = { sub: payload.sub };
next();
} catch {
next(new AppError('Invalid or expired session', 401, 'INVALID_TOKEN'));
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Request, Response, NextFunction } from 'express';
import { AppError, err } from '../types/index';
export function errorHandler(
error: Error,
_req: Request,
res: Response,
_next: NextFunction
): void {
const statusCode = error instanceof AppError ? error.statusCode : 500;
const code = error instanceof AppError ? error.code : 'INTERNAL_ERROR';
const message =
process.env.NODE_ENV === 'production' && statusCode === 500
? 'Internal server error'
: error.message;
if (statusCode === 500 && process.env.NODE_ENV !== 'production') {
console.error('[ErrorHandler]', error);
}
res.status(statusCode).json(err(message, code ? { code } : undefined));
}
+55
View File
@@ -0,0 +1,55 @@
import { Router, Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { AppError, ok } from '../types/index';
import { authMiddleware } from '../middleware/authMiddleware';
export const authRouter = Router();
const COOKIE_OPTS = {
httpOnly: true,
sameSite: 'strict' as const,
secure: process.env.NODE_ENV === 'production',
path: '/',
};
authRouter.post('/login', (req: Request, res: Response, next: NextFunction) => {
try {
const { username, password } = req.body as { username?: string; password?: string };
if (!username || !password) {
throw new AppError('Username and password are required', 400, 'MISSING_FIELDS');
}
const adminUsername = process.env.ADMIN_USERNAME;
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminUsername || !adminPassword) {
throw new AppError('Server not configured: admin credentials missing', 500, 'CONFIG_ERROR');
}
if (username !== adminUsername || password !== adminPassword) {
throw new AppError('Invalid username or password', 401, 'INVALID_CREDENTIALS');
}
const secret = process.env.JWT_SECRET;
if (!secret) throw new AppError('Server not configured: JWT_SECRET missing', 500, 'CONFIG_ERROR');
const token = jwt.sign({ sub: 'admin' }, secret, {
expiresIn: (process.env.JWT_EXPIRY ?? '8h') as jwt.SignOptions['expiresIn'],
});
res.cookie('token', token, COOKIE_OPTS);
res.json(ok({ success: true }));
} catch (e) {
next(e);
}
});
authRouter.post('/logout', (_req: Request, res: Response) => {
res.clearCookie('token', COOKIE_OPTS);
res.json(ok({ success: true }));
});
authRouter.get('/me', authMiddleware, (_req: Request, res: Response) => {
res.json(ok({ authenticated: true }));
});
+28
View File
@@ -0,0 +1,28 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as mapService from '../services/mapService';
import { ok } from '../types/index';
export const edgesRouter = Router();
edgesRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { label, edgeType, animated, metadata } = req.body as {
label?: string;
edgeType?: string;
animated?: boolean;
metadata?: string;
};
res.json(ok(await mapService.updateEdge(req.params.id, { label, edgeType, animated, metadata })));
} catch (e) {
next(e);
}
});
edgesRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await mapService.deleteEdge(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});
+60
View File
@@ -0,0 +1,60 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as moduleService from '../services/moduleService';
import { ok } from '../types/index';
export const modulesRouter = Router();
modulesRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, uPosition, uSize, manufacturer, model, ipAddress, notes } = req.body as {
name?: string;
uPosition?: number;
uSize?: number;
manufacturer?: string;
model?: string;
ipAddress?: string;
notes?: string;
};
res.json(
ok(
await moduleService.updateModule(req.params.id, {
name,
uPosition,
uSize,
manufacturer,
model,
ipAddress,
notes,
})
)
);
} catch (e) {
next(e);
}
});
modulesRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await moduleService.deleteModule(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});
modulesRouter.post('/:id/move', async (req: Request, res: Response, next: NextFunction) => {
try {
const { rackId, uPosition } = req.body as { rackId: string; uPosition: number };
res.json(ok(await moduleService.moveModule(req.params.id, rackId, uPosition)));
} catch (e) {
next(e);
}
});
modulesRouter.get('/:id/ports', async (req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await moduleService.getModulePorts(req.params.id)));
} catch (e) {
next(e);
}
});
+33
View File
@@ -0,0 +1,33 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as mapService from '../services/mapService';
import { ok } from '../types/index';
export const nodesRouter = Router();
nodesRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { label, positionX, positionY, metadata, color, icon, moduleId } = req.body as {
label?: string;
positionX?: number;
positionY?: number;
metadata?: string;
color?: string;
icon?: string;
moduleId?: string | null;
};
res.json(
ok(await mapService.updateNode(req.params.id, { label, positionX, positionY, metadata, color, icon, moduleId }))
);
} catch (e) {
next(e);
}
});
nodesRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await mapService.deleteNode(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});
+21
View File
@@ -0,0 +1,21 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as portService from '../services/portService';
import { ok } from '../types/index';
import type { VlanMode } from '../lib/constants';
export const portsRouter = Router();
portsRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { label, mode, nativeVlan, notes, vlans } = req.body as {
label?: string;
mode?: VlanMode;
nativeVlan?: number | null;
notes?: string;
vlans?: Array<{ vlanId: string; tagged: boolean }>;
};
res.json(ok(await portService.updatePort(req.params.id, { label, mode, nativeVlan, notes, vlans })));
} catch (e) {
next(e);
}
});
+83
View File
@@ -0,0 +1,83 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as rackService from '../services/rackService';
import * as moduleService from '../services/moduleService';
import { ok } from '../types/index';
import type { ModuleType, PortType } from '../lib/constants';
export const racksRouter = Router();
racksRouter.get('/', async (_req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await rackService.listRacks()));
} catch (e) {
next(e);
}
});
racksRouter.post('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, totalU, location, displayOrder } = req.body as {
name: string;
totalU?: number;
location?: string;
displayOrder?: number;
};
res.status(201).json(ok(await rackService.createRack({ name, totalU, location, displayOrder })));
} catch (e) {
next(e);
}
});
racksRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await rackService.getRack(req.params.id)));
} catch (e) {
next(e);
}
});
racksRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, totalU, location, displayOrder } = req.body as {
name?: string;
totalU?: number;
location?: string;
displayOrder?: number;
};
res.json(ok(await rackService.updateRack(req.params.id, { name, totalU, location, displayOrder })));
} catch (e) {
next(e);
}
});
racksRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await rackService.deleteRack(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});
racksRouter.post('/:id/modules', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType } =
req.body as {
name: string;
type: ModuleType;
uPosition: number;
uSize?: number;
manufacturer?: string;
model?: string;
ipAddress?: string;
notes?: string;
portCount?: number;
portType?: PortType;
};
res.status(201).json(
ok(await moduleService.createModule(req.params.id, { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType }))
);
} catch (e) {
next(e);
}
});
+97
View File
@@ -0,0 +1,97 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as mapService from '../services/mapService';
import { ok } from '../types/index';
import type { NodeType } from '../lib/constants';
export const serviceMapRouter = Router();
serviceMapRouter.get('/', async (_req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await mapService.listMaps()));
} catch (e) {
next(e);
}
});
serviceMapRouter.post('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, description } = req.body as { name: string; description?: string };
res.status(201).json(ok(await mapService.createMap({ name, description })));
} catch (e) {
next(e);
}
});
serviceMapRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await mapService.getMap(req.params.id)));
} catch (e) {
next(e);
}
});
serviceMapRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, description } = req.body as { name?: string; description?: string };
res.json(ok(await mapService.updateMap(req.params.id, { name, description })));
} catch (e) {
next(e);
}
});
serviceMapRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await mapService.deleteMap(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});
serviceMapRouter.post('/:id/nodes', async (req: Request, res: Response, next: NextFunction) => {
try {
const { label, nodeType, positionX, positionY, metadata, color, icon, moduleId } = req.body as {
label: string;
nodeType: NodeType;
positionX: number;
positionY: number;
metadata?: string;
color?: string;
icon?: string;
moduleId?: string;
};
res.status(201).json(
ok(await mapService.addNode(req.params.id, { label, nodeType, positionX, positionY, metadata, color, icon, moduleId }))
);
} catch (e) {
next(e);
}
});
serviceMapRouter.post('/:id/populate', async (req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await mapService.populateFromRack(req.params.id)));
} catch (e) {
next(e);
}
});
serviceMapRouter.post('/:id/edges', async (req: Request, res: Response, next: NextFunction) => {
try {
const { sourceId, targetId, label, edgeType, animated, metadata } = req.body as {
sourceId: string;
targetId: string;
label?: string;
edgeType?: string;
animated?: boolean;
metadata?: string;
};
res.status(201).json(
ok(await mapService.addEdge(req.params.id, { sourceId, targetId, label, edgeType, animated, metadata }))
);
} catch (e) {
next(e);
}
});
export { mapService };
+49
View File
@@ -0,0 +1,49 @@
import { Router, Request, Response, NextFunction } from 'express';
import * as vlanService from '../services/vlanService';
import { ok } from '../types/index';
export const vlansRouter = Router();
vlansRouter.get('/', async (_req: Request, res: Response, next: NextFunction) => {
try {
res.json(ok(await vlanService.listVlans()));
} catch (e) {
next(e);
}
});
vlansRouter.post('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const { vlanId, name, description, color } = req.body as {
vlanId: number;
name: string;
description?: string;
color?: string;
};
res.status(201).json(ok(await vlanService.createVlan({ vlanId, name, description, color })));
} catch (e) {
next(e);
}
});
vlansRouter.put('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const { name, description, color } = req.body as {
name?: string;
description?: string;
color?: string;
};
res.json(ok(await vlanService.updateVlan(req.params.id, { name, description, color })));
} catch (e) {
next(e);
}
});
vlansRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
await vlanService.deleteVlan(req.params.id);
res.json(ok(null));
} catch (e) {
next(e);
}
});
+170
View File
@@ -0,0 +1,170 @@
import { prisma } from '../lib/prisma';
import { AppError } from '../types/index';
import type { NodeType } from '../lib/constants';
const mapInclude = {
nodes: {
include: { module: true },
},
edges: true,
};
export async function listMaps() {
return prisma.serviceMap.findMany({
orderBy: { createdAt: 'desc' },
select: { id: true, name: true, description: true, createdAt: true, updatedAt: true },
});
}
export async function getMap(id: string) {
const map = await prisma.serviceMap.findUnique({ where: { id }, include: mapInclude });
if (!map) throw new AppError('Map not found', 404, 'NOT_FOUND');
return map;
}
export async function createMap(data: { name: string; description?: string }) {
return prisma.serviceMap.create({ data, include: mapInclude });
}
export async function updateMap(id: string, data: Partial<{ name: string; description: string }>) {
await getMap(id);
return prisma.serviceMap.update({ where: { id }, data, include: mapInclude });
}
export async function deleteMap(id: string) {
await getMap(id);
return prisma.serviceMap.delete({ where: { id } });
}
// ---- Nodes ----
export async function addNode(
mapId: string,
data: {
label: string;
nodeType: NodeType;
positionX: number;
positionY: number;
metadata?: string;
color?: string;
icon?: string;
moduleId?: string;
}
) {
await getMap(mapId);
return prisma.serviceNode.create({
data: { mapId, ...data },
include: { module: true },
});
}
export async function populateFromRack(mapId: string) {
await getMap(mapId);
const modules = await prisma.module.findMany({
orderBy: [{ rack: { displayOrder: 'asc' } }, { uPosition: 'asc' }],
include: { rack: true },
});
const existing = await prisma.serviceNode.findMany({
where: { mapId, moduleId: { not: null } },
select: { moduleId: true },
});
const existingModuleIds = new Set(existing.map((n) => n.moduleId as string));
const newModules = modules.filter((m) => !existingModuleIds.has(m.id));
if (newModules.length === 0) return getMap(mapId);
const byRack = new Map<string, typeof modules>();
for (const mod of newModules) {
if (!byRack.has(mod.rackId)) byRack.set(mod.rackId, []);
byRack.get(mod.rackId)!.push(mod);
}
const NODE_W = 200;
const NODE_H = 80;
const COL_GAP = 260;
const ROW_GAP = 110;
const nodesToCreate: Array<{
mapId: string;
label: string;
nodeType: NodeType;
positionX: number;
positionY: number;
moduleId: string;
}> = [];
let colIdx = 0;
for (const rackModules of byRack.values()) {
rackModules.forEach((mod, rowIdx) => {
nodesToCreate.push({
mapId,
label: mod.name,
nodeType: 'DEVICE' as NodeType,
positionX: colIdx * (NODE_W + COL_GAP),
positionY: rowIdx * (NODE_H + ROW_GAP),
moduleId: mod.id,
});
});
colIdx++;
}
await prisma.serviceNode.createMany({ data: nodesToCreate });
return getMap(mapId);
}
export async function updateNode(
id: string,
data: Partial<{
label: string;
positionX: number;
positionY: number;
metadata: string;
color: string;
icon: string;
moduleId: string | null;
}>
) {
const existing = await prisma.serviceNode.findUnique({ where: { id } });
if (!existing) throw new AppError('Node not found', 404, 'NOT_FOUND');
return prisma.serviceNode.update({ where: { id }, data, include: { module: true } });
}
export async function deleteNode(id: string) {
const existing = await prisma.serviceNode.findUnique({ where: { id } });
if (!existing) throw new AppError('Node not found', 404, 'NOT_FOUND');
return prisma.serviceNode.delete({ where: { id } });
}
// ---- Edges ----
export async function addEdge(
mapId: string,
data: {
sourceId: string;
targetId: string;
label?: string;
edgeType?: string;
animated?: boolean;
metadata?: string;
}
) {
await getMap(mapId);
return prisma.serviceEdge.create({ data: { mapId, ...data } });
}
export async function updateEdge(
id: string,
data: Partial<{ label: string; edgeType: string; animated: boolean; metadata: string }>
) {
const existing = await prisma.serviceEdge.findUnique({ where: { id } });
if (!existing) throw new AppError('Edge not found', 404, 'NOT_FOUND');
return prisma.serviceEdge.update({ where: { id }, data });
}
export async function deleteEdge(id: string) {
const existing = await prisma.serviceEdge.findUnique({ where: { id } });
if (!existing) throw new AppError('Edge not found', 404, 'NOT_FOUND');
return prisma.serviceEdge.delete({ where: { id } });
}
+183
View File
@@ -0,0 +1,183 @@
import { prisma } from '../lib/prisma';
import { AppError } from '../types/index';
import { MODULE_PORT_DEFAULTS, MODULE_U_DEFAULTS, type ModuleType, type PortType } from '../lib/constants';
const moduleInclude = {
ports: {
orderBy: { portNumber: 'asc' as const },
include: {
vlans: { include: { vlan: true } },
},
},
};
/** Check whether a U-range is occupied in a rack, optionally excluding one module (for moves). */
async function hasCollision(
rackId: string,
uPosition: number,
uSize: number,
excludeModuleId?: string
): Promise<boolean> {
const modules = await prisma.module.findMany({
where: { rackId, ...(excludeModuleId ? { id: { not: excludeModuleId } } : {}) },
select: { uPosition: true, uSize: true },
});
const occupied = new Set<number>();
for (const m of modules) {
for (let u = m.uPosition; u < m.uPosition + m.uSize; u++) occupied.add(u);
}
for (let u = uPosition; u < uPosition + uSize; u++) {
if (occupied.has(u)) return true;
}
return false;
}
export async function createModule(
rackId: string,
data: {
name: string;
type: ModuleType;
uPosition: number;
uSize?: number;
manufacturer?: string;
model?: string;
ipAddress?: string;
notes?: string;
portCount?: number;
portType?: PortType;
}
) {
const rack = await prisma.rack.findUnique({ where: { id: rackId } });
if (!rack) throw new AppError('Rack not found', 404, 'NOT_FOUND');
const uSize = data.uSize ?? MODULE_U_DEFAULTS[data.type] ?? 1;
if (data.uPosition < 1 || data.uPosition + uSize - 1 > rack.totalU) {
throw new AppError(
`Module does not fit within rack (U1U${rack.totalU})`,
400,
'OUT_OF_BOUNDS'
);
}
if (await hasCollision(rackId, data.uPosition, uSize)) {
throw new AppError('U-slot collision: another module occupies that space', 409, 'COLLISION');
}
const portCount = data.portCount ?? MODULE_PORT_DEFAULTS[data.type] ?? 0;
const portType: PortType = data.portType ?? 'ETHERNET';
return prisma.module.create({
data: {
rackId,
name: data.name,
type: data.type,
uPosition: data.uPosition,
uSize,
manufacturer: data.manufacturer,
model: data.model,
ipAddress: data.ipAddress,
notes: data.notes,
ports: {
create: Array.from({ length: portCount }, (_, i) => ({
portNumber: i + 1,
portType,
})),
},
},
include: moduleInclude,
});
}
export async function updateModule(
id: string,
data: Partial<{
name: string;
uPosition: number;
uSize: number;
manufacturer: string;
model: string;
ipAddress: string;
notes: string;
}>
) {
const existing = await prisma.module.findUnique({ where: { id } });
if (!existing) throw new AppError('Module not found', 404, 'NOT_FOUND');
const newPosition = data.uPosition ?? existing.uPosition;
const newSize = data.uSize ?? existing.uSize;
if (data.uPosition !== undefined || data.uSize !== undefined) {
const rack = await prisma.rack.findUnique({ where: { id: existing.rackId } });
if (!rack) throw new AppError('Rack not found', 404, 'NOT_FOUND');
if (newPosition < 1 || newPosition + newSize - 1 > rack.totalU) {
throw new AppError(
`Module does not fit within rack (U1U${rack.totalU})`,
400,
'OUT_OF_BOUNDS'
);
}
if (await hasCollision(existing.rackId, newPosition, newSize, id)) {
throw new AppError('U-slot collision', 409, 'COLLISION');
}
}
return prisma.module.update({ where: { id }, data, include: moduleInclude });
}
export async function deleteModule(id: string) {
const existing = await prisma.module.findUnique({ where: { id } });
if (!existing) throw new AppError('Module not found', 404, 'NOT_FOUND');
return prisma.module.delete({ where: { id } });
}
/**
* Move a module to a new rack and/or U-position.
* Ports and VLAN assignments move with the module (they're linked by moduleId).
*/
export async function moveModule(
id: string,
targetRackId: string,
targetUPosition: number
) {
const existing = await prisma.module.findUnique({ where: { id } });
if (!existing) throw new AppError('Module not found', 404, 'NOT_FOUND');
const targetRack = await prisma.rack.findUnique({ where: { id: targetRackId } });
if (!targetRack) throw new AppError('Target rack not found', 404, 'NOT_FOUND');
if (targetUPosition < 1 || targetUPosition + existing.uSize - 1 > targetRack.totalU) {
throw new AppError(
`Module does not fit within target rack (U1U${targetRack.totalU})`,
400,
'OUT_OF_BOUNDS'
);
}
// Collision check in target rack, excluding self (handles same-rack moves)
const excludeInTarget = targetRackId === existing.rackId ? id : undefined;
if (await hasCollision(targetRackId, targetUPosition, existing.uSize, excludeInTarget)) {
throw new AppError('U-slot collision in target rack', 409, 'COLLISION');
}
return prisma.module.update({
where: { id },
data: { rackId: targetRackId, uPosition: targetUPosition },
include: moduleInclude,
});
}
export async function getModulePorts(id: string) {
const existing = await prisma.module.findUnique({ where: { id } });
if (!existing) throw new AppError('Module not found', 404, 'NOT_FOUND');
return prisma.port.findMany({
where: { moduleId: id },
orderBy: { portNumber: 'asc' },
include: { vlans: { include: { vlan: true } } },
});
}
+47
View File
@@ -0,0 +1,47 @@
import { prisma } from '../lib/prisma';
import { AppError } from '../types/index';
import type { VlanMode } from '../lib/constants';
const portInclude = {
vlans: { include: { vlan: true } },
};
export async function updatePort(
id: string,
data: {
label?: string;
mode?: VlanMode;
nativeVlan?: number | null;
notes?: string;
vlans?: Array<{ vlanId: string; tagged: boolean }>;
}
) {
const existing = await prisma.port.findUnique({ where: { id } });
if (!existing) throw new AppError('Port not found', 404, 'NOT_FOUND');
const { vlans: vlanAssignments, ...portData } = data;
return prisma.$transaction(async (tx) => {
await tx.port.update({ where: { id }, data: portData });
if (vlanAssignments !== undefined) {
if (vlanAssignments.length > 0) {
const vlanIds = vlanAssignments.map((v) => v.vlanId);
const found = await tx.vlan.findMany({ where: { id: { in: vlanIds } } });
if (found.length !== vlanIds.length) {
throw new AppError('One or more VLANs not found', 404, 'VLAN_NOT_FOUND');
}
}
await tx.portVlan.deleteMany({ where: { portId: id } });
if (vlanAssignments.length > 0) {
await tx.portVlan.createMany({
data: vlanAssignments.map(({ vlanId, tagged }) => ({ portId: id, vlanId, tagged })),
});
}
}
return tx.port.findUnique({ where: { id }, include: portInclude });
});
}
+59
View File
@@ -0,0 +1,59 @@
import { prisma } from '../lib/prisma';
import { AppError } from '../types/index';
// Full include shape used across all rack queries
const rackInclude = {
modules: {
orderBy: { uPosition: 'asc' as const },
include: {
ports: {
orderBy: { portNumber: 'asc' as const },
include: {
vlans: {
include: { vlan: true },
},
},
},
},
},
};
export async function listRacks() {
return prisma.rack.findMany({
orderBy: { displayOrder: 'asc' },
include: rackInclude,
});
}
export async function getRack(id: string) {
const rack = await prisma.rack.findUnique({ where: { id }, include: rackInclude });
if (!rack) throw new AppError('Rack not found', 404, 'NOT_FOUND');
return rack;
}
export async function createRack(data: {
name: string;
totalU?: number;
location?: string;
displayOrder?: number;
}) {
// Auto-assign displayOrder to end of list if not provided
if (data.displayOrder === undefined) {
const last = await prisma.rack.findFirst({ orderBy: { displayOrder: 'desc' } });
data.displayOrder = last ? last.displayOrder + 1 : 0;
}
return prisma.rack.create({ data, include: rackInclude });
}
export async function updateRack(
id: string,
data: Partial<{ name: string; totalU: number; location: string; displayOrder: number }>
) {
await getRack(id); // throws 404 if missing
return prisma.rack.update({ where: { id }, data, include: rackInclude });
}
export async function deleteRack(id: string) {
await getRack(id);
return prisma.rack.delete({ where: { id } });
}
+37
View File
@@ -0,0 +1,37 @@
import { prisma } from '../lib/prisma';
import { AppError } from '../types/index';
export async function listVlans() {
return prisma.vlan.findMany({ orderBy: { vlanId: 'asc' } });
}
export async function createVlan(data: {
vlanId: number;
name: string;
description?: string;
color?: string;
}) {
const existing = await prisma.vlan.findUnique({ where: { vlanId: data.vlanId } });
if (existing) throw new AppError(`VLAN ID ${data.vlanId} already exists`, 409, 'DUPLICATE');
if (data.vlanId < 1 || data.vlanId > 4094) {
throw new AppError('VLAN ID must be between 1 and 4094', 400, 'INVALID_VLAN_ID');
}
return prisma.vlan.create({ data });
}
export async function updateVlan(
id: string,
data: Partial<{ name: string; description: string; color: string }>
) {
const existing = await prisma.vlan.findUnique({ where: { id } });
if (!existing) throw new AppError('VLAN not found', 404, 'NOT_FOUND');
return prisma.vlan.update({ where: { id }, data });
}
export async function deleteVlan(id: string) {
const existing = await prisma.vlan.findUnique({ where: { id } });
if (!existing) throw new AppError('VLAN not found', 404, 'NOT_FOUND');
return prisma.vlan.delete({ where: { id } });
}
+37
View File
@@ -0,0 +1,37 @@
import { Request } from 'express';
// ---- Error handling ----
export class AppError extends Error {
statusCode: number;
code?: string;
constructor(message: string, statusCode: number, code?: string) {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
this.code = code;
}
}
// ---- API response shape ----
export interface ApiResponse<T = unknown> {
data: T | null;
error: string | null;
meta?: Record<string, unknown>;
}
export function ok<T>(data: T, meta?: Record<string, unknown>): ApiResponse<T> {
return { data, error: null, ...(meta ? { meta } : {}) };
}
export function err(message: string, meta?: Record<string, unknown>): ApiResponse<null> {
return { data: null, error: message, ...(meta ? { meta } : {}) };
}
// ---- Augmented request ----
export interface AuthenticatedRequest extends Request {
user: { sub: string };
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"sourceMap": true,
"declaration": false
},
"include": ["server/**/*", "scripts/**/*", "prisma/seed.ts"],
"exclude": ["node_modules", "dist", "client"]
}