Compare commits
38 Commits
61a4d37d94
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d9170cec9 | |||
| 308a4c5641 | |||
| 3eac74f28c | |||
| f1c1efd8d3 | |||
| 72918bd87a | |||
| 96adb1e130 | |||
| f6b6f49379 | |||
| 1f360cdb2a | |||
| b26f88a89e | |||
| 5de001c630 | |||
| e2c5cad8a3 | |||
| becb55d57c | |||
| 444d694a06 | |||
| 0dcf5b3c8c | |||
| a13c52d3e3 | |||
| df04bb2c78 | |||
| 2e2b182844 | |||
| 1a99e22bfb | |||
| 55ee1dea93 | |||
| c9aed96400 | |||
| d381f8b720 | |||
| 25e78b4754 | |||
| a11634070f | |||
| 172896b85f | |||
| 7c04c4633f | |||
| 95d26ec805 | |||
| 3d72f429bc | |||
| 128b43e43d | |||
| b5df2e6721 | |||
| 69b7262535 | |||
| 2c95d01e7a | |||
| 7ea358e66a | |||
| 84cd94a0f5 | |||
| bcb8a95fae | |||
| 7ef0509f2b | |||
| f4e139972e | |||
| 0b4e9ea1e5 | |||
| 231de3d005 |
@@ -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:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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 +0,0 @@
|
|||||||
TBD
|
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
Generated
+3079
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
Generated
+5630
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
@@ -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"
|
||||||
@@ -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?
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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'}]`);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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 }));
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
@@ -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 (U1–U${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 (U1–U${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 (U1–U${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 } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user