Files
rack-planner/AGENTS.md

625 lines
25 KiB
Markdown
Raw Normal View History

2026-03-21 20:50:34 -05:00
# AGENTS.md — RackMapper
> **Project:** RackMapper — Web-based Network Rack Planner & Service Mapper
> **Stack:** Node.js · React · TypeScript · SQLite · Prisma ORM · Docker
> **Audience:** This file is the primary briefing for AI coding agents and senior devs onboarding to the project.
---
## Project Overview
RackMapper is a two-module, dark-mode, full-stack web application for network infrastructure management:
1. **Rack Planner** — A visual, drag-and-drop rack diagram builder. Users populate standard 19" racks (42U default) with modular devices (switches, aggregate switches, modems, routers, NAS units, PDUs, patch panels, blanks, etc.), resize devices to span multiple U-spaces, and configure networking devices through interactive port modals with VLAN assignment. Multiple racks are displayed side-by-side when more than one rack exists.
2. **Service Mapper** — A node-graph canvas (React Flow) where users visually map service interactions, traffic flows, and dependencies between infrastructure components, applications, and external services. Device nodes can be auto-populated from Rack Module data and then freely repositioned on the canvas.
All data is persisted to SQLite via Prisma ORM with a migration-first workflow. The app is web-facing and protected by a single hardcoded admin account configured via Docker/Unraid environment variables.
---
## Tech Stack
| Layer | Technology |
|---|---|
| Frontend | React 18 + TypeScript + Vite |
| Styling | Tailwind CSS (dark-mode first, `darkMode: 'class'`) |
| State Management | Zustand |
| Node Graph (Service Mapper) | React Flow (`@xyflow/react` v12+) |
| Drag & Drop (Rack Planner) | `@dnd-kit/core` + `@dnd-kit/sortable` |
| Backend | Node.js + Express (REST API) |
| ORM / DB | Prisma ORM + SQLite (`better-sqlite3`) |
| Auth | JWT via `jsonwebtoken`, `httpOnly` cookie strategy |
2026-03-21 20:50:34 -05:00
| Export | `html-to-image` or `dom-to-svg` for PNG export |
| Containerization | Docker (single container — Node serves Vite static build + API) |
| Testing | Vitest + React Testing Library |
| Linting/Formatting | ESLint + Prettier |
---
## Repository Structure
```
/
├── AGENTS.md ← this file
├── README.md
├── docker-compose.yml
├── Dockerfile
├── .env.example
├── prisma/
│ ├── schema.prisma
│ └── migrations/
├── server/
│ ├── index.ts ← Express entry point
│ ├── routes/
│ │ ├── auth.ts ← POST /api/auth/login, POST /api/auth/logout
│ │ ├── racks.ts
│ │ ├── modules.ts
│ │ ├── ports.ts
│ │ ├── vlans.ts
│ │ └── serviceMap.ts
│ ├── middleware/
│ │ ├── authMiddleware.ts ← JWT verification, applied to all /api/* except /api/auth
│ │ └── errorHandler.ts
│ ├── services/ ← Business logic, all Prisma queries live here
│ │ ├── rackService.ts
│ │ ├── moduleService.ts
│ │ ├── portService.ts
│ │ ├── vlanService.ts
│ │ └── mapService.ts
│ └── lib/
│ └── prisma.ts ← PrismaClient singleton
├── client/
│ ├── src/
│ │ ├── main.tsx
│ │ ├── App.tsx ← Router root; / → /rack, /rack, /map, /vlans
2026-03-21 20:50:34 -05:00
│ │ ├── store/
│ │ │ ├── useAuthStore.ts
│ │ │ ├── useRackStore.ts
│ │ │ └── useMapStore.ts
│ │ ├── components/
│ │ │ ├── auth/ ← LoginPage, ProtectedRoute
│ │ │ ├── rack/ ← Rack Planner components
│ │ │ ├── mapper/ ← Service Mapper components
│ │ │ │ ├── nodes/ ← Custom React Flow node components
│ │ │ │ ├── ContextMenu.tsx
│ │ │ │ └── NodeEditModal.tsx
│ │ │ ├── vlans/ ← VlanPage (full CRUD at /vlans)
2026-03-21 20:50:34 -05:00
│ │ │ ├── modals/ ← All modal components
│ │ │ └── ui/ ← Shared primitives (Button, Badge, Tooltip, etc.)
│ │ ├── hooks/
│ │ ├── api/
│ │ │ └── client.ts ← ALL fetch calls go through here, never in components
│ │ ├── types/
│ │ │ └── index.ts ← Shared TypeScript interfaces
│ │ └── lib/
│ │ └── constants.ts ← Device types, U-height defaults, VLAN ranges
│ └── index.html
└── tests/
├── unit/
└── integration/
```
---
## Authentication
### Strategy
- **Single admin account** — no registration UI, no user table in the database
- Credentials are injected at runtime via Docker environment variables: `ADMIN_USERNAME` and `ADMIN_PASSWORD`
- Password is stored as plain text in the environment variable — change it by updating the Docker env var and restarting the container
2026-03-21 20:50:34 -05:00
- JWT issued on login, stored in an `httpOnly`, `SameSite=Strict`, `Secure` cookie — never `localStorage`
- All `/api/*` routes except `/api/auth/login` are protected by `authMiddleware.ts`
- Token expiry: `8h` (configurable via `JWT_EXPIRY` env var)
### Auth Flow
```
POST /api/auth/login { username, password }
→ verify username === ADMIN_USERNAME && password === ADMIN_PASSWORD
2026-03-21 20:50:34 -05:00
→ sign JWT { sub: 'admin', iat, exp }
→ Set-Cookie: token=<jwt>; HttpOnly; SameSite=Strict; Secure; Path=/
→ 200 OK { success: true }
POST /api/auth/logout
→ Clear cookie
→ 200 OK
GET /api/auth/me
→ Returns { authenticated: true } if valid cookie, else 401
```
### Frontend Auth
- `ProtectedRoute` component wraps all app routes — redirects to `/login` if not authenticated
- `useAuthStore` (Zustand) holds `{ isAuthenticated, loading }` state
- On app mount, call `GET /api/auth/me` to verify session — show full-screen loader during check
- Login page: centered card, dark-mode, RackMapper logo/wordmark, username + password fields, submit button, error toast on failure
- No "forgot password" — admin resets by updating the Docker env var
### Environment Variables
```env
ADMIN_USERNAME=admin
ADMIN_PASSWORD=yourpassword # plain text; change by updating env var + restarting container
2026-03-21 20:50:34 -05:00
JWT_SECRET=your-secret-here # min 32 chars, random
JWT_EXPIRY=8h
DATABASE_URL=file:./data/rackmapper.db
PORT=3001
NODE_ENV=production
```
---
## Database Schema (Prisma)
```prisma
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
2026-03-21 20:50:34 -05:00
}
model Module {
id String @id @default(cuid())
2026-03-21 20:50:34 -05:00
rackId String
rack Rack @relation(fields: [rackId], references: [id], onDelete: Cascade)
2026-03-21 20:50:34 -05:00
name String
type String // ModuleType: SWITCH | AGGREGATE_SWITCH | MODEM | ROUTER | NAS | PDU | PATCH_PANEL | SERVER | FIREWALL | AP | BLANK | OTHER
uPosition Int // 1-indexed from top (U1 = topmost slot)
uSize Int @default(1)
2026-03-21 20:50:34 -05:00
manufacturer String?
model String?
ipAddress String?
notes String?
ports Port[]
serviceNodes ServiceNode[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
2026-03-21 20:50:34 -05:00
}
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") // ETHERNET | SFP | SFP_PLUS | QSFP | CONSOLE | UPLINK
mode String @default("ACCESS") // ACCESS | TRUNK | HYBRID
2026-03-21 20:50:34 -05:00
nativeVlan Int?
vlans PortVlan[]
notes String?
}
model Vlan {
id String @id @default(cuid())
vlanId Int @unique
name String
description String?
color String?
2026-03-21 20:50:34 -05:00
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)
2026-03-21 20:50:34 -05:00
@@id([portId, vlanId])
}
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())
2026-03-21 20:50:34 -05:00
mapId String
map ServiceMap @relation(fields: [mapId], references: [id], onDelete: Cascade)
2026-03-21 20:50:34 -05:00
label String
nodeType String // SERVICE | DATABASE | API | DEVICE | EXTERNAL | USER | VLAN | FIREWALL | LOAD_BALANCER | NOTE
2026-03-21 20:50:34 -05:00
positionX Float
positionY Float
metadata String?
2026-03-21 20:50:34 -05:00
color String?
icon String?
moduleId String?
module Module? @relation(fields: [moduleId], references: [id], onDelete: SetNull)
2026-03-21 20:50:34 -05:00
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?
}
```
> ⚠️ **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.
2026-03-21 20:50:34 -05:00
**Migration workflow:**
```bash
npx prisma migrate dev --name <descriptive_name> # dev: creates + applies migration
npx prisma migrate deploy # production/Docker: applies pending
npx prisma generate # regenerate client after any schema change
npx prisma studio # visual DB browser at localhost:5555
```
> ⚠️ **Never** manually edit migration files after they are applied. Create a new migration for every schema change. Never use `prisma db push` — it skips migration history.
---
## Seed
`prisma/seed.ts` starts **blank** — no default VLANs, no sample racks. It should only:
- Log a confirmation message that the DB is ready
- Be a no-op safe to run multiple times
Do not pre-seed VLANs, racks, or modules unless the user explicitly requests it.
---
## API Routes
### Auth
| Method | Endpoint | Auth Required | Description |
|---|---|---|---|
| POST | `/api/auth/login` | No | Login, sets httpOnly cookie |
| POST | `/api/auth/logout` | No | Clears cookie |
| GET | `/api/auth/me` | Yes | Session check |
### Rack Planner
| Method | Endpoint | Description |
|---|---|---|
| GET | `/api/racks` | List all racks (ordered by `displayOrder`) |
| POST | `/api/racks` | Create a rack |
| GET | `/api/racks/:id` | Get rack with all modules and ports |
| PUT | `/api/racks/:id` | Update rack metadata or displayOrder |
| DELETE | `/api/racks/:id` | Delete rack (cascades) |
| POST | `/api/racks/:id/modules` | Add a module to a rack |
| PUT | `/api/modules/:id` | Update module (position, size, metadata) |
| POST | `/api/modules/:id/move` | Move module to different rack/position (collision-validated) |
2026-03-21 20:50:34 -05:00
| DELETE | `/api/modules/:id` | Remove a module |
| GET | `/api/modules/:id/ports` | Get ports for a module |
| PUT | `/api/ports/:id` | Update port config (VLAN, mode, label) |
| GET | `/api/vlans` | List all VLANs |
| POST | `/api/vlans` | Create a VLAN |
| PUT | `/api/vlans/:id` | Update VLAN |
| DELETE | `/api/vlans/:id` | Delete VLAN |
### Service Mapper
| Method | Endpoint | Description |
|---|---|---|
| GET | `/api/maps` | List all service maps |
| POST | `/api/maps` | Create a new map |
| GET | `/api/maps/:id` | Get full map (nodes + edges) |
| PUT | `/api/maps/:id` | Update map metadata |
| DELETE | `/api/maps/:id` | Delete map |
| POST | `/api/maps/:id/nodes` | Add node |
| POST | `/api/maps/:id/populate` | Auto-populate nodes from rack modules |
| PUT | `/api/nodes/:id` | Update node (position, label, meta) |
| DELETE | `/api/nodes/:id` | Remove node |
| POST | `/api/maps/:id/edges` | Add edge |
| PUT | `/api/edges/:id` | Update edge |
| DELETE | `/api/edges/:id` | Remove edge |
---
## Module 1 — Rack Planner
### Visual Design
- Background: `#0f1117` (deep slate), rack column: `#1e2433` with silver/chrome border
- Each rack rendered as a vertical U-slot grid; U1 is the **topmost slot**
- Modules render as colored blocks spanning their `uSize`; color driven by `ModuleType`
- Empty U-slots show faint dashed outline as drop targets
- Each networking module shows: label, type badge, IP (if set), and a compact row of port indicator dots
### Multi-Rack Layout
- **Single rack:** centered full-height column
- **2+ racks:** side-by-side horizontal flex layout, each rack same height, scrollable horizontally if > 3
- Rack order controlled by `displayOrder` field; user can reorder via drag handle on rack header
- "Add Rack" button always visible in the top toolbar
### Interaction Model
- **Add module:** sidebar device-type palette → drag to U-slot, or click slot to place with type selector
- **Resize module:** drag handle at bottom edge → expands downward; validate no overlap before commit
- **Move module:** drag to new U-position in same rack or different rack; validate collision
- **Edit module:** click → slide-out panel (name, IP, manufacturer, model, notes, uSize)
- **Delete module:** trash icon on hover → confirm tooltip
### Port Configuration Modal
Triggered by clicking any port indicator dot on applicable modules.
**Modal fields:**
- Port number + label (editable)
- Port type (read-only, set at module creation)
- Mode: `Access` / `Trunk` / `Hybrid`
- Native VLAN: dropdown (populated from VLAN list)
- Tagged VLANs: multi-select (visible only in Trunk/Hybrid mode)
- Notes field
- Inline VLAN quick-create (add new VLAN ID + name without leaving modal)
- Save / Cancel
2026-03-21 20:57:16 -05:00
**Devices with interactive ports:** `SWITCH`, `AGGREGATE_SWITCH`, `ROUTER`, `FIREWALL`, `PATCH_PANEL`, `AP`, `MODEM`, `NAS`, `PDU`, (SERVER optionally has ports) (Ability to add custom rack items) (Ability to assign number of ports and what type they are to network devices) (Constants are good for rapid deployment bu all devices beed to be fully configurable)
2026-03-21 20:50:34 -05:00
### Port Count & U-Size Defaults (`constants.ts`)
```ts
export const MODULE_PORT_DEFAULTS: Record<ModuleType, number> = {
SWITCH: 24,
2026-03-21 20:57:16 -05:00
AGGREGATE_SWITCH: 8,
2026-03-21 20:50:34 -05:00
ROUTER: 4,
FIREWALL: 8,
PATCH_PANEL: 24,
AP: 1,
MODEM: 2,
SERVER: 2,
2026-03-21 20:57:16 -05:00
NAS: 1,
PDU: 12,
2026-03-21 20:50:34 -05:00
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,
};
```
Ports are **auto-generated** when a Module is created based on these defaults. Users should not need to manually add ports.
### PNG Export
- Export button in rack toolbar: captures the entire rack view (all side-by-side racks) as a single PNG
- Use `html-to-image` (`toPng`) targeting the rack canvas container ref
- Filename: `rackmapper-rack-<timestamp>.png`
- Show a brief "Exporting..." toast during capture
---
## Module 2 — Service Mapper
### Library
Use **React Flow (`@xyflow/react` v12+)**. Do not substitute with D3, Cytoscape, or Dagre without approval.
### Device-to-Module Linking
- `DeviceNode` carries an optional `moduleId` field linking it to a physical `Module` in the rack
- When `moduleId` is set, the node displays the module's name, type badge, and IP address pulled from the linked record
- Clicking a linked `DeviceNode` opens a read-only info panel showing full module detail with a "View in Rack" button that navigates to `/rack` and highlights that module
- Nodes are **fully draggable** regardless of module linkage — position on the canvas is independent of rack position
### Auto-Populate from Rack
`POST /api/maps/:id/populate` fetches all Modules across all racks and creates a `DeviceNode` for each one that doesn't already have a node in the map. Nodes are arranged in a grid layout (column per rack, rows per module in U-order). This is a one-way import — subsequent module additions must be manually added or re-triggered.
### Custom Node Types
Each `NodeType` has a custom React Flow node component in `client/src/components/mapper/nodes/`:
| Node Type | Component | Visual Style |
|---|---|---|
| `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; shows IP/Port if set in metadata |
| `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); shows IP/Port if set in metadata |
| `EXTERNAL` | `ExternalNode.tsx` | Dashed border, cloud icon; shows IP/Port if set in metadata |
2026-03-21 20:50:34 -05:00
| `VLAN` | `VlanNode.tsx` | Colored square matching VLAN color swatch |
| `FIREWALL` | `FirewallNode.tsx` | Shield icon, red accent; shows IP/Port if set in metadata |
| `LOAD_BALANCER` | `LBNode.tsx` | Scale/balance icon; shows IP/Port if set in metadata |
2026-03-21 20:50:34 -05:00
| `USER` | `UserNode.tsx` | Person icon, neutral gray |
| `NOTE` | `NoteNode.tsx` | Sticky note style, no handles, free text |
### Canvas Features
- Minimap (bottom-right, dark themed)
- Controls: zoom in/out, fit view, lock toggle
- Background: dot grid pattern (`#1e2433` dots)
- Snap-to-grid: 15px
- Multi-select: Shift+click or drag-select box
- Edge types: `smoothstep` (default), `straight`, `bezier` — selectable per edge
- Animated edges for "active traffic" flows (toggle per edge)
- Right-click canvas → context menu: Add Node (all 10 types placed at cursor position)
- Right-click node → Edit (label/colour/module link), Duplicate, Delete
- Right-click edge → Toggle Animation, set edge type (bezier/smooth/step/straight), Delete
- Double-click node → `NodeEditModal` (label, accent colour swatch + custom picker, logical IP/Port metadata, rack module link)
2026-03-21 20:50:34 -05:00
### Persistence
Debounce all position/data changes by 500ms after drag end — PATCH to API. Never save on every pixel move. Use React Flow's `onNodesChange` and `onEdgesChange` callbacks.
### PNG Export
- "Export PNG" button in Service Mapper toolbar
- Use `html-to-image` (`toPng`) on the React Flow container
- Temporarily hide minimap and controls panel during capture, restore after
- Filename: `rackmapper-map-<mapName>-<timestamp>.png`
---
## Code Style & Conventions
### TypeScript
- `"strict": true` in tsconfig — no exceptions
- No `any` — use `unknown` with type guards if necessary
- All shared types in `client/src/types/index.ts` and `server/types/index.ts`
- Prefer `interface` for object shapes, `type` for unions/aliases
### React
- Functional components only — no class components
- Custom hooks for all non-trivial stateful logic (`use` prefix, in `/hooks`)
- All API calls go through `client/src/api/client.ts` — never raw `fetch`/`axios` in components
- Prefer small, focused components; avoid prop drilling beyond 2 levels — lift to Zustand
### Styling
- Tailwind CSS only — no inline styles, no CSS modules unless strictly required
- Dark mode is the only supported mode
- Use `cn()` utility (clsx + tailwind-merge) for conditional class composition
- Color palette: `slate-900` bg · `slate-800` surface · `slate-700` border · `blue-500` accent · `red-500` destructive
### Backend
- Route handlers are thin — all business logic in `server/services/`
- All Prisma queries in service files — never inline in route handlers
- Throw typed `AppError` instances; catch in `errorHandler` middleware
- Consistent JSON response shape: `{ data, error, meta }`
---
## Commands
```bash
# Development
npm run dev # Vite + Node (concurrently)
npm run dev:client # Vite only
npm run dev:server # Nodemon server only
# Database
npx prisma migrate dev --name <name> # create + apply dev migration
npx prisma migrate deploy # apply in production / Docker
npx prisma generate # regenerate client after schema change
npx prisma studio # visual DB browser at localhost:5555
npx prisma db seed # run seed (no-op by default)
# Build
npm run build # Vite build + tsc check
npm run typecheck # tsc --noEmit — run before every PR
# Lint / Format
npm run lint
npm run lint:fix
npm run format # Prettier --write
# Tests
npm run test
npm run test:watch
npm run test -- path/to/file.test.ts
# Docker
docker compose up --build -d
docker compose down
docker compose logs -f
```
---
## Docker / Unraid Configuration
**`docker-compose.yml` environment block:**
```yaml
environment:
- NODE_ENV=production
- PORT=3001
- DATABASE_URL=file:./data/rackmapper.db
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=yourpassword # plain text
- JWT_SECRET=... # min 32 chars, random
2026-03-21 20:50:34 -05:00
- JWT_EXPIRY=8h
volumes:
- ./data:/app/data # persists SQLite file across container restarts
```
The Dockerfile should run `npx prisma migrate deploy && node dist/index.js` as the startup command to auto-apply any pending migrations on container start.
---
## Agent Permissions
### Allowed without asking
- Read any file
- Run `npm run lint`, `npm run typecheck`, `npm run format`
- Run `vitest` on a single file
- Run `npx prisma generate`
- Edit files in `client/src/`, `server/`, `prisma/schema.prisma`
### Ask first
- `npm install` or adding new dependencies
- `npx prisma migrate dev` (schema-changing migration)
- Deleting files
- Modifying `docker-compose.yml` or `Dockerfile`
- Running `npm run build`
- Any `git push` or commit
### Never do
- Edit migration files that have already been applied
- Commit secrets or `.env` files
- Hard-code credentials, IP addresses, or VLAN IDs
- Run `prisma db push` — migrations only
- Rewrite the entire repo in one diff — prefer targeted, minimal changes
---
## Key Design Decisions
1. **SQLite over PostgreSQL** — intentional for single-container Unraid deployment. No external DB process. Do not suggest migrating unless asked.
2. **httpOnly cookie auth** — chosen over `localStorage` for XSS resistance on a web-facing deployment. Do not change to `localStorage`.
3. **Single admin account via env vars** — no user table, no registration. Password is plain text in `ADMIN_PASSWORD`. Admin changes it by updating the Docker/Unraid env var and restarting the container. No bcrypt dependency.
2026-03-21 20:50:34 -05:00
4. **U1 = top of rack** — all U-position logic is 1-indexed from the top. Validate and render accordingly.
5. **`@dnd-kit` over `react-beautiful-dnd`** — `react-beautiful-dnd` is unmaintained.
6. **React Flow for Service Mapper** — first-class TypeScript, custom node API, active maintenance. Do not swap.
7. **Zustand over Redux** — intentional for this app's scope. Do not introduce Redux or Context API for global state.
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.
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.
2026-03-21 20:50:34 -05:00
---
## Accessibility & UX Standards
- All modals: keyboard navigable (Tab order, Escape to close, Enter to confirm)
- All interactive elements: `aria-label` or visible label
- Drag-and-drop: keyboard fallback (click-to-select, arrow key movement)
- Toast notifications via `sonner` for all async success/error states — no silent failures
- Destructive actions (delete rack, module, map) require a confirmation dialog, not just a toast
- Skeleton loaders (not spinners) for layout-heavy async views
- Login page must show a clear error message on failed auth — no vague "something went wrong"