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>
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>
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>
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>
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>
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>
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>
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>
- 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>
- GUI method: Docker tab field-by-field walkthrough (name, port, volume, env vars)
- CLI method: docker run one-liner with all required flags
- Building the image: local build on Unraid terminal + push-to-registry option
- JWT_SECRET generation tip using /proc/sys/kernel/random/uuid
- Updating, password change, backup, and troubleshooting sections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Drag handle at bottom edge of each module (GripHorizontal icon)
- Pointer capture tracks vertical drag delta → U-size delta
- Clamped to: minimum 1U, rack bounds, first module below
- Shows current U-size label during active resize
- On release: PUT /modules/:id with new uSize (server validates collision)
- Optimistic store update via updateModuleLocal on success
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Full CRUD: create, inline-edit, delete with confirm dialog
- Table shows VLAN ID, name, description, color swatch
- Add-VLAN form at top; hover shows edit/delete actions per row
- Route registered in App.tsx under ProtectedRoute
- VLANs nav button added to RackToolbar and MapToolbar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Right-click on canvas → add any node type at cursor position
- Right-click on node → edit, duplicate, or delete
- Right-click on edge → toggle animation, set edge type (bezier/smooth/step/straight), delete
- Double-click a node → NodeEditModal (label, accent color, rack module link)
- ContextMenu component: viewport-clamped, closes on outside click or Escape
- All actions persist to API; React Flow state updated optimistically
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Complete project scaffold with working auth, REST API, Prisma/SQLite
schema, Docker config, and React frontend for both Rack Planner and
Service Mapper modules. Both server and client pass TypeScript strict
mode with zero errors. Initial migration applied.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>