Compare commits

..

38 Commits

Author SHA1 Message Date
jason 5d9170cec9 Add .gitea/workflows/docker-build.yml
Build and Push Docker Image / build (push) Successful in 43s
2026-03-29 00:57:24 -05:00
jason 308a4c5641 Fix P2 issues 2026-03-27 13:42:35 -05:00
jason 3eac74f28c Fix P1 issues 2026-03-27 13:32:39 -05:00
jason f1c1efd8d3 feat(rack): add shift-click context modal for connections with color and edge type configurability 2026-03-22 21:35:10 -05:00
jason 72918bd87a fix(rack-planner): accept sfpCount and wanCount in module creation API route 2026-03-22 15:27:49 -05:00
jason 96adb1e130 fix(rack-planner): revamped port layout with ml-auto and border-l to ensure SFPs are visible on the right 2026-03-22 15:24:04 -05:00
jason f6b6f49379 fix(rack-planner): ensure SFP/WAN ports render even when ethernet port count is zero 2026-03-22 15:19:54 -05:00
jason 1f360cdb2a feat(rack-planner): add support for WAN and SFP ports with right-justified layout and distinct styling 2026-03-22 15:16:54 -05:00
jason b26f88a89e fix(rack-planner): compute port data synchronously in PortConfigModal to prevent empty first render 2026-03-22 15:11:00 -05:00
jason 5de001c630 refactor(rack-planner): lift port config modal state to root level to avoid rendering issues within transformed modules 2026-03-22 15:04:53 -05:00
jason e2c5cad8a3 fix(rack-planner): resolve infinite re-render loop in ConnectionLayer and add null-safety for VLAN tooltips 2026-03-22 15:01:35 -05:00
jason becb55d57c feat(rack-planner): implement port-to-port connections (patch cables) with dynamic SVG visualization layer 2026-03-22 14:55:33 -05:00
jason 444d694a06 docs: rename RREADME to README, update documentation for logical addresses and drag-drop fix 2026-03-22 14:39:44 -05:00
jason 0dcf5b3c8c feat(mapper): add IP and port fields via node metadata 2026-03-22 12:20:54 -05:00
jason a13c52d3e3 fix(rack): ensure dnd-kit DragOverlay ignores pointer events to fix hit-testing 2026-03-22 11:37:14 -05:00
jason df04bb2c78 fix(rack): memoize dnd-kit sensors and prevent pointermove state thrashing 2026-03-22 11:29:02 -05:00
jason 2e2b182844 fix(rack): prevent dragged module unmounting to fix dnd-kit drop 2026-03-22 11:23:40 -05:00
jason 1a99e22bfb Fix rack slot drag targets 2026-03-22 09:13:21 -05:00
jason 55ee1dea93 Fix drag-and-drop hover detection and slot targeting
Two root-cause bugs fixed:

1. Port <button> elements inside ModuleBlock had pointer-events:auto (browser
   default), so document.elementFromPoint() hit them instead of the RackSlot
   behind them whenever the cursor was over an occupied slot. Fixed by toggling
   body.rack-dragging during any drag, which applies a CSS rule that forces
   pointer-events:none !important on .module-block and all descendants.

2. onDragMove pointer-position reconstruction (activatorEvent.clientX + delta.x)
   was slightly off because delta is measured from the initial mousedown, not
   the activation point. Replaced with a native window pointermove listener
   (capture phase) that gives exact clientX/Y — no reconstruction needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 08:57:22 -05:00
jason c9aed96400 Fix module drag-and-drop: replace useDroppable/collision with elementFromPoint
Completely removes dnd-kit's useDroppable and collision detection for rack
slot targeting. Uses onDragMove + document.elementFromPoint() with data-rack-id
/ data-u-pos HTML attributes on RackSlot elements to resolve the hovered slot
independently of dnd-kit's SortableContext interference. Adds pointer-events-none
to ModuleBlock when isDragging so the invisible element doesn't block hit testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 08:41:03 -05:00
jason d381f8b720 Switch Docker install from npm install to npm ci to fix build hang
npm install re-resolves the full dependency tree from the registry on
every --no-cache build, making two full network round-trips (root + client).
Any slow registry response causes it to appear hung.

Changes:
- Replace both 'npm install' with 'npm ci':
    no dependency resolution step, uses lockfile as-is, exits fast on
    any mismatch rather than silently upgrading
- Copy package.json + package-lock.json explicitly (not glob) so the
  lockfile is always present for npm ci to validate against
- Set npm fetch timeouts (5s min, 30s max, 3 retries) so failures
  surface quickly rather than hanging indefinitely

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 08:29:26 -05:00
jason 25e78b4754 Fix module drag: TypeError on droppableContainers.filter() crashing collision detection
droppableContainers in @dnd-kit/core collision detection args is a custom
NodeMap class, not a plain Array. It implements [Symbol.iterator] (so
for...of works internally in closestCenter/pointerWithin) but does NOT
have Array.prototype methods like .filter().

Calling args.droppableContainers.filter(...) threw:
  TypeError: args.droppableContainers.filter is not a function

dnd-kit silently catches errors in the collision detection callback and
treats them as no collision (over = null). Every module drag ended with
over = null, hitting the early return in handleDragEnd, causing the module
to snap back to its original slot every time.

Fix: Array.from(args.droppableContainers) converts the NodeMap iterable
to a plain array before filtering for dropType === 'slot' containers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 08:23:51 -05:00
jason a11634070f Fix module drag drop (collision detection) + widen rack to fix port clipping
Collision detection root cause:
  pointerWithin returns ALL droppables containing the pointer, in
  registration order — not sorted by element size. Rack columns register
  via useSortable before their child RackSlots, so they always came first
  in the result list. over.data.current was { dragType: 'rack' }, never
  { dropType: 'slot' }, so handleDragEnd's slot check never matched and
  the module snapped back.

Fix: filter droppableContainers to elements with data.current.dropType
=== 'slot' before running pointerWithin. This does an exact pointer
hit-test against only the 44px slot rects. If no slot is hit (e.g. the
pointer is in a gap or over a rack header), fall back to closestCenter
over all droppables so rack-column reorder still works.

Width fix:
  24 ports * 10px + 23 gaps * 3px = 309px
  + px-2 padding (16px) + border-l-4 (4px) = 329px minimum
  w-80 (320px) was 9px short, clipping port 24.
  Increased to w-96 (384px) / min-w-[384px] — 55px of breathing room.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 08:15:45 -05:00
jason 172896b85f Fix module drag-and-drop: custom collision detection to hit slots over racks
Root cause: SortableContext registers each rack column as a droppable.
Each column is ~1800px tall (42U x 44px). The default closestCenter
algorithm compared center-to-center distances, so the rack column's
center consistently beat the 44px RackSlot's center — meaning over.data
resolved to { dragType: 'rack' } and handleDragEnd's check for
dropType === 'slot' never matched. Drops silently did nothing.

Fix: replace closestCenter with a two-phase collision detection:
  1. pointerWithin — returns droppables whose bounding rect contains
     the actual pointer position. Slots are exactly hit-tested.
  2. closestCenter fallback — used when the pointer is not within any
     registered droppable (e.g. dragging a rack header between columns
     for sortable reorder where the pointer may be in the gap).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 07:55:35 -05:00
jason 7c04c4633f Fix service map nodes disappearing on page reload
Root cause: Zustand store resets to activeMap=null on every page load.
fetchMaps() only loaded the summary list — nodes were never reloaded
because the user had to manually re-select their map each time.

Fixes:
1. Persist last active map ID in localStorage (key: rackmapper:lastMapId)
   - loadMap() saves the ID on successful load
   - setActiveMap() saves/clears the ID
   - deleteMap() clears the ID if the deleted map was active

2. Auto-restore on mount inside fetchMaps():
   - If the saved map ID is still in the list, auto-load it
   - If there is exactly one map, auto-load it as a convenience

3. Block spurious position saves during map load (blockSaveRef):
   - fitView fires position NodeChanges for all nodes after load
   - Without a guard these would overwrite stored positions with
     React Flow's fitted coordinates immediately on every reload
   - blockSaveRef is set true on activeMap change, cleared after 800ms

4. Tighten the position-change filter:
   - Require dragging === false (strict equality, not just falsy)
   - Require position != null before saving
   - Both conditions must be true to queue a save

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:05:01 -05:00
jason 95d26ec805 Fix module drag + move delete button into edit modal
Module drag broken:
  listeners were on a 12px grip strip only; dragging anywhere else on
  the block had no effect. Moved {...listeners} {...attributes} to the
  outer container so the whole module face is the drag source.
  Port buttons now stop pointerdown propagation so clicking a port does
  not accidentally start a drag. Resize handle also stops pointerdown
  propagation before forwarding to its own handler.
  Removed the now-redundant GripVertical strip.

Delete button covering ports 23-24:
  Removed the absolute-positioned Trash2 button from ModuleBlock face.
  Delete is now inside ModuleEditPanel with an inline confirm flow:
    - 'Delete module' link in the modal footer (left side)
    - Clicking shows 'Remove this module? [Delete] [Cancel]' inline
    - On confirm: calls API, removeModuleLocal, closes modal
  ConfirmDialog import and related state also removed from ModuleBlock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:10:12 -05:00
jason 3d72f429bc Redesign rack module face: ports-first layout, wider column, taller U-slots
Problems fixed:
- Name label + type badge were eating all horizontal space in 1U modules,
  pushing 24 port dots into a cramped overflow that was barely visible
- U_HEIGHT_PX=28 was too tight to show a full port row at all
- Column width (192px) was too narrow to fit 24x10px dots + gaps (286px needed)

Changes:
- U_HEIGHT_PX: 28 -> 44px  (enough room for ports + resize handle)
- RackColumn: w-48 (192px) -> w-80 (320px), min-w-[200px] -> min-w-[320px]
- PORTS_PER_ROW = 24 constant added to constants.ts
- ModuleBlock face redesigned:
    * Removed name <span> and type <Badge> from the visible face
    * Module name + IP now shown as a native title tooltip on hover
    * Port dots are the primary face content (24 per row, gap-[3px])
    * Multiple rows rendered for multi-U modules (up to available height)
    * Hidden port overflow shown as "+N more" below the rows
    * Drag handle slimmed to 12px; delete/resize handles unchanged
    * Type still communicated via background color

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:03:10 -05:00
jason 128b43e43d Fix database not initializing in Docker (no db file created)
Root cause: DATABASE_URL used a relative path (file:./data/rackmapper.db).
Prisma CLI (migrate deploy) resolves relative SQLite paths from the
prisma/ schema directory -> /app/prisma/data/rackmapper.db, while the
Prisma Client at runtime resolves from CWD -> /app/data/rackmapper.db.
The migration ran against a different path than the bind mount, so no
database file ever appeared in /app/data (the mounted volume).

Fixes:
- Change DATABASE_URL to absolute path: file:/app/data/rackmapper.db
  everywhere (docker-compose, .env.example, UNRAID.md)
- Replace inline CMD with docker-entrypoint.sh:
    mkdir -p /app/data before migrating (safety net)
    npx prisma migrate deploy with set -e so failures are visible
    exec node dist/server/index.js
  This surfaces migration errors in docker logs instead of silently
  exiting, and ensures the data dir always exists before SQLite opens it
- Update .env.example to reflect plain ADMIN_PASSWORD and COOKIE_SECURE

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:52:16 -05:00
jason b5df2e6721 Fix palette drag-and-drop not triggering AddModuleModal
DevicePalette's useDraggable was missing dragType: 'palette' in its data
object. RackPlanner's handleDragStart and handleDragEnd both guard on
dragType === 'palette' — without it the drag overlay never showed and the
drop onto a slot was silently ignored.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:44:50 -05:00
jason 69b7262535 Fix 401 Unauthorized on all API calls after login (HTTP installs)
Root cause: cookie was set with Secure=true whenever NODE_ENV=production.
Browsers refuse to send Secure cookies over plain HTTP, so the session
cookie was dropped on every request after login — causing every protected
endpoint to return 401.

Fix: replace the NODE_ENV check with an explicit COOKIE_SECURE env var
(default false). Set COOKIE_SECURE=true only when running behind an HTTPS
reverse proxy. Direct HTTP installs (standard Unraid setup) work as-is.

Also updated UNRAID.md to document COOKIE_SECURE with a warning explaining
why it must stay false for plain-HTTP access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:40:08 -05:00
jason 2c95d01e7a Fix Prisma OpenSSL error on Alpine Linux (Docker)
- Add openssl + openssl-dev to Dockerfile apk install; Alpine does not
  ship OpenSSL by default but Prisma's query engine binary requires it
- Add binaryTargets to schema.prisma generator:
    native                    → used during docker build (npx prisma generate)
    linux-musl-openssl-3.0.x  → correct engine binary for Alpine at runtime
  Without the explicit target Prisma defaults to openssl-1.1.x, which
  does not exist on Alpine 3.18+, producing the "Could not parse schema
  engine response" error at migrate/startup time

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:32:25 -05:00
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
96 changed files with 16462 additions and 110 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"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:*)",
"Bash(engine response\" error at migrate/startup time:*)"
]
}
}
+21
View File
@@ -0,0 +1,21 @@
# 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= # Plain-text password
# JWT
JWT_SECRET= # Min 32 random chars — generate with: openssl rand -hex 32
JWT_EXPIRY=8h
# Cookie security — set to true only if behind an HTTPS reverse proxy
COOKIE_SECURE=false
# Database — absolute path avoids Prisma CLI vs runtime resolution mismatch
# In Docker this maps to the bind-mounted /app/data volume
DATABASE_URL=file:/app/data/rackmapper.db
# Server
PORT=3001
NODE_ENV=development
+25
View File
@@ -0,0 +1,25 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: registry.alwisp.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and Push
run: |
docker build -t registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest .
docker push registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest
+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
}
+59 -109
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) |
@@ -490,14 +440,14 @@ Each `NodeType` has a custom React Flow node component in `client/src/components
| Node Type | Component | Visual Style | | Node Type | Component | Visual Style |
|---|---|---| |---|---|---|
| `DEVICE` | `DeviceNode.tsx` | Rack icon; accent border color by ModuleType; shows IP if linked | | `DEVICE` | `DeviceNode.tsx` | Rack icon; accent border color by ModuleType; shows IP if linked or overridden by metadata |
| `SERVICE` | `ServiceNode.tsx` | Rounded card, colored left border, icon + label | | `SERVICE` | `ServiceNode.tsx` | Rounded card, colored left border, icon + label; shows IP/Port if set in metadata |
| `DATABASE` | `DatabaseNode.tsx` | Cylinder icon, dark teal accent | | `DATABASE` | `DatabaseNode.tsx` | Cylinder icon, dark teal accent; shows IP/Port if set in metadata |
| `API` | `ApiNode.tsx` | Badge style with optional method tag (REST/gRPC/WS) | | `API` | `ApiNode.tsx` | Badge style with optional method tag (REST/gRPC/WS); shows IP/Port if set in metadata |
| `EXTERNAL` | `ExternalNode.tsx` | Dashed border, cloud icon | | `EXTERNAL` | `ExternalNode.tsx` | Dashed border, cloud icon; shows IP/Port if set in metadata |
| `VLAN` | `VlanNode.tsx` | Colored square matching VLAN color swatch | | `VLAN` | `VlanNode.tsx` | Colored square matching VLAN color swatch |
| `FIREWALL` | `FirewallNode.tsx` | Shield icon, red accent | | `FIREWALL` | `FirewallNode.tsx` | Shield icon, red accent; shows IP/Port if set in metadata |
| `LOAD_BALANCER` | `LBNode.tsx` | Scale/balance icon | | `LOAD_BALANCER` | `LBNode.tsx` | Scale/balance icon; shows IP/Port if set in metadata |
| `USER` | `UserNode.tsx` | Person icon, neutral gray | | `USER` | `UserNode.tsx` | Person icon, neutral gray |
| `NOTE` | `NoteNode.tsx` | Sticky note style, no handles, free text | | `NOTE` | `NoteNode.tsx` | Sticky note style, no handles, free text |
@@ -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, logical IP/Port metadata, 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.
@@ -660,6 +608,8 @@ The Dockerfile should run `npx prisma migrate deploy && node dist/index.js` as t
8. **Ports auto-generated on Module creation** — use `MODULE_PORT_DEFAULTS`. Do not require manual port addition. 8. **Ports auto-generated on Module creation** — use `MODULE_PORT_DEFAULTS`. Do not require manual port addition.
9. **DeviceNode position is canvas-independent** — linking a node to a Module does not constrain its canvas position. 9. **DeviceNode position is canvas-independent** — linking a node to a Module does not constrain its canvas position.
10. **VLAN seed is blank** — the user creates all VLANs manually. Do not pre-seed VLAN records. 10. **VLAN seed is blank** — the user creates all VLANs manually. Do not pre-seed VLAN records.
11. **Logical Address Metadata** — IP and Port for mapper nodes are stored as a JSON string in the `metadata` field of the `ServiceNode` table. This avoids schema migrations for functional logical address tracking.
12. **Fixed Drag & Drop** — Rack Planner drag-and-drop utilizes `@dnd-kit` with optimized hit-testing via `document.elementFromPoint` and `pointer-events: none` on the `DragOverlay`. Dragged modules remain mounted to maintain library state.
--- ---
+40
View File
@@ -0,0 +1,40 @@
FROM node:20-alpine
WORKDIR /app
# Install build tools + OpenSSL (required by Prisma query engine on Alpine)
RUN apk add --no-cache python3 make g++ openssl openssl-dev
# Configure npm: disable package-lock update, cap network retries/timeout
RUN npm config set fetch-retry-mintimeout 5000 \
&& npm config set fetch-retry-maxtimeout 30000 \
&& npm config set fetch-retries 3 \
&& npm config set prefer-offline false
# Copy lockfiles + manifests together so npm ci can validate the lockfile
COPY package.json package-lock.json ./
COPY client/package.json client/package-lock.json ./client/
# Use npm ci (lockfile-exact, no resolution step, much faster than npm install)
RUN npm ci
RUN cd client && npm ci
# 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
# Copy and make entrypoint executable
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh
EXPOSE 3001
ENTRYPOINT ["/app/docker-entrypoint.sh"]
+104
View File
@@ -0,0 +1,104 @@
# 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, logical IP/Port, and rack module link
- Logical Address mapping — assign IP and Port to any node type via metadata (stored as JSON)
- Persistent storage — all node details and logical addresses are saved to the SQLite database
- 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 |
-1
View File
@@ -1 +0,0 @@
TBD
+255
View File
@@ -0,0 +1,255 @@
# 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:/app/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)* |
| Secure Cookie | `COOKIE_SECURE` | `false` *(set to `true` only if behind HTTPS reverse proxy)* |
> **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 '-'
> ```
> **COOKIE_SECURE** — Leave this unset or `false` for direct HTTP access (the default for Unraid). Only set it to `true` if you are terminating HTTPS at a reverse proxy (e.g. Nginx Proxy Manager, Traefik) in front of RackMapper, otherwise login will succeed but every subsequent API call will return 401 Unauthorized because the browser will refuse to send the session cookie over plain HTTP.
---
### 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:/app/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 \
-e COOKIE_SECURE=false \
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>
);
}
+211
View File
@@ -0,0 +1,211 @@
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;
sfpCount?: number;
wanCount?: number;
}
) => 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}`),
};
// ---- Connections ----
const connections = {
create: (data: { fromPortId: string; toPortId: string; color?: string; label?: string; edgeType?: string }) =>
post<{ id: string }>('/connections', data),
update: (id: string, data: Partial<{ color: string; label: string; edgeType: string }>) =>
put<{ id: string }>(`/connections/${id}`, data),
delete: (id: string) => del<null>(`/connections/${id}`),
deleteByPorts: (p1: string, p2: string) => del<null>(`/connections/ports/${p1}/${p2}`),
};
export const apiClient = { auth, racks, modules, ports, vlans, maps, nodes, edges, connections };
+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,211 @@
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;
initialMetadata?: string | null;
onSaved: (updated: { label: string; color: string; moduleId: string | null; metadata?: string }) => void;
}
export function NodeEditModal({
open,
onClose,
nodeId,
initialLabel,
initialColor,
initialModuleId,
initialMetadata,
onSaved,
}: NodeEditModalProps) {
const { racks } = useRackStore();
const [label, setLabel] = useState(initialLabel);
const [color, setColor] = useState(initialColor ?? '#3b82f6');
const [moduleId, setModuleId] = useState<string>(initialModuleId ?? '');
const [ipAddress, setIpAddress] = useState('');
const [port, setPort] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
setLabel(initialLabel);
setColor(initialColor ?? '#3b82f6');
setModuleId(initialModuleId ?? '');
if (initialMetadata) {
try {
const parsed = JSON.parse(initialMetadata);
setIpAddress(parsed.ipAddress || '');
setPort(parsed.port || '');
} catch {
setIpAddress('');
setPort('');
}
} else {
setIpAddress('');
setPort('');
}
}
}, [open, initialLabel, initialColor, initialModuleId, initialMetadata]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!label.trim()) return;
setLoading(true);
try {
let metaObj: Record<string, unknown> = {};
if (initialMetadata) {
try {
metaObj = JSON.parse(initialMetadata);
} catch { /* ignore */ }
}
if (ipAddress.trim()) metaObj.ipAddress = ipAddress.trim();
else delete metaObj.ipAddress;
if (port.trim()) metaObj.port = port.trim();
else delete metaObj.port;
const metadataString = Object.keys(metaObj).length > 0 ? JSON.stringify(metaObj) : '';
await apiClient.nodes.update(nodeId, {
label: label.trim(),
color,
moduleId: moduleId || null,
metadata: metadataString,
});
onSaved({ label: label.trim(), color, moduleId: moduleId || null, metadata: metadataString });
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>
{/* Logical Address */}
<div className="grid grid-cols-2 gap-4">
<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="e.g. 10.0.0.5"
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 font-mono"
/>
</div>
<div className="space-y-1">
<label className="block text-sm text-slate-300">Port</label>
<input
value={port}
onChange={(e) => setPort(e.target.value)}
disabled={loading}
placeholder="e.g. 443"
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 font-mono"
/>
</div>
</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,637 @@
/**
* 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;
metadata?: 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);
// Block position saves briefly after map load to prevent fitView from
// firing spurious position changes that overwrite stored positions
const blockSaveRef = useRef(false);
// Load maps list on mount
useEffect(() => {
fetchMaps().catch(() => toast.error('Failed to load maps'));
}, [fetchMaps]);
// When active map changes, update flow state and block position saves briefly
useEffect(() => {
if (activeMap) {
blockSaveRef.current = true;
setNodes(toFlowNodes(activeMap));
setEdges(toFlowEdges(activeMap));
// Unblock after React Flow has settled its initial layout
const t = setTimeout(() => { blockSaveRef.current = false; }, 800);
return () => clearTimeout(t);
} else {
setNodes([]);
setEdges([]);
}
}, [activeMap, setNodes, setEdges]);
// Debounced node position save (500ms after drag ends)
const handleNodesChange = useCallback(
(changes: NodeChange<Node>[]) => {
onNodesChange(changes);
// Don't persist positions during initial load / fitView settle period
if (blockSaveRef.current) return;
const positionChanges = changes.filter((c) => {
if (c.type !== 'position') return false;
const pc = c as { type: 'position'; id: string; position?: { x: number; y: number }; dragging?: boolean };
// Only save when drag has fully ended (dragging === false) and position is present
return pc.dragging === false && pc.position != null;
});
if (positionChanges.length === 0) return;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => {
for (const change of positionChanges) {
const pc = change as { id: string; position: { x: number; y: number } };
try {
await apiClient.nodes.update(pc.id, {
positionX: pc.position.x,
positionY: pc.position.y,
});
} catch {
// Silent — minor 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 }; metadata?: 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 }; metadata?: string };
setNodeEditState({
nodeId: node.id,
label: d.label ?? '',
color: d.color,
moduleId: d.module?.id ?? null,
metadata: d.metadata ?? 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; metadata?: string }) {
if (!nodeEditState) return;
setNodes((nds) =>
nds.map((n) =>
n.id === nodeEditState.nodeId
? { ...n, data: { ...n.data, label: updated.label, color: updated.color, metadata: updated.metadata } }
: 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;
const node = nodes.find(n => n.id === nodeId);
const metadata = node ? (node.data as any).metadata : null;
return [
{
label: 'Edit node',
icon: <Edit2 size={12} />,
onClick: () => setNodeEditState({ nodeId, label, color, moduleId, metadata }),
},
{
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}
initialMetadata={nodeEditState.metadata}
onSaved={handleNodeEditSaved}
/>
)}
</div>
);
}
export function ServiceMapper() {
return (
<ReactFlowProvider>
<ServiceMapperInner />
</ReactFlowProvider>
);
}
@@ -0,0 +1,43 @@
import { memo, useMemo } 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 meta = useMemo(() => {
try {
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
} catch {
return {};
}
}, [data]);
const hasAddress = meta.ipAddress || meta.port;
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 flex-col gap-1">
<div className="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>
{hasAddress && (
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
{meta.ipAddress}
{meta.ipAddress && meta.port && ':'}
{meta.port}
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-yellow-400 !border-yellow-600" />
</div>
);
});
ApiNode.displayName = 'ApiNode';
@@ -0,0 +1,37 @@
import { memo, useMemo } 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';
const meta = useMemo(() => {
try {
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
} catch {
return {};
}
}, [data]);
const hasAddress = meta.ipAddress || meta.port;
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 flex-col gap-1">
<div className="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>
{hasAddress && (
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
{meta.ipAddress}
{meta.ipAddress && meta.port && ':'}
{meta.port}
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-teal-400 !border-teal-600" />
</div>
);
});
DatabaseNode.displayName = 'DatabaseNode';
@@ -0,0 +1,76 @@
import { memo, useMemo } 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;
const meta = useMemo(() => {
try {
return nodeData.metadata ? JSON.parse(nodeData.metadata as string) : {};
} catch {
return {};
}
}, [nodeData.metadata]);
const ipToDisplay = meta.ipAddress || mod?.ipAddress;
const hasAddress = ipToDisplay || meta.port;
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 mt-1">
<Badge variant="slate" className="text-[10px]">{MODULE_TYPE_LABELS[mod.type]}</Badge>
{hasAddress && (
<span className="text-[10px] text-slate-400 font-mono ml-1 mt-0.5">
{ipToDisplay}{meta.port ? `:${meta.port}` : ''}
</span>
)}
</div>
)}
{!mod && (
<div className="flex flex-col mt-1">
<span className="text-[10px] text-slate-500">Unlinked device</span>
{hasAddress && (
<span className="text-[10px] text-slate-400 font-mono">
{ipToDisplay}{meta.port ? `:${meta.port}` : ''}
</span>
)}
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div>
);
});
DeviceNode.displayName = 'DeviceNode';
@@ -0,0 +1,37 @@
import { memo, useMemo } 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';
const meta = useMemo(() => {
try {
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
} catch {
return {};
}
}, [data]);
const hasAddress = meta.ipAddress || meta.port;
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 flex-col gap-1">
<div className="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>
{hasAddress && (
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
{meta.ipAddress}
{meta.ipAddress && meta.port && ':'}
{meta.port}
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div>
);
});
ExternalNode.displayName = 'ExternalNode';
@@ -0,0 +1,37 @@
import { memo, useMemo } 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';
const meta = useMemo(() => {
try {
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
} catch {
return {};
}
}, [data]);
const hasAddress = meta.ipAddress || meta.port;
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 flex-col gap-1">
<div className="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>
{hasAddress && (
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
{meta.ipAddress}
{meta.ipAddress && meta.port && ':'}
{meta.port}
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-red-400 !border-red-600" />
</div>
);
});
FirewallNode.displayName = 'FirewallNode';
@@ -0,0 +1,37 @@
import { memo, useMemo } 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';
const meta = useMemo(() => {
try {
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
} catch {
return {};
}
}, [data]);
const hasAddress = meta.ipAddress || meta.port;
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 flex-col gap-1">
<div className="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>
{hasAddress && (
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
{meta.ipAddress}
{meta.ipAddress && meta.port && ':'}
{meta.port}
</div>
)}
</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,44 @@
import { memo, useMemo } 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';
const meta = useMemo(() => {
try {
return (data as any).metadata ? JSON.parse((data as any).metadata) : {};
} catch {
return {};
}
}, [data]);
const hasAddress = meta.ipAddress || meta.port;
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 flex-col gap-1">
<div className="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>
{hasAddress && (
<div className="text-[10px] text-slate-400 font-mono pl-[21px] truncate">
{meta.ipAddress}
{meta.ipAddress && meta.port && ':'}
{meta.port}
</div>
)}
</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,270 @@
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 [sfpCount, setSfpCount] = useState(0);
const [wanCount, setWanCount] = useState(0);
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]);
setSfpCount(0);
setWanCount(0);
}
function reset() {
setSelectedType(null);
setName('');
setUSize(1);
setPortCount(0);
setIpAddress('');
setManufacturer('');
setModel('');
setSfpCount(0);
setWanCount(0);
}
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,
sfpCount,
wanCount,
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-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-3 gap-3">
<div className="space-y-1">
<label className="block text-sm text-slate-300">Ethernet</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">SFP</label>
<input
type="number" min={0} max={128}
value={sfpCount}
onChange={(e) => setSfpCount(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">WAN</label>
<input
type="number" min={0} max={128}
value={wanCount}
onChange={(e) => setWanCount(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>
<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,200 @@
import { useState, useEffect, useMemo, type FormEvent } from 'react';
import { toast } from 'sonner';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/Button';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { useRackStore } from '../../store/useRackStore';
interface ConnectionConfigModalProps {
connectionId: string | null;
open: boolean;
onClose: () => void;
}
const EDGE_STYLES = [
{ value: 'bezier', label: 'Curved (Bezier)' },
{ value: 'straight', label: 'Straight Line' },
{ value: 'step', label: 'Stepped / Orthogonal' },
];
const PRESET_COLORS = [
'#3b82f6', // Blue
'#10b981', // Emerald
'#8b5cf6', // Violet
'#ef4444', // Red
'#f59e0b', // Amber
'#ec4899', // Pink
'#64748b', // Slate
'#ffffff', // White
];
export function ConnectionConfigModal({ connectionId, open, onClose }: ConnectionConfigModalProps) {
const { racks, updateConnection, deleteConnection } = useRackStore();
const [loading, setLoading] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
// Synchronously find the connection from the global store
const connection = useMemo(() => {
if (!connectionId) return null;
for (const rack of racks) {
for (const mod of rack.modules) {
for (const port of mod.ports) {
const found = port.sourceConnections?.find((c) => c.id === connectionId);
if (found) return found;
}
}
}
return null;
}, [racks, connectionId]);
// Form state
const [color, setColor] = useState('#3b82f6');
const [edgeType, setEdgeType] = useState('bezier');
const [label, setLabel] = useState('');
// Reset form state when connection is found or changed
useEffect(() => {
if (connection && open) {
setColor(connection.color || '#3b82f6');
setEdgeType(connection.edgeType || 'bezier');
setLabel(connection.label || '');
}
}, [connection, open]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!connectionId) return;
setLoading(true);
try {
await updateConnection(connectionId, { color, edgeType, label });
toast.success('Connection updated');
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Update failed');
} finally {
setLoading(false);
}
}
async function handleDelete() {
if (!connectionId) return;
setLoading(true);
try {
await deleteConnection(connectionId);
toast.success('Connection removed');
setConfirmDeleteOpen(false);
onClose();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Delete failed');
} finally {
setLoading(false);
}
}
if (!open || !connection) return null;
return (
<>
<Modal open={open} onClose={() => !loading && onClose()} title="Edit Connection">
<div className="flex flex-col gap-6">
<p className="text-sm text-slate-400">
Customize the cable style and color.
</p>
<form id="connection-form" onSubmit={handleSubmit} className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<label htmlFor="connection-label" className="text-sm font-semibold text-slate-300">Cable Label (Optional)</label>
<input
id="connection-label"
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. Uplink to Core"
className="px-3 py-2 rounded-md bg-slate-900 border border-slate-700 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold text-slate-300">Cable Color</label>
<div className="flex flex-wrap gap-2">
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
style={{ backgroundColor: c }}
className={`w-8 h-8 rounded-full border-2 transition-all hover:scale-110 ${
color === c ? 'border-white scale-110 shadow-lg' : 'border-transparent'
}`}
aria-label={`Select color ${c}`}
/>
))}
<div className="relative w-8 h-8 rounded-full overflow-hidden border-2 border-slate-700">
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="absolute -top-2 -left-2 w-12 h-12 cursor-pointer"
title="Custom color"
aria-label="Select custom cable color"
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold text-slate-300">Curve Style</label>
<div className="grid grid-cols-3 gap-2">
{EDGE_STYLES.map((style) => (
<button
key={style.value}
type="button"
onClick={() => setEdgeType(style.value)}
className={`px-3 py-2 rounded-md border text-xs font-medium transition-all ${
edgeType === style.value
? 'bg-blue-500/10 border-blue-500 text-blue-400'
: 'bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-slate-200'
}`}
aria-pressed={edgeType === style.value}
>
{style.label}
</button>
))}
</div>
</div>
</form>
<div className="flex items-center justify-between pt-4 border-t border-slate-800">
<Button
type="button"
variant="ghost"
className="text-red-400 hover:text-red-300"
onClick={() => setConfirmDeleteOpen(true)}
disabled={loading}
>
Delete Connection
</Button>
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button type="submit" form="connection-form" variant="primary" loading={loading}>
Save Changes
</Button>
</div>
</div>
</div>
</Modal>
<ConfirmDialog
open={confirmDeleteOpen}
onClose={() => !loading && setConfirmDeleteOpen(false)}
onConfirm={handleDelete}
title="Delete Connection"
message="Are you sure you want to remove this connection?"
confirmLabel="Delete Connection"
loading={loading}
/>
</>
);
}
@@ -0,0 +1,203 @@
import { useState, useEffect, type FormEvent } from 'react';
import { Trash2 } from 'lucide-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, removeModuleLocal } = 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);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
if (open) {
setConfirmingDelete(false);
setName(module.name);
setIpAddress(module.ipAddress ?? '');
setManufacturer(module.manufacturer ?? '');
setModelVal(module.model ?? '');
setNotes(module.notes ?? '');
setUSize(module.uSize);
}
}, [open, module]);
async function handleDelete() {
setDeleting(true);
try {
await apiClient.modules.delete(module.id);
removeModuleLocal(module.id);
toast.success(`${module.name} removed`);
onClose();
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Delete failed');
} finally {
setDeleting(false);
setConfirmingDelete(false);
}
}
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 items-center justify-between gap-3 pt-1 border-t border-slate-700 mt-1">
{/* Delete — left side with inline confirm */}
{confirmingDelete ? (
<div className="flex items-center gap-2">
<span className="text-xs text-red-400">Remove this module?</span>
<Button
variant="danger"
size="sm"
type="button"
loading={deleting}
onClick={handleDelete}
>
Delete
</Button>
<Button
variant="secondary"
size="sm"
type="button"
onClick={() => setConfirmingDelete(false)}
disabled={deleting}
>
Cancel
</Button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmingDelete(true)}
disabled={loading}
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-red-400 transition-colors disabled:opacity-40"
>
<Trash2 size={13} />
Delete module
</button>
)}
{/* Save / Cancel — right side */}
<div className="flex gap-2">
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading || deleting}>
Cancel
</Button>
<Button size="sm" type="submit" loading={loading} disabled={!name.trim() || deleting}>
Save Changes
</Button>
</div>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,338 @@
import { useState, useEffect, useMemo, 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, deleteConnection } = useRackStore();
const [vlans, setVlans] = useState<Vlan[]>([]);
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
// Synchronously find the port from the global store
const port = useMemo(() => {
for (const rack of racks) {
for (const mod of rack.modules) {
const found = mod.ports.find((p) => p.id === portId);
if (found) return found;
}
}
return null;
}, [racks, portId]);
// Form state
const [label, setLabel] = useState('');
const [mode, setMode] = useState<VlanMode>('ACCESS');
const [nativeVlanId, setNativeVlanId] = useState<string>('');
const [taggedVlanIds, setTaggedVlanIds] = useState<string[]>([]);
const [notes, setNotes] = useState('');
// Quick-create VLAN
const [newVlanId, setNewVlanId] = useState('');
const [newVlanName, setNewVlanName] = useState('');
const [newVlanColor, setNewVlanColor] = useState('#3b82f6');
const [creatingVlan, setCreatingVlan] = useState(false);
// Reset form state when port is found or changed
useEffect(() => {
if (port && open) {
setLabel(port.label ?? '');
setMode(port.mode);
setNativeVlanId(port.nativeVlan?.toString() ?? '');
setTaggedVlanIds(port.vlans.filter((v) => v.tagged).map((v) => v.vlanId));
setNotes(port.notes ?? '');
}
}, [port, open]);
// 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(),
color: newVlanColor,
});
setVlans((v) => [...v, created].sort((a, b) => a.vlanId - b.vlanId));
setNewVlanId('');
setNewVlanName('');
setNewVlanColor('#3b82f6');
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;
const connections = [...(port.sourceConnections || []), ...(port.targetConnections || [])];
async function handleDisconnect(connId: string) {
if (!confirm('Remove this patch cable?')) return;
try {
setLoading(true);
await deleteConnection(connId);
toast.success('Disconnected');
} catch (err) {
toast.error('Failed to disconnect');
} finally {
setLoading(false);
}
}
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>
{/* Existing Connections */}
{connections.length > 0 && (
<div className="space-y-2">
<label className="block text-sm text-slate-300">Patch Cables</label>
<div className="space-y-1.5">
{connections.map((c) => (
<div
key={c.id}
className="p-2 bg-slate-800 border border-slate-700 rounded-lg flex items-center justify-between"
>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: c.color || '#3b82f6' }} />
<span className="text-xs text-slate-200">
Cable {c.label || `#${c.id.slice(-4)}`}
</span>
</div>
<button
type="button"
onClick={() => handleDisconnect(c.id)}
className="text-[10px] uppercase font-bold text-red-400 hover:text-red-300 px-2 py-1 rounded hover:bg-red-950 transition-colors"
>
Disconnect
</button>
</div>
))}
</div>
</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>
<div className="flex items-center gap-2">
<select
value={nativeVlanId}
onChange={(e) => setNativeVlanId(e.target.value)}
disabled={loading || fetching}
className="flex-1 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>
{nativeVlanId && (
<div
className="w-5 h-5 rounded-full border border-slate-600 shrink-0"
style={{
backgroundColor: vlans.find((v) => v.vlanId === Number(nativeVlanId))?.color ?? '#3b82f6',
}}
/>
)}
</div>
</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)}
style={{
backgroundColor: taggedVlanIds.includes(v.id) ? v.color ?? '#3b82f6' : 'transparent',
borderColor: taggedVlanIds.includes(v.id) ? 'transparent' : v.color ?? '#475569',
color: taggedVlanIds.includes(v.id) ? '#fff' : v.color ?? '#94a3b8',
}}
className={`px-2 py-0.5 rounded text-[11px] border font-medium transition-all hover:brightness-110 flex items-center gap-1`}
>
<div
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: taggedVlanIds.includes(v.id) ? '#fff' : v.color ?? '#3b82f6' }}
/>
{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 min-w-0 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
type="color"
value={newVlanColor}
onChange={(e) => setNewVlanColor(e.target.value)}
className="w-8 h-8 rounded shrink-0 bg-transparent border border-slate-600 p-0.5 cursor-pointer"
title="VLAN Color"
/>
<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,208 @@
import { useEffect, useState, useMemo, useCallback } from 'react';
import { useRackStore } from '../../store/useRackStore';
export function ConnectionLayer() {
const { racks, cablingFromPortId, setActiveConfigConnectionId } = useRackStore();
const [coords, setCoords] = useState<Record<string, { x: number; y: number }>>({});
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [isShiftPressed, setIsShiftPressed] = useState(false);
// Update port coordinates
const updateCoords = useCallback(() => {
const newCoords: Record<string, { x: number; y: number }> = {};
const dots = document.querySelectorAll('[data-port-id]');
// Find the closest scrollable parent that defines our coordinate system
// RackPlanner has overflow-auto on the canvas wrapper
const canvas = document.querySelector('.rack-planner-canvas');
if (!canvas) return;
const canvasRect = canvas.getBoundingClientRect();
dots.forEach((dot) => {
const portId = (dot as HTMLElement).dataset.portId;
if (!portId) return;
const rect = dot.getBoundingClientRect();
// Coordinate is relative to the canvas origin, including its scroll position
newCoords[portId] = {
x: rect.left + rect.width / 2 - canvasRect.left + canvas.scrollLeft,
y: rect.top + rect.height / 2 - canvasRect.top + canvas.scrollTop,
};
});
setCoords(newCoords);
}, []);
useEffect(() => {
updateCoords();
// Re-calculate on window resize or when racks change (modules move)
window.addEventListener('resize', updateCoords);
// Also re-calculate if the user scrolls (though ideally lines are pinned to the canvas)
// Actually, if SVG is INSIDE the scrollable container, we don't need scroll adjustment.
// Use a MutationObserver to detect DOM changes (like modules being added/moved)
const observer = new MutationObserver(() => {
// Small debounce or check if it was our OWN SVG that changed
updateCoords();
});
const canvas = document.querySelector('.rack-planner-canvas');
if (canvas) {
// DO NOT observe the entire subtree with attributes if it includes the ConnectionLayer
// Instead, just watch for module layout changes
observer.observe(canvas, { childList: true, subtree: true });
}
return () => {
window.removeEventListener('resize', updateCoords);
observer.disconnect();
};
}, [racks, updateCoords]);
// Track mouse for "draft" connection (only while actively cabling)
useEffect(() => {
if (!cablingFromPortId) return;
const onMouseMove = (e: MouseEvent) => {
const canvas = document.querySelector('.rack-planner-canvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
setMousePos({
x: e.clientX - rect.left + canvas.scrollLeft,
y: e.clientY - rect.top + canvas.scrollTop,
});
};
window.addEventListener('mousemove', onMouseMove);
return () => window.removeEventListener('mousemove', onMouseMove);
}, [cablingFromPortId]);
useEffect(() => {
const syncShiftState = (event: KeyboardEvent) => {
setIsShiftPressed(event.shiftKey);
};
const clearShiftState = () => {
setIsShiftPressed(false);
};
window.addEventListener('keydown', syncShiftState);
window.addEventListener('keyup', syncShiftState);
window.addEventListener('blur', clearShiftState);
return () => {
window.removeEventListener('keydown', syncShiftState);
window.removeEventListener('keyup', syncShiftState);
window.removeEventListener('blur', clearShiftState);
};
}, []);
const connections = useMemo(() => {
const conns: { id: string; from: string; to: string; color?: string; edgeType?: string }[] = [];
racks.forEach((rack) => {
rack.modules.forEach((mod) => {
mod.ports.forEach((port) => {
port.sourceConnections?.forEach((c) => {
conns.push({
id: c.id,
from: c.fromPortId,
to: c.toPortId,
color: c.color,
edgeType: c.edgeType,
});
});
});
});
});
return conns;
}, [racks]);
// Decide if we should show draft line
const draftStart = cablingFromPortId ? coords[cablingFromPortId] : null;
return (
<svg
className="absolute top-0 left-0 pointer-events-none z-20 overflow-visible"
style={{ width: '1px', height: '1px' }} // SVG origin is top-left of canvas
>
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="4" markerHeight="4" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
</marker>
</defs>
{/* Existing connections */}
{connections.map((conn) => {
const start = coords[conn.from];
const end = coords[conn.to];
if (!start || !end) return null;
let d = '';
if (conn.edgeType === 'straight') {
d = `M ${start.x} ${start.y} L ${end.x} ${end.y}`;
} else if (conn.edgeType === 'step') {
const midX = start.x + (end.x - start.x) / 2;
d = `M ${start.x} ${start.y} L ${midX} ${start.y} L ${midX} ${end.y} L ${end.x} ${end.y}`;
} else {
// default bezier
const dx = Math.abs(end.x - start.x);
const dy = Math.abs(end.y - start.y);
const distance = Math.sqrt(dx*dx + dy*dy);
const curvature = Math.min(100, distance / 3);
d = `M ${start.x} ${start.y} C ${start.x + curvature} ${start.y}, ${end.x - curvature} ${end.y}, ${end.x} ${end.y}`;
}
return (
<g key={conn.id} className="connection-group">
<path
d={d}
stroke={conn.color || '#3b82f6'}
strokeWidth="2.5"
fill="none"
opacity="0.8"
className="drop-shadow-sm transition-opacity hover:opacity-100"
/>
{/* Thicker transparent helper for easier identification if we ever add hover interactions */}
<path
d={d}
stroke="transparent"
strokeWidth="10"
fill="none"
className={isShiftPressed ? 'pointer-events-auto cursor-pointer' : 'pointer-events-none'}
tabIndex={0}
role="button"
aria-label="Edit connection"
onClick={(e) => {
if (e.shiftKey) {
e.stopPropagation();
setActiveConfigConnectionId(conn.id);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setActiveConfigConnectionId(conn.id);
}
}}
/>
</g>
);
})}
{/* Draft connection line (dashed) */}
{draftStart && (
<line
x1={draftStart.x}
y1={draftStart.y}
x2={mousePos.x}
y2={mousePos.y}
stroke="#3b82f6"
strokeWidth="2"
strokeDasharray="5 3"
opacity="0.6"
/>
)}
</svg>
);
}
@@ -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: { dragType: 'palette', 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 touch-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>
);
}
+282
View File
@@ -0,0 +1,282 @@
import { useState, useCallback, useRef } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { GripHorizontal } from 'lucide-react';
import { toast } from 'sonner';
import type { Module } from '../../types';
import { cn } from '../../lib/utils';
import { MODULE_TYPE_COLORS, U_HEIGHT_PX, PORTS_PER_ROW } from '../../lib/constants';
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, updateModuleLocal, setActiveConfigPortId } = useRackStore();
const [hovered, setHovered] = useState(false);
const [editOpen, setEditOpen] = useState(false);
// 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;
// Categorize ports for layout: separate WAN/SFP to the right
const mainPorts = module.ports.filter(p => !['SFP', 'SFP_PLUS', 'QSFP', 'WAN'].includes(p.portType));
const sidePorts = module.ports.filter(p => ['SFP', 'SFP_PLUS', 'QSFP', 'WAN'].includes(p.portType));
// Split Main ports into rows
const portRows: (typeof module.ports)[] = [];
if (mainPorts.length > 0) {
for (let i = 0; i < mainPorts.length; i += PORTS_PER_ROW) {
portRows.push(mainPorts.slice(i, i + PORTS_PER_ROW));
}
} else if (sidePorts.length > 0) {
portRows.push([]);
}
const availableForPorts = height - 16;
const maxRows = Math.max(1, Math.floor(availableForPorts / 14));
const visibleRows = portRows.length > 0 ? portRows.slice(0, maxRows) : [];
const hiddenPortCount = mainPorts.length - (visibleRows.length > 0 ? visibleRows.flat().length : 0);
// SFP/WAN ports often sit on the far right of the module
// We'll show them on the first row if possible
// 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');
}
}
function openPort(portId: string) {
setActiveConfigPortId(portId);
}
const { cablingFromPortId, setCablingFromPortId, createConnection } = useRackStore();
async function handlePortClick(e: React.MouseEvent, portId: string) {
e.stopPropagation();
// If shift key is pressed, open config modal as before
if (e.shiftKey) {
openPort(portId);
return;
}
// Toggle cabling mode
if (!cablingFromPortId) {
setCablingFromPortId(portId);
} else if (cablingFromPortId === portId) {
setCablingFromPortId(null);
} else {
// Connect!
try {
await createConnection(cablingFromPortId, portId);
setCablingFromPortId(null);
toast.success('Patch cable connected');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Connection failed');
setCablingFromPortId(null);
}
}
}
return (
<>
<div
ref={setNodeRef}
{...listeners}
{...attributes}
className={cn(
'module-block relative w-full border-l-4 select-none overflow-hidden transition-opacity',
colors.bg,
colors.border,
isDragging ? 'opacity-0 pointer-events-none' : 'cursor-grab active:cursor-grabbing',
!isDragging && hovered && 'brightness-110',
previewUSize !== null && 'ring-1 ring-white/30'
)}
style={{ height }}
title={`${module.name}${module.ipAddress ? `${module.ipAddress}` : ''}`}
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)}
>
{/* Port grid — primary face content */}
{hasPorts && previewUSize === null ? (
<div className="flex flex-col gap-1.5 px-2 pt-1.5">
{visibleRows.map((row, rowIdx) => (
<div key={rowIdx} className="flex items-center w-full min-h-[12px]">
{/* Standard ports Group */}
<div className="flex gap-[3px] flex-wrap">
{row.map((port) => {
const hasVlan = port.vlans.length > 0;
const vlanColor = hasVlan
? port.mode === 'ACCESS'
? port.vlans[0]?.vlan?.color || '#10b981'
: '#a78bfa'
: '#475569';
const isCablingSource = cablingFromPortId === port.id;
return (
<button
key={port.id}
data-port-id={port.id}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => handlePortClick(e, port.id)}
aria-label={`Port ${port.portNumber}`}
title={`Port ${port.portNumber}\n${port.portType}${port.label ? ` · ${port.label}` : ''}`}
style={{ backgroundColor: vlanColor }}
className={cn(
'w-2 h-2 rounded-full border border-black/20 hover:scale-125 transition-all outline-none',
isCablingSource && 'ring-2 ring-white ring-offset-1 ring-offset-slate-900 animate-pulse'
)}
/>
);
})}
</div>
{/* SFP/WAN Group (push to right) */}
{rowIdx === 0 && sidePorts.length > 0 && (
<div className="flex gap-1.5 ml-auto border-l border-slate-700/50 pl-1.5 h-3 items-center">
{sidePorts.map((port) => {
const hasVlan = port.vlans.length > 0;
const isSfp = port.portType?.includes('SFP');
const isWan = port.portType === 'WAN';
const vlanColor = hasVlan
? port.vlans[0]?.vlan?.color || '#3b82f6'
: isWan ? '#2563eb' : '#94a3b8';
const isCablingSource = cablingFromPortId === port.id;
return (
<button
key={port.id}
data-port-id={port.id}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => handlePortClick(e, port.id)}
title={`${port.portType} ${port.portNumber}`}
style={{ backgroundColor: vlanColor }}
className={cn(
'w-2.5 h-2.5 transition-transform hover:scale-125 border border-black/40',
isSfp ? 'rounded-none rotate-45 scale-75' : 'rounded-full ring-1 ring-white/10',
isCablingSource && 'ring-2 ring-white ring-offset-1 ring-offset-slate-900 animate-pulse'
)}
/>
);
})}
</div>
)}
</div>
))}
{hiddenPortCount > 0 && (
<span className="text-[10px] text-slate-500">+{hiddenPortCount} ports</span>
)}
</div>
) : (
previewUSize === null && (
<div className="px-2 pt-1.5 text-[10px] text-white/30 italic select-none">no ports</div>
)
)}
{/* Resize preview label */}
{previewUSize !== null && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="text-xs font-bold text-white/80 bg-black/40 px-2 py-0.5 rounded">
{previewUSize}U
</span>
</div>
)}
{/* 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={(e) => { e.stopPropagation(); handleResizePointerDown(e); }}
onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp}
onClick={(e) => e.stopPropagation()}
aria-label="Resize module"
title="Drag to resize"
>
<GripHorizontal size={9} className="text-white/40 pointer-events-none" />
</div>
</div>
<ModuleEditPanel module={module} open={editOpen} onClose={() => setEditOpen(false)} />
</>
);
}
+130
View File
@@ -0,0 +1,130 @@
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 { U_HEIGHT_PX } from '../../lib/constants';
import { ModuleBlock } from './ModuleBlock';
import { RackSlot } from './RackSlot';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { useRackStore } from '../../store/useRackStore';
interface RackColumnProps {
rack: Rack;
/** Slot currently hovered by a drag — passed down to RackSlot for blue highlight. */
hoverSlot?: { rackId: string; uPosition: number } | null;
}
export function RackColumn({ rack, hoverSlot }: 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,
};
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-[384px] w-96 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="relative border-x border-slate-600 bg-[#1e2433]"
style={{ height: rack.totalU * U_HEIGHT_PX }}
>
<div className="absolute inset-0 flex flex-col">
{Array.from({ length: rack.totalU }, (_, i) => i + 1).map((u) => (
<RackSlot
key={u}
rackId={rack.id}
uPosition={u}
isOver={hoverSlot?.rackId === rack.id && hoverSlot?.uPosition === u}
/>
))}
</div>
{rack.modules.map((module) => (
<div
key={module.id}
className="absolute left-0 right-0 z-10"
style={{ top: (module.uPosition - 1) * U_HEIGHT_PX }}
>
<ModuleBlock module={module} />
</div>
))}
</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}
/>
</>
);
}
+318
View File
@@ -0,0 +1,318 @@
import { useEffect, useRef, useState } from 'react';
import {
DndContext,
DragOverlay,
PointerSensor,
closestCenter,
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 { ConnectionLayer } from './ConnectionLayer';
import { AddModuleModal } from '../modals/AddModuleModal';
import { PortConfigModal } from '../modals/PortConfigModal';
import { ConnectionConfigModal } from '../modals/ConnectionConfigModal';
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;
}
interface HoverSlot {
rackId: string;
uPosition: number;
}
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>
);
}
/**
* Resolve which rack slot (if any) is under the pointer during a drag.
*
* Strategy: elementFromPoint at the current pointer coordinates.
* - All ModuleBlocks get pointer-events:none via the body.rack-dragging CSS rule,
* so elementFromPoint sees through them to the slot element beneath.
* - DragOverlay has pointer-events:none natively (dnd-kit).
* - RackSlot divs carry data-rack-id / data-u-pos attributes that we read here.
*/
function resolveSlotFromPoint(clientX: number, clientY: number): HoverSlot | null {
const el = document.elementFromPoint(clientX, clientY);
if (!el) return null;
const slotEl = el.closest('[data-rack-id][data-u-pos]') as HTMLElement | null;
if (!slotEl) return null;
const rackId = slotEl.dataset.rackId;
const uPos = parseInt(slotEl.dataset.uPos ?? '', 10);
if (!rackId || isNaN(uPos)) return null;
return { rackId, uPosition: uPos };
}
const POINTER_SENSOR_OPTIONS = {
activationConstraint: { distance: 6 },
};
export function RackPlanner() {
const { racks, loading, fetchRacks, moveModule, activeConfigPortId, setActiveConfigPortId, activeConfigConnectionId, setActiveConfigConnectionId } = 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);
// hoverSlot drives the blue highlight on slots during drag.
// hoverSlotRef is the reliable read-path inside async handleDragEnd
// (avoids stale-closure issues with state).
const [hoverSlot, setHoverSlot] = useState<HoverSlot | null>(null);
const hoverSlotRef = useRef<HoverSlot | null>(null);
// Tracks whether ANY module/palette drag is in progress — used to
// activate the body.rack-dragging CSS class and the pointermove listener.
const isDraggingAnyRef = useRef(false);
function updateHoverSlot(slot: HoverSlot | null) {
hoverSlotRef.current = slot;
setHoverSlot(slot);
}
const sensors = useSensors(
useSensor(PointerSensor, POINTER_SENSOR_OPTIONS)
);
useEffect(() => {
fetchRacks().catch(() => toast.error('Failed to load racks'));
}, [fetchRacks]);
/**
* Native pointermove listener registered once on mount.
* Only runs while isDraggingAnyRef is true — gives us the exact cursor
* position without any reconstruction arithmetic, so resolveSlotFromPoint
* is always called with accurate coordinates.
*/
useEffect(() => {
function onPointerMove(e: PointerEvent) {
if (!isDraggingAnyRef.current) return;
const slot = resolveSlotFromPoint(e.clientX, e.clientY);
const prev = hoverSlotRef.current;
if (prev?.rackId !== slot?.rackId || prev?.uPosition !== slot?.uPosition) {
hoverSlotRef.current = slot;
setHoverSlot(slot);
}
}
// Capture phase so we get the event before any element can stop propagation.
window.addEventListener('pointermove', onPointerMove, { capture: true });
return () => window.removeEventListener('pointermove', onPointerMove, { capture: true });
}, []);
function handleDragStart(event: DragStartEvent) {
const data = event.active.data.current as Record<string, unknown>;
if (data?.dragType === 'palette') {
setActivePaletteType(data.type as ModuleType);
isDraggingAnyRef.current = true;
document.body.classList.add('rack-dragging');
} else if (data?.dragType === 'module') {
setDraggingModuleId(data.moduleId as string);
setActiveDragModuleLabel(data.label as string);
isDraggingAnyRef.current = true;
document.body.classList.add('rack-dragging');
}
updateHoverSlot(null);
}
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
// Stop native hover tracking and remove body class FIRST
isDraggingAnyRef.current = false;
document.body.classList.remove('rack-dragging');
// Capture hoverSlot BEFORE resetting state
const slot = hoverSlotRef.current;
setActivePaletteType(null);
setActiveDragModuleLabel(null);
setDraggingModuleId(null);
updateHoverSlot(null);
const dragData = active.data.current as Record<string, unknown>;
// --- Palette → slot: open AddModuleModal pre-filled ---
if (dragData?.dragType === 'palette' && slot) {
setPendingDrop({
type: dragData.type as ModuleType,
rackId: slot.rackId,
uPosition: slot.uPosition,
});
return;
}
// --- Module → slot: move the module ---
if (dragData?.dragType === 'module' && slot) {
const moduleId = dragData.moduleId as string;
// No-op if dropped on own position
if (dragData.fromRackId === slot.rackId && dragData.fromUPosition === slot.uPosition) return;
try {
await moveModule(moduleId, slot.rackId, slot.uPosition);
toast.success('Module moved');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Move failed');
}
return;
}
// --- Rack header → rack header: reorder racks ---
if (!over) return;
const dropData = over.data.current as Record<string, unknown> | undefined;
if (dragData?.dragType === 'rack' && dropData?.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);
try {
await Promise.all(
reordered.map((rack, idx) =>
rack.displayOrder !== idx
? apiClient.racks.update(rack.id, { displayOrder: idx })
: Promise.resolve(rack)
)
);
await fetchRacks();
} catch {
toast.error('Failed to save rack order');
await fetchRacks();
}
}
}
const rackIds = racks.map((r) => r.id);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
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 relative rack-planner-canvas">
{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}
hoverSlot={hoverSlot}
/>
))}
</div>
</SortableContext>
<ConnectionLayer />
</>
)}
</div>
</div>
</div>
<DragOverlay dropAnimation={null} className="pointer-events-none" zIndex={999} style={{ pointerEvents: 'none' }}>
{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}
/>
)}
{activeConfigPortId && (
<PortConfigModal
open={!!activeConfigPortId}
portId={activeConfigPortId}
onClose={() => setActiveConfigPortId(null)}
/>
)}
{activeConfigConnectionId && (
<ConnectionConfigModal
open={!!activeConfigConnectionId}
connectionId={activeConfigConnectionId}
onClose={() => setActiveConfigConnectionId(null)}
/>
)}
</DndContext>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react';
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;
/** Passed from RackPlanner via RackColumn — true when a drag is hovering this slot */
isOver?: boolean;
}
export function RackSlot({ rackId, uPosition, isOver = false }: RackSlotProps) {
const [addModuleOpen, setAddModuleOpen] = useState(false);
return (
<>
<div
// Data attributes let RackPlanner's onDragMove identify this slot via elementFromPoint
data-rack-id={rackId}
data-u-pos={String(uPosition)}
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>
);
}
+42
View File
@@ -0,0 +1,42 @@
@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 */
}
}
/*
* During any rack drag, make every module-block and ALL of its children
* transparent to pointer-events so that document.elementFromPoint() can
* "see through" them to the RackSlot elements underneath.
* The `!important` is necessary because individual elements (port buttons,
* resize handle) carry their own pointer-events values.
*/
body.rack-dragging .module-block,
body.rack-dragging .module-block * {
pointer-events: none !important;
}
+79
View File
@@ -0,0 +1,79 @@
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 = 44;
// ---- Ports rendered per row in ModuleBlock ----
export const PORTS_PER_ROW = 24;
// ---- 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 });
}
},
}));
+88
View File
@@ -0,0 +1,88 @@
import { create } from 'zustand';
import type { ServiceMap, ServiceMapSummary } from '../types';
import { apiClient } from '../api/client';
const LAST_MAP_KEY = 'rackmapper:lastMapId';
function saveLastMapId(id: string | null) {
if (id) localStorage.setItem(LAST_MAP_KEY, id);
else localStorage.removeItem(LAST_MAP_KEY);
}
function getLastMapId(): string | null {
return localStorage.getItem(LAST_MAP_KEY);
}
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, get) => ({
maps: [],
activeMap: null,
loading: false,
fetchMaps: async () => {
set({ loading: true });
try {
const maps = await apiClient.maps.list();
set({ maps, loading: false });
// Auto-restore the last active map after loading the list
const lastId = getLastMapId();
if (lastId && maps.some((m) => m.id === lastId)) {
await get().loadMap(lastId);
} else if (maps.length === 1) {
// Convenience: auto-load if there's only one map
await get().loadMap(maps[0].id);
}
} 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);
saveLastMapId(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);
if (getLastMapId() === id) saveLastMapId(null);
set((s) => ({
maps: s.maps.filter((m) => m.id !== id),
activeMap: s.activeMap?.id === id ? null : s.activeMap,
}));
},
setActiveMap: (map) => {
saveLastMapId(map?.id ?? null);
set({ activeMap: map });
},
}));
+150
View File
@@ -0,0 +1,150 @@
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;
// Cabling
cablingFromPortId: string | null;
setCablingFromPortId: (id: string | null) => void;
createConnection: (fromPortId: string, toPortId: string) => Promise<void>;
updateConnection: (id: string, data: Partial<{ color: string; label: string; edgeType: string }>) => Promise<void>;
deleteConnection: (id: string) => Promise<void>;
// Port Config Global Modal
activeConfigPortId: string | null;
setActiveConfigPortId: (id: string | null) => void;
// Connection Config Global Modal
activeConfigConnectionId: string | null;
setActiveConfigConnectionId: (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 }),
// Cabling
cablingFromPortId: null,
setCablingFromPortId: (id) => set({ cablingFromPortId: id }),
createConnection: async (fromPortId, toPortId) => {
await apiClient.connections.create({ fromPortId, toPortId });
// Refresh racks to get updated nested connections
const racks = await apiClient.racks.list();
set({ racks });
},
deleteConnection: async (id) => {
await apiClient.connections.delete(id);
const racks = await apiClient.racks.list();
set({ racks });
},
updateConnection: async (id, data) => {
await apiClient.connections.update(id, data);
const racks = await apiClient.racks.list();
set({ racks });
},
activeConfigPortId: null,
setActiveConfigPortId: (id) => set({ activeConfigPortId: id }),
activeConfigConnectionId: null,
setActiveConfigConnectionId: (id) => set({ activeConfigConnectionId: id }),
}));
+142
View File
@@ -0,0 +1,142 @@
// ---- 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' | 'WAN';
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;
// Physically connected links (patch cables)
sourceConnections?: Connection[];
targetConnections?: Connection[];
}
export interface Connection {
id: string;
fromPortId: string;
toPortId: string;
color?: string;
label?: string;
edgeType?: string;
createdAt: 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:/app/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
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -e
echo "[entrypoint] RackMapper starting..."
# Ensure the data directory exists and is writable
mkdir -p /app/data
echo "[entrypoint] Data directory: $(ls -la /app/data)"
# Run migrations (creates the SQLite file if it doesn't exist)
echo "[entrypoint] Running database migrations..."
npx prisma migrate deploy
echo "[entrypoint] Migrations complete."
# Start the server
echo "[entrypoint] Starting server..."
exec node dist/server/index.js
+43
View File
@@ -0,0 +1,43 @@
const js = require('@eslint/js');
const globals = require('globals');
const tseslint = require('typescript-eslint');
module.exports = tseslint.config(
{
ignores: ['dist/**', 'client/**', 'node_modules/**'],
},
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{
files: ['server/**/*.ts', 'scripts/**/*.ts', 'prisma/seed.ts'],
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
globals: {
...globals.node,
},
},
rules: {
'@typescript-eslint/no-misused-promises': [
'error',
{
checksVoidReturn: {
arguments: false,
},
},
],
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-unsafe-assignment': 'off',
},
}
);
+5630
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
{
"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": {
"@eslint/js": "^9.39.4",
"@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",
"globals": "^17.4.0",
"nodemon": "^3.1.7",
"prettier": "^3.3.3",
"prisma": "^5.22.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3",
"typescript-eslint": "^8.57.2",
"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");
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "Connection" (
"id" TEXT NOT NULL PRIMARY KEY,
"fromPortId" TEXT NOT NULL,
"toPortId" TEXT NOT NULL,
"color" TEXT,
"label" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Connection_fromPortId_fkey" FOREIGN KEY ("fromPortId") REFERENCES "Port" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Connection_toPortId_fkey" FOREIGN KEY ("toPortId") REFERENCES "Port" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Connection_fromPortId_toPortId_key" ON "Connection"("fromPortId", "toPortId");
@@ -0,0 +1,20 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Connection" (
"id" TEXT NOT NULL PRIMARY KEY,
"fromPortId" TEXT NOT NULL,
"toPortId" TEXT NOT NULL,
"color" TEXT,
"label" TEXT,
"edgeType" TEXT NOT NULL DEFAULT 'bezier',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Connection_fromPortId_fkey" FOREIGN KEY ("fromPortId") REFERENCES "Port" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Connection_toPortId_fkey" FOREIGN KEY ("toPortId") REFERENCES "Port" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_Connection" ("color", "createdAt", "fromPortId", "id", "label", "toPortId") SELECT "color", "createdAt", "fromPortId", "id", "label", "toPortId" FROM "Connection";
DROP TABLE "Connection";
ALTER TABLE "new_Connection" RENAME TO "Connection";
CREATE UNIQUE INDEX "Connection_fromPortId_toPortId_key" ON "Connection"("fromPortId", "toPortId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+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"
+136
View File
@@ -0,0 +1,136 @@
generator client {
provider = "prisma-client-js"
// linux-musl-openssl-3.0.x = Alpine Linux (Docker); native = local dev/build
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
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?
// Connections — port can be source or target of a patch cable
sourceConnections Connection[] @relation("SourcePort")
targetConnections Connection[] @relation("TargetPort")
}
model Connection {
id String @id @default(cuid())
fromPortId String
fromPort Port @relation("SourcePort", fields: [fromPortId], references: [id], onDelete: Cascade)
toPortId String
toPort Port @relation("TargetPort", fields: [toPortId], references: [id], onDelete: Cascade)
color String? // Optional custom cable color
label String? // Optional cable label (e.g. "Cable #104")
edgeType String @default("bezier") // bezier | straight | step
createdAt DateTime @default(now())
@@unique([fromPortId, toPortId])
}
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);
});
+12
View File
@@ -0,0 +1,12 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function check() {
const modules = await prisma.module.findMany({
include: { ports: true }
});
console.log(JSON.stringify(modules, null, 2));
}
check().catch(console.error);
+65
View File
@@ -0,0 +1,65 @@
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 connectionsRouter from './routes/connections';
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);
app.use('/api/connections', connectionsRouter);
// ---- 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' | 'WAN';
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));
}
+58
View File
@@ -0,0 +1,58 @@
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();
// secure:true requires HTTPS — for plain-HTTP homelab installs (Unraid, etc.)
// this must be false so the browser actually sends the cookie back.
// Set COOKIE_SECURE=true in your env only if you're behind an HTTPS reverse proxy.
const COOKIE_OPTS = {
httpOnly: true,
sameSite: 'strict' as const,
secure: process.env.COOKIE_SECURE === 'true',
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 }));
});
+49
View File
@@ -0,0 +1,49 @@
import { Router } from 'express';
import * as connService from '../services/connectionService';
import { ok } from '../types/index';
const router = Router();
// POST /api/connections
router.post('/', async (req, res, next) => {
try {
const { fromPortId, toPortId, color, label, edgeType } = req.body;
const conn = await connService.createConnection({ fromPortId, toPortId, color, label, edgeType });
res.status(201).json(ok(conn));
} catch (err) {
next(err);
}
});
// PUT /api/connections/:id
router.put('/:id', async (req, res, next) => {
try {
const { color, label, edgeType } = req.body;
const conn = await connService.updateConnection(req.params.id, { color, label, edgeType });
res.json(ok(conn));
} catch (err) {
next(err);
}
});
// DELETE /api/connections/:id
router.delete('/:id', async (req, res, next) => {
try {
await connService.deleteConnection(req.params.id);
res.json(ok(null));
} catch (err) {
next(err);
}
});
// DELETE /api/connections/ports/:p1/:p2 (remove link between two specific ports)
router.delete('/ports/:p1/:p2', async (req, res, next) => {
try {
await connService.deleteByPorts(req.params.p1, req.params.p2);
res.json(ok(null));
} catch (err) {
next(err);
}
});
export default router;
+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);
}
});
+85
View File
@@ -0,0 +1,85 @@
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, sfpCount, wanCount } =
req.body as {
name: string;
type: ModuleType;
uPosition: number;
uSize?: number;
manufacturer?: string;
model?: string;
ipAddress?: string;
notes?: string;
portCount?: number;
portType?: PortType;
sfpCount?: number;
wanCount?: number;
};
res.status(201).json(
ok(await moduleService.createModule(req.params.id, { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType, sfpCount, wanCount }))
);
} 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);
}
});
+41
View File
@@ -0,0 +1,41 @@
import { prisma } from '../lib/prisma';
import { AppError } from '../types/index';
export async function createConnection(data: { fromPortId: string; toPortId: string; color?: string; label?: string; edgeType?: string }) {
// Check if both ports exist
const [from, to] = await Promise.all([
prisma.port.findUnique({ where: { id: data.fromPortId } }),
prisma.port.findUnique({ where: { id: data.toPortId } }),
]);
if (!from || !to) throw new AppError('One or both ports not found', 404, 'NOT_FOUND');
if (from.id === to.id) throw new AppError('Cannot connect a port to itself', 400, 'BAD_REQUEST');
// Check if ports are already occupied?
// (In real life, a port can only have one cable, but we might allow one source and one target per port if we want to be flexible, but better to prevent simple loops)
// Create connection (if it already exists, use upsert or just throw error; @@unique already handles it)
return prisma.connection.create({ data });
}
export async function deleteConnection(id: string) {
return prisma.connection.delete({ where: { id } });
}
export async function updateConnection(id: string, data: Partial<{ color: string; label: string; edgeType: string }>) {
return prisma.connection.update({
where: { id },
data,
});
}
export async function deleteByPorts(portId1: string, portId2: string) {
return prisma.connection.deleteMany({
where: {
OR: [
{ fromPortId: portId1, toPortId: portId2 },
{ fromPortId: portId2, toPortId: portId1 },
],
},
});
}
+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 } });
}
+206
View File
@@ -0,0 +1,206 @@
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;
sfpCount?: number;
wanCount?: number;
}
) {
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 sfpCount = data.sfpCount ?? (data.type === 'AGGREGATE_SWITCH' ? data.portCount ?? MODULE_PORT_DEFAULTS[data.type] : 0);
const wanCount = data.wanCount ?? 0;
// If aggregate switch is chosen, it usually uses its portCount as SFP ports, but it can be overridden.
// Standard ethernet port count is either the provided portCount or the default, adjusted if it's an aggregate switch (where default are SFP)
const ethernetCount = data.type === 'AGGREGATE_SWITCH'
? (data.portCount ? data.portCount : 0) // if user manually set portCount for Aggr, we treat it as ethernet (unlikely but possible)
: (data.portCount ?? MODULE_PORT_DEFAULTS[data.type] ?? 0);
const portsToCreate = [];
let currentNum = 1;
// 1. WAN/Uplink ports (often on the left or special)
for (let i = 0; i < wanCount; i++) {
portsToCreate.push({ portNumber: currentNum++, portType: 'WAN' as PortType });
}
// 2. Standard Ethernet ports
for (let i = 0; i < ethernetCount; i++) {
if (data.type === 'AGGREGATE_SWITCH' && !data.portCount) break; // skip if it's aggr and we handle them as SFPs below
portsToCreate.push({ portNumber: currentNum++, portType: 'ETHERNET' as PortType });
}
// 3. SFP ports
for (let i = 0; i < sfpCount; i++) {
portsToCreate.push({ portNumber: currentNum++, portType: 'SFP' as PortType });
}
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: portsToCreate,
},
},
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 });
});
}
+61
View File
@@ -0,0 +1,61 @@
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 },
},
sourceConnections: true,
targetConnections: 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"]
}