Compare commits

...

31 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:32:25 -05:00
49 changed files with 2057 additions and 356 deletions
+2 -1
View File
@@ -8,7 +8,8 @@
"Bash(npx tsc:*)", "Bash(npx tsc:*)",
"Bash(git commit:*)", "Bash(git commit:*)",
"Bash(npm uninstall:*)", "Bash(npm uninstall:*)",
"Bash(git add:*)" "Bash(git add:*)",
"Bash(engine response\" error at migrate/startup time:*)"
] ]
} }
} }
+7 -3
View File
@@ -3,14 +3,18 @@
# Admin credentials # Admin credentials
ADMIN_USERNAME=admin ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH= # Generate with: npx ts-node scripts/hashPassword.ts yourpassword ADMIN_PASSWORD= # Plain-text password
# JWT # JWT
JWT_SECRET= # Min 32 random chars — generate with: openssl rand -hex 32 JWT_SECRET= # Min 32 random chars — generate with: openssl rand -hex 32
JWT_EXPIRY=8h JWT_EXPIRY=8h
# Database (relative path inside container; bind-mounted to ./data/) # Cookie security — set to true only if behind an HTTPS reverse proxy
DATABASE_URL=file:./data/rackmapper.db 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 # Server
PORT=3001 PORT=3001
+25
View File
@@ -0,0 +1,25 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: registry.alwisp.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and Push
run: |
docker build -t registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest .
docker push registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest
+10 -8
View File
@@ -440,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 |
@@ -463,7 +463,7 @@ Each `NodeType` has a custom React Flow node component in `client/src/components
- Right-click canvas → context menu: Add Node (all 10 types placed at cursor position) - 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 node → Edit (label/colour/module link), Duplicate, Delete
- Right-click edge → Toggle Animation, set edge type (bezier/smooth/step/straight), Delete - Right-click edge → Toggle Animation, set edge type (bezier/smooth/step/straight), Delete
- Double-click node → `NodeEditModal` (label, accent colour swatch + custom picker, rack module link) - Double-click node → `NodeEditModal` (label, accent colour swatch + custom picker, logical IP/Port metadata, rack module link)
### Persistence ### Persistence
@@ -608,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.
--- ---
+19 -10
View File
@@ -2,16 +2,22 @@ FROM node:20-alpine
WORKDIR /app WORKDIR /app
# Install build tools needed for better-sqlite3 native bindings # Install build tools + OpenSSL (required by Prisma query engine on Alpine)
RUN apk add --no-cache python3 make g++ RUN apk add --no-cache python3 make g++ openssl openssl-dev
# Copy package manifests # Configure npm: disable package-lock update, cap network retries/timeout
COPY package*.json ./ RUN npm config set fetch-retry-mintimeout 5000 \
COPY client/package*.json ./client/ && npm config set fetch-retry-maxtimeout 30000 \
&& npm config set fetch-retries 3 \
&& npm config set prefer-offline false
# Install all dependencies (dev deps needed for prisma CLI + tsc build) # Copy lockfiles + manifests together so npm ci can validate the lockfile
RUN npm install COPY package.json package-lock.json ./
RUN cd client && npm install 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 source
COPY . . COPY . .
@@ -25,7 +31,10 @@ RUN npm run build
# Ensure data directory exists for SQLite bind mount # Ensure data directory exists for SQLite bind mount
RUN mkdir -p /app/data 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 EXPOSE 3001
# Apply pending migrations then start ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/server/index.js"]
+3 -1
View File
@@ -19,7 +19,9 @@ A self-hosted, dark-mode web app for visualising and managing network rack infra
- Right-click canvas → add any node type at cursor position - Right-click canvas → add any node type at cursor position
- Right-click node → Edit, Duplicate, Delete - Right-click node → Edit, Duplicate, Delete
- Right-click edge → Toggle animation, change edge type, Delete - Right-click edge → Toggle animation, change edge type, Delete
- Double-click a node → edit label, accent colour, and rack module link - 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) - Auto-populate nodes from all rack modules ("Import Rack" button)
- Connect nodes by dragging from handles; Delete key removes selected items - Connect nodes by dragging from handles; Delete key removes selected items
- Minimap, zoom controls, snap-to-grid (15px), PNG export - Minimap, zoom controls, snap-to-grid (15px), PNG export
+6 -2
View File
@@ -109,17 +109,20 @@ Click **Add another Path, Port, Variable, Label or Device** → select **Variabl
|---|---|---| |---|---|---|
| Node Environment | `NODE_ENV` | `production` | | Node Environment | `NODE_ENV` | `production` |
| Port | `PORT` | `3001` | | Port | `PORT` | `3001` |
| Database URL | `DATABASE_URL` | `file:./data/rackmapper.db` | | Database URL | `DATABASE_URL` | `file:/app/data/rackmapper.db` |
| Admin Username | `ADMIN_USERNAME` | `admin` *(or your preferred username)* | | Admin Username | `ADMIN_USERNAME` | `admin` *(or your preferred username)* |
| Admin Password | `ADMIN_PASSWORD` | `yourpassword` | | Admin Password | `ADMIN_PASSWORD` | `yourpassword` |
| JWT Secret | `JWT_SECRET` | `a-long-random-string-min-32-chars` | | JWT Secret | `JWT_SECRET` | `a-long-random-string-min-32-chars` |
| JWT Expiry | `JWT_EXPIRY` | `8h` *(optional — defaults to 8h)* | | 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: > **JWT_SECRET** should be a random string of at least 32 characters. You can generate one in the Unraid terminal:
> ```bash > ```bash
> cat /proc/sys/kernel/random/uuid | tr -d '-' && cat /proc/sys/kernel/random/uuid | tr -d '-' > 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 ### Step 3 — Apply
@@ -148,11 +151,12 @@ docker run -d \
-v /mnt/user/appdata/rackmapper/data:/app/data \ -v /mnt/user/appdata/rackmapper/data:/app/data \
-e NODE_ENV=production \ -e NODE_ENV=production \
-e PORT=3001 \ -e PORT=3001 \
-e DATABASE_URL="file:./data/rackmapper.db" \ -e DATABASE_URL="file:/app/data/rackmapper.db" \
-e ADMIN_USERNAME=admin \ -e ADMIN_USERNAME=admin \
-e ADMIN_PASSWORD=yourpassword \ -e ADMIN_PASSWORD=yourpassword \
-e JWT_SECRET=a-long-random-string-min-32-chars \ -e JWT_SECRET=a-long-random-string-min-32-chars \
-e JWT_EXPIRY=8h \ -e JWT_EXPIRY=8h \
-e COOKIE_SECURE=false \
rackmapper:latest rackmapper:latest
``` ```
+14 -1
View File
@@ -81,6 +81,8 @@ const racks = {
notes?: string; notes?: string;
portCount?: number; portCount?: number;
portType?: PortType; portType?: PortType;
sfpCount?: number;
wanCount?: number;
} }
) => post<Module>(`/racks/${rackId}/modules`, data), ) => post<Module>(`/racks/${rackId}/modules`, data),
}; };
@@ -195,4 +197,15 @@ const edges = {
delete: (id: string) => del<null>(`/edges/${id}`), delete: (id: string) => del<null>(`/edges/${id}`),
}; };
export const apiClient = { auth, racks, modules, ports, vlans, maps, nodes, edges }; // ---- 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 };
+61 -3
View File
@@ -25,7 +25,8 @@ export interface NodeEditModalProps {
initialLabel: string; initialLabel: string;
initialColor?: string; initialColor?: string;
initialModuleId?: string | null; initialModuleId?: string | null;
onSaved: (updated: { label: string; color: string; moduleId: string | null }) => void; initialMetadata?: string | null;
onSaved: (updated: { label: string; color: string; moduleId: string | null; metadata?: string }) => void;
} }
export function NodeEditModal({ export function NodeEditModal({
@@ -35,12 +36,15 @@ export function NodeEditModal({
initialLabel, initialLabel,
initialColor, initialColor,
initialModuleId, initialModuleId,
initialMetadata,
onSaved, onSaved,
}: NodeEditModalProps) { }: NodeEditModalProps) {
const { racks } = useRackStore(); const { racks } = useRackStore();
const [label, setLabel] = useState(initialLabel); const [label, setLabel] = useState(initialLabel);
const [color, setColor] = useState(initialColor ?? '#3b82f6'); const [color, setColor] = useState(initialColor ?? '#3b82f6');
const [moduleId, setModuleId] = useState<string>(initialModuleId ?? ''); const [moduleId, setModuleId] = useState<string>(initialModuleId ?? '');
const [ipAddress, setIpAddress] = useState('');
const [port, setPort] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
@@ -48,20 +52,50 @@ export function NodeEditModal({
setLabel(initialLabel); setLabel(initialLabel);
setColor(initialColor ?? '#3b82f6'); setColor(initialColor ?? '#3b82f6');
setModuleId(initialModuleId ?? ''); 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]); }, [open, initialLabel, initialColor, initialModuleId, initialMetadata]);
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
if (!label.trim()) return; if (!label.trim()) return;
setLoading(true); setLoading(true);
try { 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, { await apiClient.nodes.update(nodeId, {
label: label.trim(), label: label.trim(),
color, color,
moduleId: moduleId || null, moduleId: moduleId || null,
metadata: metadataString,
}); });
onSaved({ label: label.trim(), color, moduleId: moduleId || null }); onSaved({ label: label.trim(), color, moduleId: moduleId || null, metadata: metadataString });
toast.success('Node updated'); toast.success('Node updated');
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -139,6 +173,30 @@ export function NodeEditModal({
</select> </select>
</div> </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"> <div className="flex justify-end gap-3 pt-1">
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading}> <Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading}>
Cancel Cancel
+34 -18
View File
@@ -136,6 +136,7 @@ type NodeEditState = {
label: string; label: string;
color?: string; color?: string;
moduleId?: string | null; moduleId?: string | null;
metadata?: string | null;
} | null; } | null;
function ServiceMapperInner() { function ServiceMapperInner() {
@@ -148,48 +149,58 @@ function ServiceMapperInner() {
const [ctxMenu, setCtxMenu] = useState<CtxMenu>(null); const [ctxMenu, setCtxMenu] = useState<CtxMenu>(null);
const [nodeEditState, setNodeEditState] = useState<NodeEditState>(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 // Load maps list on mount
useEffect(() => { useEffect(() => {
fetchMaps().catch(() => toast.error('Failed to load maps')); fetchMaps().catch(() => toast.error('Failed to load maps'));
}, [fetchMaps]); }, [fetchMaps]);
// When active map changes, update flow state // When active map changes, update flow state and block position saves briefly
useEffect(() => { useEffect(() => {
if (activeMap) { if (activeMap) {
blockSaveRef.current = true;
setNodes(toFlowNodes(activeMap)); setNodes(toFlowNodes(activeMap));
setEdges(toFlowEdges(activeMap)); setEdges(toFlowEdges(activeMap));
// Unblock after React Flow has settled its initial layout
const t = setTimeout(() => { blockSaveRef.current = false; }, 800);
return () => clearTimeout(t);
} else { } else {
setNodes([]); setNodes([]);
setEdges([]); setEdges([]);
} }
}, [activeMap, setNodes, setEdges]); }, [activeMap, setNodes, setEdges]);
// Debounced node position save (500ms after drag end) // Debounced node position save (500ms after drag ends)
const handleNodesChange = useCallback( const handleNodesChange = useCallback(
(changes: NodeChange<Node>[]) => { (changes: NodeChange<Node>[]) => {
onNodesChange(changes); onNodesChange(changes);
const positionChanges = changes.filter( // Don't persist positions during initial load / fitView settle period
(c): c is NodeChange<Node> & { type: 'position'; dragging: false } => if (blockSaveRef.current) return;
c.type === 'position' && !(c as { dragging?: boolean }).dragging
); 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 (positionChanges.length === 0) return;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current); if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(async () => { saveTimerRef.current = setTimeout(async () => {
for (const change of positionChanges) { for (const change of positionChanges) {
const nodeId = (change as { id: string }).id; const pc = change as { id: string; position: { x: number; y: number } };
const position = (change as { position?: { x: number; y: number } }).position;
if (!position) continue;
try { try {
await apiClient.nodes.update(nodeId, { await apiClient.nodes.update(pc.id, {
positionX: position.x, positionX: pc.position.x,
positionY: position.y, positionY: pc.position.y,
}); });
} catch { } catch {
// Silent — position drift on failure is acceptable // Silent — minor position drift on failure is acceptable
} }
} }
}, 500); }, 500);
@@ -267,7 +278,7 @@ function ServiceMapperInner() {
const onNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => { const onNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => {
event.preventDefault(); event.preventDefault();
const d = node.data as { label?: string; color?: string; module?: { id: string } }; const d = node.data as { label?: string; color?: string; module?: { id: string }; metadata?: string };
setCtxMenu({ setCtxMenu({
kind: 'node', kind: 'node',
x: event.clientX, x: event.clientX,
@@ -292,12 +303,13 @@ function ServiceMapperInner() {
}, []); }, []);
const onNodeDoubleClick = useCallback((_event: React.MouseEvent, node: Node) => { const onNodeDoubleClick = useCallback((_event: React.MouseEvent, node: Node) => {
const d = node.data as { label?: string; color?: string; module?: { id: string } }; const d = node.data as { label?: string; color?: string; module?: { id: string }; metadata?: string };
setNodeEditState({ setNodeEditState({
nodeId: node.id, nodeId: node.id,
label: d.label ?? '', label: d.label ?? '',
color: d.color, color: d.color,
moduleId: d.module?.id ?? null, moduleId: d.module?.id ?? null,
metadata: d.metadata ?? null,
}); });
}, []); }, []);
@@ -420,12 +432,12 @@ function ServiceMapperInner() {
// ---- Node edit modal save ---- // ---- Node edit modal save ----
function handleNodeEditSaved(updated: { label: string; color: string; moduleId: string | null }) { function handleNodeEditSaved(updated: { label: string; color: string; moduleId: string | null; metadata?: string }) {
if (!nodeEditState) return; if (!nodeEditState) return;
setNodes((nds) => setNodes((nds) =>
nds.map((n) => nds.map((n) =>
n.id === nodeEditState.nodeId n.id === nodeEditState.nodeId
? { ...n, data: { ...n.data, label: updated.label, color: updated.color } } ? { ...n, data: { ...n.data, label: updated.label, color: updated.color, metadata: updated.metadata } }
: n : n
) )
); );
@@ -447,11 +459,14 @@ function ServiceMapperInner() {
if (ctxMenu.kind === 'node') { if (ctxMenu.kind === 'node') {
const { nodeId, label, color, moduleId } = ctxMenu; const { nodeId, label, color, moduleId } = ctxMenu;
const node = nodes.find(n => n.id === nodeId);
const metadata = node ? (node.data as any).metadata : null;
return [ return [
{ {
label: 'Edit node', label: 'Edit node',
icon: <Edit2 size={12} />, icon: <Edit2 size={12} />,
onClick: () => setNodeEditState({ nodeId, label, color, moduleId }), onClick: () => setNodeEditState({ nodeId, label, color, moduleId, metadata }),
}, },
{ {
label: 'Duplicate', label: 'Duplicate',
@@ -605,6 +620,7 @@ function ServiceMapperInner() {
initialLabel={nodeEditState.label} initialLabel={nodeEditState.label}
initialColor={nodeEditState.color} initialColor={nodeEditState.color}
initialModuleId={nodeEditState.moduleId} initialModuleId={nodeEditState.moduleId}
initialMetadata={nodeEditState.metadata}
onSaved={handleNodeEditSaved} onSaved={handleNodeEditSaved}
/> />
)} )}
+27 -8
View File
@@ -1,20 +1,39 @@
import { memo } from 'react'; import { memo, useMemo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react'; import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Zap } from 'lucide-react'; import { Zap } from 'lucide-react';
export const ApiNode = memo(({ data, selected }: NodeProps) => { export const ApiNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'API'; 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; const method = (data as { method?: string }).method;
return ( 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'}`}> <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" /> <Handle type="target" position={Position.Top} className="!bg-yellow-400 !border-yellow-600" />
<div className="px-3 py-2 flex items-center gap-2"> <div className="px-3 py-2 flex flex-col gap-1">
<Zap size={13} className="text-yellow-400 shrink-0" /> <div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-100 truncate flex-1">{label}</span> <Zap size={13} className="text-yellow-400 shrink-0" />
{method && ( <span className="text-xs font-medium text-slate-100 truncate flex-1">{label}</span>
<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 && (
{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">
</span> {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> </div>
<Handle type="source" position={Position.Bottom} className="!bg-yellow-400 !border-yellow-600" /> <Handle type="source" position={Position.Bottom} className="!bg-yellow-400 !border-yellow-600" />
@@ -1,15 +1,34 @@
import { memo } from 'react'; import { memo, useMemo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react'; import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Database } from 'lucide-react'; import { Database } from 'lucide-react';
export const DatabaseNode = memo(({ data, selected }: NodeProps) => { export const DatabaseNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Database'; 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 ( 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'}`}> <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" /> <Handle type="target" position={Position.Top} className="!bg-teal-400 !border-teal-600" />
<div className="px-3 py-2 flex items-center gap-2"> <div className="px-3 py-2 flex flex-col gap-1">
<Database size={13} className="text-teal-400 shrink-0" /> <div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-100 truncate">{label}</span> <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> </div>
<Handle type="source" position={Position.Bottom} className="!bg-teal-400 !border-teal-600" /> <Handle type="source" position={Position.Bottom} className="!bg-teal-400 !border-teal-600" />
</div> </div>
@@ -1,4 +1,4 @@
import { memo } from 'react'; import { memo, useMemo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react'; import { Handle, Position, type NodeProps } from '@xyflow/react';
import type { Module } from '../../../types'; import type { Module } from '../../../types';
import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS } from '../../../lib/constants'; import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS } from '../../../lib/constants';
@@ -16,6 +16,17 @@ export const DeviceNode = memo(({ data, selected }: NodeProps) => {
const colors = mod ? MODULE_TYPE_COLORS[mod.type] : null; 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 ( return (
<div <div
className={`min-w-[160px] max-w-[200px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden transition-all ${ className={`min-w-[160px] max-w-[200px] bg-slate-800 border rounded-lg shadow-lg overflow-hidden transition-all ${
@@ -37,15 +48,24 @@ export const DeviceNode = memo(({ data, selected }: NodeProps) => {
<span className="text-xs font-semibold text-slate-100 truncate">{nodeData.label}</span> <span className="text-xs font-semibold text-slate-100 truncate">{nodeData.label}</span>
</div> </div>
{mod && ( {mod && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1 mt-1">
<Badge variant="slate" className="text-[10px]">{MODULE_TYPE_LABELS[mod.type]}</Badge> <Badge variant="slate" className="text-[10px]">{MODULE_TYPE_LABELS[mod.type]}</Badge>
{mod.ipAddress && ( {hasAddress && (
<span className="text-[10px] text-slate-400 font-mono">{mod.ipAddress}</span> <span className="text-[10px] text-slate-400 font-mono ml-1 mt-0.5">
{ipToDisplay}{meta.port ? `:${meta.port}` : ''}
</span>
)} )}
</div> </div>
)} )}
{!mod && ( {!mod && (
<span className="text-[10px] text-slate-500">Unlinked device</span> <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> </div>
@@ -1,15 +1,34 @@
import { memo } from 'react'; import { memo, useMemo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react'; import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Cloud } from 'lucide-react'; import { Cloud } from 'lucide-react';
export const ExternalNode = memo(({ data, selected }: NodeProps) => { export const ExternalNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'External'; 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 ( 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'}`}> <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" /> <Handle type="target" position={Position.Top} className="!bg-slate-400 !border-slate-600" />
<div className="px-3 py-2 flex items-center gap-2"> <div className="px-3 py-2 flex flex-col gap-1">
<Cloud size={13} className="text-slate-400 shrink-0" /> <div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-300 truncate">{label}</span> <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> </div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" /> <Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div> </div>
@@ -1,15 +1,34 @@
import { memo } from 'react'; import { memo, useMemo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react'; import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Shield } from 'lucide-react'; import { Shield } from 'lucide-react';
export const FirewallNode = memo(({ data, selected }: NodeProps) => { export const FirewallNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Firewall'; 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 ( 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'}`}> <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" /> <Handle type="target" position={Position.Top} className="!bg-red-400 !border-red-600" />
<div className="px-3 py-2 flex items-center gap-2"> <div className="px-3 py-2 flex flex-col gap-1">
<Shield size={13} className="text-red-400 shrink-0" /> <div className="flex items-center gap-2">
<span className="text-xs font-semibold text-slate-100 truncate">{label}</span> <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> </div>
<Handle type="source" position={Position.Bottom} className="!bg-red-400 !border-red-600" /> <Handle type="source" position={Position.Bottom} className="!bg-red-400 !border-red-600" />
</div> </div>
+23 -4
View File
@@ -1,15 +1,34 @@
import { memo } from 'react'; import { memo, useMemo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react'; import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Scale } from 'lucide-react'; import { Scale } from 'lucide-react';
export const LBNode = memo(({ data, selected }: NodeProps) => { export const LBNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Load Balancer'; 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 ( 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'}`}> <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" /> <Handle type="target" position={Position.Top} className="!bg-orange-400 !border-orange-600" />
<div className="px-3 py-2 flex items-center gap-2"> <div className="px-3 py-2 flex flex-col gap-1">
<Scale size={13} className="text-orange-400 shrink-0" /> <div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-100 truncate">{label}</span> <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> </div>
<Handle type="source" position={Position.Bottom} className="!bg-orange-400 !border-orange-600" /> <Handle type="source" position={Position.Bottom} className="!bg-orange-400 !border-orange-600" />
</div> </div>
@@ -1,10 +1,20 @@
import { memo } from 'react'; import { memo, useMemo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react'; import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Layers } from 'lucide-react'; import { Layers } from 'lucide-react';
export const ServiceNode = memo(({ data, selected }: NodeProps) => { export const ServiceNode = memo(({ data, selected }: NodeProps) => {
const label = (data as { label?: string }).label ?? 'Service'; const label = (data as { label?: string }).label ?? 'Service';
const color = (data as { color?: string }).color ?? '#3b82f6'; 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 ( return (
<div <div
@@ -14,9 +24,18 @@ export const ServiceNode = memo(({ data, selected }: NodeProps) => {
style={{ borderLeftColor: color, borderLeftWidth: 3 }} style={{ borderLeftColor: color, borderLeftWidth: 3 }}
> >
<Handle type="target" position={Position.Top} className="!bg-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"> <div className="px-3 py-2 flex flex-col gap-1">
<Layers size={13} style={{ color }} className="shrink-0" /> <div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-100 truncate">{label}</span> <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> </div>
<Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" /> <Handle type="source" position={Position.Bottom} className="!bg-slate-400 !border-slate-600" />
</div> </div>
+45 -16
View File
@@ -35,6 +35,8 @@ export function AddModuleModal({ open, onClose, rackId, uPosition, initialType }
const [ipAddress, setIpAddress] = useState(''); const [ipAddress, setIpAddress] = useState('');
const [manufacturer, setManufacturer] = useState(''); const [manufacturer, setManufacturer] = useState('');
const [model, setModel] = useState(''); const [model, setModel] = useState('');
const [sfpCount, setSfpCount] = useState(0);
const [wanCount, setWanCount] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Sync state when modal opens with a new initialType (e.g. drag-drop reuse) // Sync state when modal opens with a new initialType (e.g. drag-drop reuse)
@@ -54,6 +56,8 @@ export function AddModuleModal({ open, onClose, rackId, uPosition, initialType }
setName(MODULE_TYPE_LABELS[type]); setName(MODULE_TYPE_LABELS[type]);
setUSize(MODULE_U_DEFAULTS[type]); setUSize(MODULE_U_DEFAULTS[type]);
setPortCount(MODULE_PORT_DEFAULTS[type]); setPortCount(MODULE_PORT_DEFAULTS[type]);
setSfpCount(0);
setWanCount(0);
} }
function reset() { function reset() {
@@ -64,6 +68,8 @@ export function AddModuleModal({ open, onClose, rackId, uPosition, initialType }
setIpAddress(''); setIpAddress('');
setManufacturer(''); setManufacturer('');
setModel(''); setModel('');
setSfpCount(0);
setWanCount(0);
} }
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
@@ -77,6 +83,8 @@ export function AddModuleModal({ open, onClose, rackId, uPosition, initialType }
uPosition, uPosition,
uSize, uSize,
portCount, portCount,
sfpCount,
wanCount,
ipAddress: ipAddress.trim() || undefined, ipAddress: ipAddress.trim() || undefined,
manufacturer: manufacturer.trim() || undefined, manufacturer: manufacturer.trim() || undefined,
model: model.trim() || undefined, model: model.trim() || undefined,
@@ -165,31 +173,17 @@ export function AddModuleModal({ open, onClose, rackId, uPosition, initialType }
/> />
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<label className="block text-sm text-slate-300">Size (U)</label> <label className="block text-sm text-slate-300">Size (U)</label>
<input <input
type="number" type="number" min={1} max={20}
min={1}
max={20}
value={uSize} value={uSize}
onChange={(e) => setUSize(Number(e.target.value))} onChange={(e) => setUSize(Number(e.target.value))}
disabled={loading} 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" 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="space-y-1">
<label className="block text-sm text-slate-300">Port count</label>
<input
type="number"
min={0}
max={128}
value={portCount}
onChange={(e) => setPortCount(Number(e.target.value))}
disabled={loading}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
/>
</div>
<div className="space-y-1"> <div className="space-y-1">
<label className="block text-sm text-slate-300">IP Address</label> <label className="block text-sm text-slate-300">IP Address</label>
<input <input
@@ -202,6 +196,41 @@ export function AddModuleModal({ open, onClose, rackId, uPosition, initialType }
</div> </div>
</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="grid grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<label className="block text-sm text-slate-300">Manufacturer</label> <label className="block text-sm text-slate-300">Manufacturer</label>
@@ -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}
/>
</>
);
}
@@ -1,4 +1,5 @@
import { useState, useEffect, type FormEvent } from 'react'; import { useState, useEffect, type FormEvent } from 'react';
import { Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Module } from '../../types'; import type { Module } from '../../types';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
@@ -15,7 +16,7 @@ interface ModuleEditPanelProps {
} }
export function ModuleEditPanel({ module, open, onClose }: ModuleEditPanelProps) { export function ModuleEditPanel({ module, open, onClose }: ModuleEditPanelProps) {
const { updateModuleLocal } = useRackStore(); const { updateModuleLocal, removeModuleLocal } = useRackStore();
const [name, setName] = useState(module.name); const [name, setName] = useState(module.name);
const [ipAddress, setIpAddress] = useState(module.ipAddress ?? ''); const [ipAddress, setIpAddress] = useState(module.ipAddress ?? '');
const [manufacturer, setManufacturer] = useState(module.manufacturer ?? ''); const [manufacturer, setManufacturer] = useState(module.manufacturer ?? '');
@@ -23,9 +24,12 @@ export function ModuleEditPanel({ module, open, onClose }: ModuleEditPanelProps)
const [notes, setNotes] = useState(module.notes ?? ''); const [notes, setNotes] = useState(module.notes ?? '');
const [uSize, setUSize] = useState(module.uSize); const [uSize, setUSize] = useState(module.uSize);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setConfirmingDelete(false);
setName(module.name); setName(module.name);
setIpAddress(module.ipAddress ?? ''); setIpAddress(module.ipAddress ?? '');
setManufacturer(module.manufacturer ?? ''); setManufacturer(module.manufacturer ?? '');
@@ -35,6 +39,21 @@ export function ModuleEditPanel({ module, open, onClose }: ModuleEditPanelProps)
} }
}, [open, module]); }, [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) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
@@ -132,13 +151,51 @@ export function ModuleEditPanel({ module, open, onClose }: ModuleEditPanelProps)
/> />
</div> </div>
<div className="flex justify-end gap-3 pt-1"> <div className="flex items-center justify-between gap-3 pt-1 border-t border-slate-700 mt-1">
<Button variant="secondary" size="sm" type="button" onClick={onClose} disabled={loading}> {/* Delete — left side with inline confirm */}
Cancel {confirmingDelete ? (
</Button> <div className="flex items-center gap-2">
<Button size="sm" type="submit" loading={loading} disabled={!name.trim()}> <span className="text-xs text-red-400">Remove this module?</span>
Save Changes <Button
</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> </div>
</form> </form>
</Modal> </Modal>
+118 -43
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, type FormEvent } from 'react'; import { useState, useEffect, useMemo, type FormEvent } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Port, Vlan, VlanMode } from '../../types'; import type { Port, Vlan, VlanMode } from '../../types';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
@@ -14,41 +14,45 @@ interface PortConfigModalProps {
} }
export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps) { export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps) {
const { racks, fetchRacks } = useRackStore(); const { racks, fetchRacks, deleteConnection } = useRackStore();
const [port, setPort] = useState<Port | null>(null);
const [vlans, setVlans] = useState<Vlan[]>([]); 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 [label, setLabel] = useState('');
const [mode, setMode] = useState<VlanMode>('ACCESS'); const [mode, setMode] = useState<VlanMode>('ACCESS');
const [nativeVlanId, setNativeVlanId] = useState<string>(''); const [nativeVlanId, setNativeVlanId] = useState<string>('');
const [taggedVlanIds, setTaggedVlanIds] = useState<string[]>([]); const [taggedVlanIds, setTaggedVlanIds] = useState<string[]>([]);
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
// Quick-create VLAN // Quick-create VLAN
const [newVlanId, setNewVlanId] = useState(''); const [newVlanId, setNewVlanId] = useState('');
const [newVlanName, setNewVlanName] = useState(''); const [newVlanName, setNewVlanName] = useState('');
const [newVlanColor, setNewVlanColor] = useState('#3b82f6');
const [creatingVlan, setCreatingVlan] = useState(false); const [creatingVlan, setCreatingVlan] = useState(false);
// Find the port from store // Reset form state when port is found or changed
useEffect(() => { useEffect(() => {
if (!open) return; if (port && open) {
let found: Port | undefined; setLabel(port.label ?? '');
for (const rack of racks) { setMode(port.mode);
for (const mod of rack.modules) { setNativeVlanId(port.nativeVlan?.toString() ?? '');
found = mod.ports.find((p) => p.id === portId); setTaggedVlanIds(port.vlans.filter((v) => v.tagged).map((v) => v.vlanId));
if (found) break; setNotes(port.notes ?? '');
}
if (found) break;
} }
if (found) { }, [port, open]);
setPort(found);
setLabel(found.label ?? '');
setMode(found.mode);
setNativeVlanId(found.nativeVlan?.toString() ?? '');
setTaggedVlanIds(found.vlans.filter((v) => v.tagged).map((v) => v.vlanId));
setNotes(found.notes ?? '');
}
}, [open, portId, racks]);
// Load VLAN list // Load VLAN list
useEffect(() => { useEffect(() => {
@@ -99,10 +103,15 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps)
if (!id || !newVlanName.trim()) return; if (!id || !newVlanName.trim()) return;
setCreatingVlan(true); setCreatingVlan(true);
try { try {
const created = await apiClient.vlans.create({ vlanId: id, name: newVlanName.trim() }); const created = await apiClient.vlans.create({
vlanId: id,
name: newVlanName.trim(),
color: newVlanColor,
});
setVlans((v) => [...v, created].sort((a, b) => a.vlanId - b.vlanId)); setVlans((v) => [...v, created].sort((a, b) => a.vlanId - b.vlanId));
setNewVlanId(''); setNewVlanId('');
setNewVlanName(''); setNewVlanName('');
setNewVlanColor('#3b82f6');
toast.success(`VLAN ${id} created`); toast.success(`VLAN ${id} created`);
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to create VLAN'); toast.error(err instanceof Error ? err.message : 'Failed to create VLAN');
@@ -119,6 +128,21 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps)
if (!port) return null; 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 ( return (
<Modal open={open} onClose={onClose} title={`Port ${port.portNumber} Configuration`} size="md"> <Modal open={open} onClose={onClose} title={`Port ${port.portNumber} Configuration`} size="md">
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
@@ -140,6 +164,35 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps)
/> />
</div> </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 */} {/* Mode */}
<div className="space-y-1"> <div className="space-y-1">
<label className="block text-sm text-slate-300">Mode</label> <label className="block text-sm text-slate-300">Mode</label>
@@ -164,19 +217,29 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps)
{/* Native VLAN */} {/* Native VLAN */}
<div className="space-y-1"> <div className="space-y-1">
<label className="block text-sm text-slate-300">Native VLAN</label> <label className="block text-sm text-slate-300">Native VLAN</label>
<select <div className="flex items-center gap-2">
value={nativeVlanId} <select
onChange={(e) => setNativeVlanId(e.target.value)} value={nativeVlanId}
disabled={loading || fetching} onChange={(e) => setNativeVlanId(e.target.value)}
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" 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 value=""> Untagged </option>
<option key={v.id} value={v.vlanId.toString()}> {vlans.map((v) => (
VLAN {v.vlanId} {v.name} <option key={v.id} value={v.vlanId.toString()}>
</option> VLAN {v.vlanId} {v.name}
))} </option>
</select> ))}
</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> </div>
{/* Tagged VLANs — Trunk/Hybrid only */} {/* Tagged VLANs — Trunk/Hybrid only */}
@@ -192,12 +255,17 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps)
key={v.id} key={v.id}
type="button" type="button"
onClick={() => toggleTaggedVlan(v.id)} onClick={() => toggleTaggedVlan(v.id)}
className={`px-2 py-0.5 rounded text-xs border transition-colors ${ style={{
taggedVlanIds.includes(v.id) backgroundColor: taggedVlanIds.includes(v.id) ? v.color ?? '#3b82f6' : 'transparent',
? 'bg-blue-700 border-blue-500 text-white' borderColor: taggedVlanIds.includes(v.id) ? 'transparent' : v.color ?? '#475569',
: 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-400' 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} {v.vlanId} {v.name}
</button> </button>
))} ))}
@@ -222,7 +290,14 @@ export function PortConfigModal({ portId, open, onClose }: PortConfigModalProps)
value={newVlanName} value={newVlanName}
onChange={(e) => setNewVlanName(e.target.value)} onChange={(e) => setNewVlanName(e.target.value)}
placeholder="Name" placeholder="Name"
className="flex-1 bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-100 placeholder:text-slate-600 focus:outline-none focus:ring-1 focus:ring-blue-500" 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 <Button
type="button" type="button"
@@ -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>
);
}
+2 -2
View File
@@ -16,7 +16,7 @@ const ALL_TYPES: ModuleType[] = [
function PaletteItem({ type }: { type: ModuleType }) { function PaletteItem({ type }: { type: ModuleType }) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `palette-${type}`, id: `palette-${type}`,
data: { type }, data: { dragType: 'palette', type },
}); });
const colors = MODULE_TYPE_COLORS[type]; const colors = MODULE_TYPE_COLORS[type];
@@ -27,7 +27,7 @@ function PaletteItem({ type }: { type: ModuleType }) {
{...listeners} {...listeners}
{...attributes} {...attributes}
className={cn( 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', '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.bg,
colors.border, colors.border,
isDragging ? 'opacity-40' : 'hover:brightness-125' isDragging ? 'opacity-40' : 'hover:brightness-125'
+139 -115
View File
@@ -1,12 +1,10 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef } from 'react';
import { useDraggable } from '@dnd-kit/core'; import { useDraggable } from '@dnd-kit/core';
import { Trash2, GripVertical, GripHorizontal } from 'lucide-react'; import { GripHorizontal } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Module } from '../../types'; import type { Module } from '../../types';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS, U_HEIGHT_PX } from '../../lib/constants'; import { MODULE_TYPE_COLORS, U_HEIGHT_PX, PORTS_PER_ROW } from '../../lib/constants';
import { Badge } from '../ui/Badge';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { ModuleEditPanel } from '../modals/ModuleEditPanel'; import { ModuleEditPanel } from '../modals/ModuleEditPanel';
import { PortConfigModal } from '../modals/PortConfigModal'; import { PortConfigModal } from '../modals/PortConfigModal';
import { useRackStore } from '../../store/useRackStore'; import { useRackStore } from '../../store/useRackStore';
@@ -17,13 +15,9 @@ interface ModuleBlockProps {
} }
export function ModuleBlock({ module }: ModuleBlockProps) { export function ModuleBlock({ module }: ModuleBlockProps) {
const { racks, removeModuleLocal, updateModuleLocal } = useRackStore(); const { racks, updateModuleLocal, setActiveConfigPortId } = useRackStore();
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deletingLoading, setDeletingLoading] = useState(false);
const [portModalOpen, setPortModalOpen] = useState(false);
const [selectedPortId, setSelectedPortId] = useState<string | null>(null);
// Resize state // Resize state
const [previewUSize, setPreviewUSize] = useState<number | null>(null); const [previewUSize, setPreviewUSize] = useState<number | null>(null);
@@ -49,6 +43,29 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
const height = displayUSize * U_HEIGHT_PX; const height = displayUSize * U_HEIGHT_PX;
const hasPorts = module.ports.length > 0; 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) // Compute the maximum allowed uSize for this module (rack bounds + collision)
const maxResizeU = useCallback((): number => { const maxResizeU = useCallback((): number => {
const rack = racks.find((r) => r.id === module.rackId); const rack = racks.find((r) => r.id === module.rackId);
@@ -96,38 +113,55 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
} }
} }
async function handleDelete() { function openPort(portId: string) {
setDeletingLoading(true); setActiveConfigPortId(portId);
try {
await apiClient.modules.delete(module.id);
removeModuleLocal(module.id);
toast.success(`${module.name} removed`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Delete failed');
} finally {
setDeletingLoading(false);
setConfirmDeleteOpen(false);
}
} }
function openPort(portId: string) { const { cablingFromPortId, setCablingFromPortId, createConnection } = useRackStore();
setSelectedPortId(portId);
setPortModalOpen(true); 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 ( return (
<> <>
<div <div
ref={setNodeRef} ref={setNodeRef}
{...listeners}
{...attributes}
className={cn( className={cn(
'relative w-full border-l-2 select-none flex flex-col justify-between px-2 py-0.5 overflow-hidden transition-opacity', 'module-block relative w-full border-l-4 select-none overflow-hidden transition-opacity',
colors.bg, colors.bg,
colors.border, colors.border,
isDragging ? 'opacity-0' : 'cursor-pointer', isDragging ? 'opacity-0 pointer-events-none' : 'cursor-grab active:cursor-grabbing',
!isDragging && hovered && 'brightness-110', !isDragging && hovered && 'brightness-110',
previewUSize !== null && 'ring-1 ring-white/30' previewUSize !== null && 'ring-1 ring-white/30'
)} )}
style={{ height }} style={{ height }}
title={`${module.name}${module.ipAddress ? `${module.ipAddress}` : ''}`}
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
onClick={() => !isDragging && !isResizing.current && setEditOpen(true)} onClick={() => !isDragging && !isResizing.current && setEditOpen(true)}
@@ -136,123 +170,113 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
aria-label={`Edit ${module.name}`} aria-label={`Edit ${module.name}`}
onKeyDown={(e) => e.key === 'Enter' && setEditOpen(true)} onKeyDown={(e) => e.key === 'Enter' && setEditOpen(true)}
> >
{/* Drag handle */} {/* Port grid — primary face content */}
<div {hasPorts && previewUSize === null ? (
{...listeners} <div className="flex flex-col gap-1.5 px-2 pt-1.5">
{...attributes} {visibleRows.map((row, rowIdx) => (
className="absolute left-0 top-0 bottom-6 w-4 flex items-start justify-center pt-1.5 cursor-grab active:cursor-grabbing text-white/30 hover:text-white/70 transition-colors z-10 touch-none" <div key={rowIdx} className="flex items-center w-full min-h-[12px]">
onClick={(e) => e.stopPropagation()} {/* Standard ports Group */}
aria-label={`Drag ${module.name}`} <div className="flex gap-[3px] flex-wrap">
> {row.map((port) => {
<GripVertical size={10} /> const hasVlan = port.vlans.length > 0;
</div> const vlanColor = hasVlan
? port.mode === 'ACCESS'
? port.vlans[0]?.vlan?.color || '#10b981'
: '#a78bfa'
: '#475569';
const isCablingSource = cablingFromPortId === port.id;
{/* Main content */} return (
<div className="flex items-center gap-1 min-w-0 pl-3"> <button
<span className="text-xs font-semibold text-white truncate flex-1">{module.name}</span> key={port.id}
<Badge variant="slate" className="text-[10px] shrink-0"> data-port-id={port.id}
{MODULE_TYPE_LABELS[module.type]} onPointerDown={(e) => e.stopPropagation()}
</Badge> onClick={(e) => handlePortClick(e, port.id)}
</div> 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>
{module.ipAddress && ( {/* SFP/WAN Group (push to right) */}
<div className="text-[10px] text-slate-300 font-mono truncate">{module.ipAddress}</div> {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>
)
)} )}
{/* U-size preview label during resize */} {/* Resize preview label */}
{previewUSize !== null && ( {previewUSize !== null && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="text-xs font-bold text-white/70 bg-black/30 px-1.5 py-0.5 rounded"> <span className="text-xs font-bold text-white/80 bg-black/40 px-2 py-0.5 rounded">
{previewUSize}U {previewUSize}U
</span> </span>
</div> </div>
)} )}
{/* Port dots — only if module has ports and enough height */}
{hasPorts && height >= 28 && previewUSize === null && (
<div
className="flex flex-wrap gap-0.5 mt-0.5"
onClick={(e) => e.stopPropagation()}
>
{module.ports.slice(0, 32).map((port) => {
const hasVlan = port.vlans.length > 0;
return (
<button
key={port.id}
onClick={() => openPort(port.id)}
aria-label={`Port ${port.portNumber}`}
className={cn(
'w-2.5 h-2.5 rounded-sm border transition-colors',
hasVlan
? 'bg-green-400 border-green-500 hover:bg-green-300'
: 'bg-slate-600 border-slate-500 hover:bg-slate-400'
)}
/>
);
})}
{module.ports.length > 32 && (
<span className="text-[9px] text-slate-400">+{module.ports.length - 32}</span>
)}
</div>
)}
{/* Delete button — hover only */}
{hovered && previewUSize === null && (
<button
className="absolute top-0.5 right-0.5 p-0.5 rounded bg-red-800/80 hover:bg-red-600 text-white transition-colors z-10"
onClick={(e) => {
e.stopPropagation();
setConfirmDeleteOpen(true);
}}
aria-label={`Delete ${module.name}`}
>
<Trash2 size={11} />
</button>
)}
{/* Resize handle — bottom edge */} {/* Resize handle — bottom edge */}
<div <div
className={cn( className={cn(
'absolute bottom-0 left-0 right-0 h-3 flex items-center justify-center z-20', 'absolute bottom-0 left-0 right-0 h-3 flex items-center justify-center z-20',
'cursor-ns-resize touch-none', 'cursor-ns-resize touch-none',
hovered || previewUSize !== null hovered || previewUSize !== null ? 'opacity-100' : 'opacity-0 hover:opacity-100',
? 'opacity-100'
: 'opacity-0 hover:opacity-100',
'transition-opacity' 'transition-opacity'
)} )}
onPointerDown={handleResizePointerDown} onPointerDown={(e) => { e.stopPropagation(); handleResizePointerDown(e); }}
onPointerMove={handleResizePointerMove} onPointerMove={handleResizePointerMove}
onPointerUp={handleResizePointerUp} onPointerUp={handleResizePointerUp}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
aria-label="Resize module" aria-label="Resize module"
title="Drag to resize" title="Drag to resize"
> >
<GripHorizontal size={10} className="text-white/50 pointer-events-none" /> <GripHorizontal size={9} className="text-white/40 pointer-events-none" />
</div> </div>
</div> </div>
<ModuleEditPanel module={module} open={editOpen} onClose={() => setEditOpen(false)} /> <ModuleEditPanel module={module} open={editOpen} onClose={() => setEditOpen(false)} />
<ConfirmDialog
open={confirmDeleteOpen}
onClose={() => setConfirmDeleteOpen(false)}
onConfirm={handleDelete}
title="Remove Module"
message={`Remove "${module.name}" from the rack? This will also delete all associated port configuration.`}
confirmLabel="Remove"
loading={deletingLoading}
/>
{selectedPortId && (
<PortConfigModal
portId={selectedPortId}
open={portModalOpen}
onClose={() => {
setPortModalOpen(false);
setSelectedPortId(null);
}}
/>
)}
</> </>
); );
} }
+28 -32
View File
@@ -4,7 +4,7 @@ import { CSS } from '@dnd-kit/utilities';
import { Trash2, MapPin, GripVertical } from 'lucide-react'; import { Trash2, MapPin, GripVertical } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Rack } from '../../types'; import type { Rack } from '../../types';
import { buildOccupancyMap } from '../../lib/utils'; import { U_HEIGHT_PX } from '../../lib/constants';
import { ModuleBlock } from './ModuleBlock'; import { ModuleBlock } from './ModuleBlock';
import { RackSlot } from './RackSlot'; import { RackSlot } from './RackSlot';
import { ConfirmDialog } from '../ui/ConfirmDialog'; import { ConfirmDialog } from '../ui/ConfirmDialog';
@@ -12,11 +12,11 @@ import { useRackStore } from '../../store/useRackStore';
interface RackColumnProps { interface RackColumnProps {
rack: Rack; rack: Rack;
/** ID of the module currently being dragged — render its slots as droppable ghosts. */ /** Slot currently hovered by a drag — passed down to RackSlot for blue highlight. */
draggingModuleId?: string | null; hoverSlot?: { rackId: string; uPosition: number } | null;
} }
export function RackColumn({ rack, draggingModuleId }: RackColumnProps) { export function RackColumn({ rack, hoverSlot }: RackColumnProps) {
const { deleteRack } = useRackStore(); const { deleteRack } = useRackStore();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@@ -33,9 +33,6 @@ export function RackColumn({ rack, draggingModuleId }: RackColumnProps) {
opacity: isDragging ? 0.4 : 1, opacity: isDragging ? 0.4 : 1,
}; };
const occupancy = buildOccupancyMap(rack.modules);
const renderedModuleIds = new Set<string>();
async function handleDelete() { async function handleDelete() {
setDeleting(true); setDeleting(true);
try { try {
@@ -51,7 +48,7 @@ export function RackColumn({ rack, draggingModuleId }: RackColumnProps) {
return ( return (
<> <>
<div ref={setNodeRef} style={style} className="flex flex-col min-w-[200px] w-48 shrink-0"> <div ref={setNodeRef} style={style} className="flex flex-col min-w-[384px] w-96 shrink-0">
{/* Rack header — drag handle for reorder */} {/* 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"> <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 */} {/* Drag handle */}
@@ -84,31 +81,30 @@ export function RackColumn({ rack, draggingModuleId }: RackColumnProps) {
</div> </div>
{/* U-slot body */} {/* U-slot body */}
<div className="border-x border-slate-600 bg-[#1e2433] flex flex-col"> <div
{Array.from({ length: rack.totalU }, (_, i) => i + 1).map((u) => { className="relative border-x border-slate-600 bg-[#1e2433]"
const moduleId = occupancy.get(u) ?? null; 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>
if (moduleId) { {rack.modules.map((module) => (
const module = rack.modules.find((m) => m.id === moduleId); <div
if (!module) return null; key={module.id}
className="absolute left-0 right-0 z-10"
// Only render ModuleBlock at its top U style={{ top: (module.uPosition - 1) * U_HEIGHT_PX }}
if (module.uPosition !== u) return null; >
if (renderedModuleIds.has(moduleId)) return null; <ModuleBlock module={module} />
renderedModuleIds.add(moduleId); </div>
))}
// If this module is being dragged, show empty droppable slot(s) instead
if (moduleId === draggingModuleId) {
return (
<RackSlot key={`ghost-${u}`} rackId={rack.id} uPosition={u} />
);
}
return <ModuleBlock key={module.id} module={module} />;
}
return <RackSlot key={u} rackId={rack.id} uPosition={u} />;
})}
</div> </div>
{/* Rack footer */} {/* Rack footer */}
+145 -32
View File
@@ -3,6 +3,7 @@ import {
DndContext, DndContext,
DragOverlay, DragOverlay,
PointerSensor, PointerSensor,
closestCenter,
useSensor, useSensor,
useSensors, useSensors,
type DragStartEvent, type DragStartEvent,
@@ -15,7 +16,10 @@ import { apiClient } from '../../api/client';
import { RackToolbar } from './RackToolbar'; import { RackToolbar } from './RackToolbar';
import { RackColumn } from './RackColumn'; import { RackColumn } from './RackColumn';
import { DevicePalette } from './DevicePalette'; import { DevicePalette } from './DevicePalette';
import { ConnectionLayer } from './ConnectionLayer';
import { AddModuleModal } from '../modals/AddModuleModal'; import { AddModuleModal } from '../modals/AddModuleModal';
import { PortConfigModal } from '../modals/PortConfigModal';
import { ConnectionConfigModal } from '../modals/ConnectionConfigModal';
import { RackSkeleton } from '../ui/Skeleton'; import { RackSkeleton } from '../ui/Skeleton';
import type { ModuleType } from '../../types'; import type { ModuleType } from '../../types';
import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS } from '../../lib/constants'; import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS } from '../../lib/constants';
@@ -27,6 +31,11 @@ interface PendingDrop {
type: ModuleType; type: ModuleType;
} }
interface HoverSlot {
rackId: string;
uPosition: number;
}
function DragOverlayItem({ type }: { type: ModuleType }) { function DragOverlayItem({ type }: { type: ModuleType }) {
const colors = MODULE_TYPE_COLORS[type]; const colors = MODULE_TYPE_COLORS[type];
return ( return (
@@ -50,8 +59,35 @@ function ModuleDragOverlay({ label }: { label: string }) {
); );
} }
/**
* 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() { export function RackPlanner() {
const { racks, loading, fetchRacks, moveModule, updateRack } = useRackStore(); const { racks, loading, fetchRacks, moveModule, activeConfigPortId, setActiveConfigPortId, activeConfigConnectionId, setActiveConfigConnectionId } = useRackStore();
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
// Drag state // Drag state
@@ -60,56 +96,103 @@ export function RackPlanner() {
const [draggingModuleId, setDraggingModuleId] = useState<string | null>(null); const [draggingModuleId, setDraggingModuleId] = useState<string | null>(null);
const [pendingDrop, setPendingDrop] = useState<PendingDrop | 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( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }) useSensor(PointerSensor, POINTER_SENSOR_OPTIONS)
); );
useEffect(() => { useEffect(() => {
fetchRacks().catch(() => toast.error('Failed to load racks')); fetchRacks().catch(() => toast.error('Failed to load racks'));
}, [fetchRacks]); }, [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) { function handleDragStart(event: DragStartEvent) {
const data = event.active.data.current as Record<string, unknown>; const data = event.active.data.current as Record<string, unknown>;
if (data?.dragType === 'palette') { if (data?.dragType === 'palette') {
setActivePaletteType(data.type as ModuleType); setActivePaletteType(data.type as ModuleType);
isDraggingAnyRef.current = true;
document.body.classList.add('rack-dragging');
} else if (data?.dragType === 'module') { } else if (data?.dragType === 'module') {
setDraggingModuleId(data.moduleId as string); setDraggingModuleId(data.moduleId as string);
setActiveDragModuleLabel(data.label as string); setActiveDragModuleLabel(data.label as string);
isDraggingAnyRef.current = true;
document.body.classList.add('rack-dragging');
} }
updateHoverSlot(null);
} }
async function handleDragEnd(event: DragEndEvent) { async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event; 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); setActivePaletteType(null);
setActiveDragModuleLabel(null); setActiveDragModuleLabel(null);
setDraggingModuleId(null); setDraggingModuleId(null);
updateHoverSlot(null);
if (!over) return;
const dragData = active.data.current as Record<string, unknown>; const dragData = active.data.current as Record<string, unknown>;
const dropData = over.data.current as Record<string, unknown> | undefined;
// --- Palette → slot: open AddModuleModal pre-filled --- // --- Palette → slot: open AddModuleModal pre-filled ---
if (dragData?.dragType === 'palette' && dropData?.dropType === 'slot') { if (dragData?.dragType === 'palette' && slot) {
setPendingDrop({ setPendingDrop({
type: dragData.type as ModuleType, type: dragData.type as ModuleType,
rackId: dropData.rackId as string, rackId: slot.rackId,
uPosition: dropData.uPosition as number, uPosition: slot.uPosition,
}); });
return; return;
} }
// --- Module → slot: move the module --- // --- Module → slot: move the module ---
if (dragData?.dragType === 'module' && dropData?.dropType === 'slot') { if (dragData?.dragType === 'module' && slot) {
const moduleId = dragData.moduleId as string; const moduleId = dragData.moduleId as string;
const targetRackId = dropData.rackId as string;
const targetUPosition = dropData.uPosition as number;
// No-op if dropped on own position // No-op if dropped on own position
if (dragData.fromRackId === targetRackId && dragData.fromUPosition === targetUPosition) return; if (dragData.fromRackId === slot.rackId && dragData.fromUPosition === slot.uPosition) return;
try { try {
await moveModule(moduleId, targetRackId, targetUPosition); await moveModule(moduleId, slot.rackId, slot.uPosition);
toast.success('Module moved'); toast.success('Module moved');
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Move failed'); toast.error(err instanceof Error ? err.message : 'Move failed');
@@ -118,24 +201,26 @@ export function RackPlanner() {
} }
// --- Rack header → rack header: reorder racks --- // --- Rack header → rack header: reorder racks ---
if (dragData?.dragType === 'rack' && over.data.current?.dragType === 'rack') { 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 oldIndex = racks.findIndex((r) => r.id === active.id);
const newIndex = racks.findIndex((r) => r.id === over.id); const newIndex = racks.findIndex((r) => r.id === over.id);
if (oldIndex === newIndex) return; if (oldIndex === newIndex) return;
const reordered = arrayMove(racks, oldIndex, newIndex); const reordered = arrayMove(racks, oldIndex, newIndex);
// Persist new displayOrder values
try { try {
await Promise.all( await Promise.all(
reordered.map((rack, idx) => reordered.map((rack, idx) =>
rack.displayOrder !== idx ? apiClient.racks.update(rack.id, { displayOrder: idx }) : Promise.resolve(rack) rack.displayOrder !== idx
? apiClient.racks.update(rack.id, { displayOrder: idx })
: Promise.resolve(rack)
) )
); );
// Refresh store to sync
await fetchRacks(); await fetchRacks();
} catch { } catch {
toast.error('Failed to save rack order'); toast.error('Failed to save rack order');
await fetchRacks(); // rollback await fetchRacks();
} }
} }
} }
@@ -143,14 +228,19 @@ export function RackPlanner() {
const rackIds = racks.map((r) => r.id); const rackIds = racks.map((r) => r.id);
return ( return (
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}> <DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="flex flex-col h-screen bg-[#0f1117]"> <div className="flex flex-col h-screen bg-[#0f1117]">
<RackToolbar rackCanvasRef={canvasRef} /> <RackToolbar rackCanvasRef={canvasRef} />
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<DevicePalette /> <DevicePalette />
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto relative rack-planner-canvas">
{loading ? ( {loading ? (
<RackSkeleton /> <RackSkeleton />
) : racks.length === 0 ? ( ) : racks.length === 0 ? (
@@ -170,23 +260,30 @@ export function RackPlanner() {
</div> </div>
</div> </div>
) : ( ) : (
<SortableContext items={rackIds} strategy={horizontalListSortingStrategy}> <>
<div <SortableContext items={rackIds} strategy={horizontalListSortingStrategy}>
ref={canvasRef} <div
className="flex gap-4 p-4 min-h-full items-start" ref={canvasRef}
style={{ background: '#0f1117' }} className="flex gap-4 p-4 min-h-full items-start"
> style={{ background: '#0f1117' }}
{racks.map((rack) => ( >
<RackColumn key={rack.id} rack={rack} draggingModuleId={draggingModuleId} /> {racks.map((rack) => (
))} <RackColumn
</div> key={rack.id}
</SortableContext> rack={rack}
hoverSlot={hoverSlot}
/>
))}
</div>
</SortableContext>
<ConnectionLayer />
</>
)} )}
</div> </div>
</div> </div>
</div> </div>
<DragOverlay dropAnimation={null}> <DragOverlay dropAnimation={null} className="pointer-events-none" zIndex={999} style={{ pointerEvents: 'none' }}>
{activePaletteType && <DragOverlayItem type={activePaletteType} />} {activePaletteType && <DragOverlayItem type={activePaletteType} />}
{activeDragModuleLabel && <ModuleDragOverlay label={activeDragModuleLabel} />} {activeDragModuleLabel && <ModuleDragOverlay label={activeDragModuleLabel} />}
</DragOverlay> </DragOverlay>
@@ -200,6 +297,22 @@ export function RackPlanner() {
initialType={pendingDrop.type} initialType={pendingDrop.type}
/> />
)} )}
{activeConfigPortId && (
<PortConfigModal
open={!!activeConfigPortId}
portId={activeConfigPortId}
onClose={() => setActiveConfigPortId(null)}
/>
)}
{activeConfigConnectionId && (
<ConnectionConfigModal
open={!!activeConfigConnectionId}
connectionId={activeConfigConnectionId}
onClose={() => setActiveConfigConnectionId(null)}
/>
)}
</DndContext> </DndContext>
); );
} }
+6 -8
View File
@@ -1,5 +1,4 @@
import { useState } from 'react'; import { useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { U_HEIGHT_PX } from '../../lib/constants'; import { U_HEIGHT_PX } from '../../lib/constants';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
@@ -8,20 +7,19 @@ import { AddModuleModal } from '../modals/AddModuleModal';
interface RackSlotProps { interface RackSlotProps {
rackId: string; rackId: string;
uPosition: number; uPosition: number;
/** Passed from RackPlanner via RackColumn — true when a drag is hovering this slot */
isOver?: boolean;
} }
export function RackSlot({ rackId, uPosition }: RackSlotProps) { export function RackSlot({ rackId, uPosition, isOver = false }: RackSlotProps) {
const [addModuleOpen, setAddModuleOpen] = useState(false); const [addModuleOpen, setAddModuleOpen] = useState(false);
const { setNodeRef, isOver } = useDroppable({
id: `slot-${rackId}-${uPosition}`,
data: { dropType: 'slot', rackId, uPosition },
});
return ( return (
<> <>
<div <div
ref={setNodeRef} // Data attributes let RackPlanner's onDragMove identify this slot via elementFromPoint
data-rack-id={rackId}
data-u-pos={String(uPosition)}
className={cn( className={cn(
'w-full border border-dashed transition-colors group cursor-pointer flex items-center justify-between px-2', 'w-full border border-dashed transition-colors group cursor-pointer flex items-center justify-between px-2',
isOver isOver
+12
View File
@@ -28,3 +28,15 @@
height: 1.75rem; /* 28px per U */ 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;
}
+4 -1
View File
@@ -70,7 +70,10 @@ export const MODULE_TYPE_COLORS: Record<ModuleType, { bg: string; border: string
}; };
// ---- U-slot height in px (used for layout calculations) ---- // ---- U-slot height in px (used for layout calculations) ----
export const U_HEIGHT_PX = 28; export const U_HEIGHT_PX = 44;
// ---- Ports rendered per row in ModuleBlock ----
export const PORTS_PER_ROW = 24;
// ---- Default rack size ---- // ---- Default rack size ----
export const DEFAULT_RACK_U = 42; export const DEFAULT_RACK_U = 42;
+33 -3
View File
@@ -2,6 +2,17 @@ import { create } from 'zustand';
import type { ServiceMap, ServiceMapSummary } from '../types'; import type { ServiceMap, ServiceMapSummary } from '../types';
import { apiClient } from '../api/client'; 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 { interface MapState {
maps: ServiceMapSummary[]; maps: ServiceMapSummary[];
activeMap: ServiceMap | null; activeMap: ServiceMap | null;
@@ -13,7 +24,7 @@ interface MapState {
setActiveMap: (map: ServiceMap | null) => void; setActiveMap: (map: ServiceMap | null) => void;
} }
export const useMapStore = create<MapState>((set) => ({ export const useMapStore = create<MapState>((set, get) => ({
maps: [], maps: [],
activeMap: null, activeMap: null,
loading: false, loading: false,
@@ -23,6 +34,15 @@ export const useMapStore = create<MapState>((set) => ({
try { try {
const maps = await apiClient.maps.list(); const maps = await apiClient.maps.list();
set({ maps, loading: false }); 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 { } catch {
set({ loading: false }); set({ loading: false });
throw new Error('Failed to load maps'); throw new Error('Failed to load maps');
@@ -33,6 +53,7 @@ export const useMapStore = create<MapState>((set) => ({
set({ loading: true }); set({ loading: true });
try { try {
const map = await apiClient.maps.get(id); const map = await apiClient.maps.get(id);
saveLastMapId(id);
set({ activeMap: map, loading: false }); set({ activeMap: map, loading: false });
} catch { } catch {
set({ loading: false }); set({ loading: false });
@@ -42,17 +63,26 @@ export const useMapStore = create<MapState>((set) => ({
createMap: async (name, description) => { createMap: async (name, description) => {
const map = await apiClient.maps.create({ 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] })); set((s) => ({
maps: [
{ id: map.id, name: map.name, description: map.description, createdAt: map.createdAt, updatedAt: map.updatedAt },
...s.maps,
],
}));
return map; return map;
}, },
deleteMap: async (id) => { deleteMap: async (id) => {
await apiClient.maps.delete(id); await apiClient.maps.delete(id);
if (getLastMapId() === id) saveLastMapId(null);
set((s) => ({ set((s) => ({
maps: s.maps.filter((m) => m.id !== id), maps: s.maps.filter((m) => m.id !== id),
activeMap: s.activeMap?.id === id ? null : s.activeMap, activeMap: s.activeMap?.id === id ? null : s.activeMap,
})); }));
}, },
setActiveMap: (map) => set({ activeMap: map }), setActiveMap: (map) => {
saveLastMapId(map?.id ?? null);
set({ activeMap: map });
},
})); }));
+41
View File
@@ -19,6 +19,18 @@ interface RackState {
removeModuleLocal: (moduleId: string) => void; removeModuleLocal: (moduleId: string) => void;
// Selection // Selection
setSelectedModule: (id: string | null) => void; 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) => ({ export const useRackStore = create<RackState>((set, get) => ({
@@ -106,4 +118,33 @@ export const useRackStore = create<RackState>((set, get) => ({
}, },
setSelectedModule: (id) => set({ selectedModuleId: id }), 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 }),
})); }));
+14 -1
View File
@@ -14,7 +14,7 @@ export type ModuleType =
| 'BLANK' | 'BLANK'
| 'OTHER'; | 'OTHER';
export type PortType = 'ETHERNET' | 'SFP' | 'SFP_PLUS' | 'QSFP' | 'CONSOLE' | 'UPLINK'; export type PortType = 'ETHERNET' | 'SFP' | 'SFP_PLUS' | 'QSFP' | 'CONSOLE' | 'UPLINK' | 'WAN';
export type VlanMode = 'ACCESS' | 'TRUNK' | 'HYBRID'; export type VlanMode = 'ACCESS' | 'TRUNK' | 'HYBRID';
@@ -56,6 +56,19 @@ export interface Port {
nativeVlan?: number; nativeVlan?: number;
vlans: PortVlanAssignment[]; vlans: PortVlanAssignment[];
notes?: string; 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 { export interface Module {
+1 -1
View File
@@ -9,7 +9,7 @@ services:
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3001 - PORT=3001
- DATABASE_URL=file:./data/rackmapper.db - DATABASE_URL=file:/app/data/rackmapper.db
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin} - ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_PASSWORD=${ADMIN_PASSWORD}
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -e
echo "[entrypoint] RackMapper starting..."
# Ensure the data directory exists and is writable
mkdir -p /app/data
echo "[entrypoint] Data directory: $(ls -la /app/data)"
# Run migrations (creates the SQLite file if it doesn't exist)
echo "[entrypoint] Running database migrations..."
npx prisma migrate deploy
echo "[entrypoint] Migrations complete."
# Start the server
echo "[entrypoint] Starting server..."
exec node dist/server/index.js
+43
View File
@@ -0,0 +1,43 @@
const js = require('@eslint/js');
const globals = require('globals');
const tseslint = require('typescript-eslint');
module.exports = tseslint.config(
{
ignores: ['dist/**', 'client/**', 'node_modules/**'],
},
js.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
{
files: ['server/**/*.ts', 'scripts/**/*.ts', 'prisma/seed.ts'],
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
globals: {
...globals.node,
},
},
rules: {
'@typescript-eslint/no-misused-promises': [
'error',
{
checksVoidReturn: {
arguments: false,
},
},
],
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-unsafe-assignment': 'off',
},
}
);
+386 -3
View File
@@ -17,6 +17,7 @@
"jsonwebtoken": "^9.0.2" "jsonwebtoken": "^9.0.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4",
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
"@types/cookie-parser": "^1.4.8", "@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
@@ -25,11 +26,13 @@
"@types/node": "^22.9.0", "@types/node": "^22.9.0",
"concurrently": "^9.1.0", "concurrently": "^9.1.0",
"eslint": "^9.14.0", "eslint": "^9.14.0",
"globals": "^17.4.0",
"nodemon": "^3.1.7", "nodemon": "^3.1.7",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"typescript-eslint": "^8.57.2",
"vitest": "^2.1.5" "vitest": "^2.1.5"
} }
}, },
@@ -582,6 +585,19 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/@eslint/eslintrc/node_modules/globals": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.39.4", "version": "9.39.4",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
@@ -1276,6 +1292,288 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz",
"integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.57.2",
"@typescript-eslint/type-utils": "8.57.2",
"@typescript-eslint/utils": "8.57.2",
"@typescript-eslint/visitor-keys": "8.57.2",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.57.2",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 4"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz",
"integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.57.2",
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/typescript-estree": "8.57.2",
"@typescript-eslint/visitor-keys": "8.57.2",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz",
"integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.57.2",
"@typescript-eslint/types": "^8.57.2",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz",
"integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/visitor-keys": "8.57.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz",
"integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz",
"integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/typescript-estree": "8.57.2",
"@typescript-eslint/utils": "8.57.2",
"debug": "^4.4.3",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz",
"integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz",
"integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.57.2",
"@typescript-eslint/tsconfig-utils": "8.57.2",
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/visitor-keys": "8.57.2",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
"tinyglobby": "^0.2.15",
"ts-api-utils": "^2.4.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz",
"integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.57.2",
"@typescript-eslint/types": "8.57.2",
"@typescript-eslint/typescript-estree": "8.57.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz",
"integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.57.2",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "2.1.9", "version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
@@ -2741,9 +3039,9 @@
} }
}, },
"node_modules/globals": { "node_modules/globals": {
"version": "14.0.0", "version": "17.4.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz",
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -4337,6 +4635,54 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinypool": { "node_modules/tinypool": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
@@ -4409,6 +4755,19 @@
"tree-kill": "cli.js" "tree-kill": "cli.js"
} }
}, },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.12"
},
"peerDependencies": {
"typescript": ">=4.8.4"
}
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -4488,6 +4847,30 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/typescript-eslint": {
"version": "8.57.2",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.2.tgz",
"integrity": "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@typescript-eslint/typescript-estree": "8.57.2",
"@typescript-eslint/utils": "8.57.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/undefsafe": { "node_modules/undefsafe": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
+3
View File
@@ -27,6 +27,7 @@
"jsonwebtoken": "^9.0.2" "jsonwebtoken": "^9.0.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4",
"@types/better-sqlite3": "^7.6.12", "@types/better-sqlite3": "^7.6.12",
"@types/cookie-parser": "^1.4.8", "@types/cookie-parser": "^1.4.8",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
@@ -35,11 +36,13 @@
"@types/node": "^22.9.0", "@types/node": "^22.9.0",
"concurrently": "^9.1.0", "concurrently": "^9.1.0",
"eslint": "^9.14.0", "eslint": "^9.14.0",
"globals": "^17.4.0",
"nodemon": "^3.1.7", "nodemon": "^3.1.7",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"prisma": "^5.22.0", "prisma": "^5.22.0",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"typescript-eslint": "^8.57.2",
"vitest": "^2.1.5" "vitest": "^2.1.5"
}, },
"prisma": { "prisma": {
@@ -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;
+21 -1
View File
@@ -1,5 +1,7 @@
generator client { generator client {
provider = "prisma-client-js" 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 { datasource db {
@@ -51,6 +53,24 @@ model Port {
nativeVlan Int? nativeVlan Int?
vlans PortVlan[] vlans PortVlan[]
notes String? 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 { model Vlan {
+12
View File
@@ -0,0 +1,12 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function check() {
const modules = await prisma.module.findMany({
include: { ports: true }
});
console.log(JSON.stringify(modules, null, 2));
}
check().catch(console.error);
+2
View File
@@ -12,6 +12,7 @@ import { vlansRouter } from './routes/vlans';
import { serviceMapRouter } from './routes/serviceMap'; import { serviceMapRouter } from './routes/serviceMap';
import { nodesRouter } from './routes/nodes'; import { nodesRouter } from './routes/nodes';
import { edgesRouter } from './routes/edges'; import { edgesRouter } from './routes/edges';
import connectionsRouter from './routes/connections';
import { authMiddleware } from './middleware/authMiddleware'; import { authMiddleware } from './middleware/authMiddleware';
import { errorHandler } from './middleware/errorHandler'; import { errorHandler } from './middleware/errorHandler';
@@ -44,6 +45,7 @@ app.use('/api/vlans', vlansRouter);
app.use('/api/maps', serviceMapRouter); app.use('/api/maps', serviceMapRouter);
app.use('/api/nodes', nodesRouter); app.use('/api/nodes', nodesRouter);
app.use('/api/edges', edgesRouter); app.use('/api/edges', edgesRouter);
app.use('/api/connections', connectionsRouter);
// ---- Serve Vite build in production ---- // ---- Serve Vite build in production ----
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
+1 -1
View File
@@ -5,7 +5,7 @@ export type ModuleType =
| 'SWITCH' | 'AGGREGATE_SWITCH' | 'MODEM' | 'ROUTER' | 'NAS' | 'SWITCH' | 'AGGREGATE_SWITCH' | 'MODEM' | 'ROUTER' | 'NAS'
| 'PDU' | 'PATCH_PANEL' | 'SERVER' | 'FIREWALL' | 'AP' | 'BLANK' | 'OTHER'; | 'PDU' | 'PATCH_PANEL' | 'SERVER' | 'FIREWALL' | 'AP' | 'BLANK' | 'OTHER';
export type PortType = 'ETHERNET' | 'SFP' | 'SFP_PLUS' | 'QSFP' | 'CONSOLE' | 'UPLINK'; export type PortType = 'ETHERNET' | 'SFP' | 'SFP_PLUS' | 'QSFP' | 'CONSOLE' | 'UPLINK' | 'WAN';
export type VlanMode = 'ACCESS' | 'TRUNK' | 'HYBRID'; export type VlanMode = 'ACCESS' | 'TRUNK' | 'HYBRID';
+4 -1
View File
@@ -5,10 +5,13 @@ import { authMiddleware } from '../middleware/authMiddleware';
export const authRouter = Router(); 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 = { const COOKIE_OPTS = {
httpOnly: true, httpOnly: true,
sameSite: 'strict' as const, sameSite: 'strict' as const,
secure: process.env.NODE_ENV === 'production', secure: process.env.COOKIE_SECURE === 'true',
path: '/', path: '/',
}; };
+49
View File
@@ -0,0 +1,49 @@
import { Router } from 'express';
import * as connService from '../services/connectionService';
import { ok } from '../types/index';
const router = Router();
// POST /api/connections
router.post('/', async (req, res, next) => {
try {
const { fromPortId, toPortId, color, label, edgeType } = req.body;
const conn = await connService.createConnection({ fromPortId, toPortId, color, label, edgeType });
res.status(201).json(ok(conn));
} catch (err) {
next(err);
}
});
// PUT /api/connections/:id
router.put('/:id', async (req, res, next) => {
try {
const { color, label, edgeType } = req.body;
const conn = await connService.updateConnection(req.params.id, { color, label, edgeType });
res.json(ok(conn));
} catch (err) {
next(err);
}
});
// DELETE /api/connections/:id
router.delete('/:id', async (req, res, next) => {
try {
await connService.deleteConnection(req.params.id);
res.json(ok(null));
} catch (err) {
next(err);
}
});
// DELETE /api/connections/ports/:p1/:p2 (remove link between two specific ports)
router.delete('/ports/:p1/:p2', async (req, res, next) => {
try {
await connService.deleteByPorts(req.params.p1, req.params.p2);
res.json(ok(null));
} catch (err) {
next(err);
}
});
export default router;
+4 -2
View File
@@ -61,7 +61,7 @@ racksRouter.delete('/:id', async (req: Request, res: Response, next: NextFunctio
racksRouter.post('/:id/modules', async (req: Request, res: Response, next: NextFunction) => { racksRouter.post('/:id/modules', async (req: Request, res: Response, next: NextFunction) => {
try { try {
const { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType } = const { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType, sfpCount, wanCount } =
req.body as { req.body as {
name: string; name: string;
type: ModuleType; type: ModuleType;
@@ -73,9 +73,11 @@ racksRouter.post('/:id/modules', async (req: Request, res: Response, next: NextF
notes?: string; notes?: string;
portCount?: number; portCount?: number;
portType?: PortType; portType?: PortType;
sfpCount?: number;
wanCount?: number;
}; };
res.status(201).json( res.status(201).json(
ok(await moduleService.createModule(req.params.id, { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType })) ok(await moduleService.createModule(req.params.id, { name, type, uPosition, uSize, manufacturer, model, ipAddress, notes, portCount, portType, sfpCount, wanCount }))
); );
} catch (e) { } catch (e) {
next(e); next(e);
+41
View File
@@ -0,0 +1,41 @@
import { prisma } from '../lib/prisma';
import { AppError } from '../types/index';
export async function createConnection(data: { fromPortId: string; toPortId: string; color?: string; label?: string; edgeType?: string }) {
// Check if both ports exist
const [from, to] = await Promise.all([
prisma.port.findUnique({ where: { id: data.fromPortId } }),
prisma.port.findUnique({ where: { id: data.toPortId } }),
]);
if (!from || !to) throw new AppError('One or both ports not found', 404, 'NOT_FOUND');
if (from.id === to.id) throw new AppError('Cannot connect a port to itself', 400, 'BAD_REQUEST');
// Check if ports are already occupied?
// (In real life, a port can only have one cable, but we might allow one source and one target per port if we want to be flexible, but better to prevent simple loops)
// Create connection (if it already exists, use upsert or just throw error; @@unique already handles it)
return prisma.connection.create({ data });
}
export async function deleteConnection(id: string) {
return prisma.connection.delete({ where: { id } });
}
export async function updateConnection(id: string, data: Partial<{ color: string; label: string; edgeType: string }>) {
return prisma.connection.update({
where: { id },
data,
});
}
export async function deleteByPorts(portId1: string, portId2: string) {
return prisma.connection.deleteMany({
where: {
OR: [
{ fromPortId: portId1, toPortId: portId2 },
{ fromPortId: portId2, toPortId: portId1 },
],
},
});
}
+29 -6
View File
@@ -47,6 +47,8 @@ export async function createModule(
notes?: string; notes?: string;
portCount?: number; portCount?: number;
portType?: PortType; portType?: PortType;
sfpCount?: number;
wanCount?: number;
} }
) { ) {
const rack = await prisma.rack.findUnique({ where: { id: rackId } }); const rack = await prisma.rack.findUnique({ where: { id: rackId } });
@@ -66,8 +68,32 @@ export async function createModule(
throw new AppError('U-slot collision: another module occupies that space', 409, 'COLLISION'); throw new AppError('U-slot collision: another module occupies that space', 409, 'COLLISION');
} }
const portCount = data.portCount ?? MODULE_PORT_DEFAULTS[data.type] ?? 0; const sfpCount = data.sfpCount ?? (data.type === 'AGGREGATE_SWITCH' ? data.portCount ?? MODULE_PORT_DEFAULTS[data.type] : 0);
const portType: PortType = data.portType ?? 'ETHERNET'; 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({ return prisma.module.create({
data: { data: {
@@ -81,10 +107,7 @@ export async function createModule(
ipAddress: data.ipAddress, ipAddress: data.ipAddress,
notes: data.notes, notes: data.notes,
ports: { ports: {
create: Array.from({ length: portCount }, (_, i) => ({ create: portsToCreate,
portNumber: i + 1,
portType,
})),
}, },
}, },
include: moduleInclude, include: moduleInclude,
+2
View File
@@ -12,6 +12,8 @@ const rackInclude = {
vlans: { vlans: {
include: { vlan: true }, include: { vlan: true },
}, },
sourceConnections: true,
targetConnections: true,
}, },
}, },
}, },