feat(rack): patch by dragging port to port, and select a cable before deleting it

Cabling was a two-click flow with no way back: the only way to remove a patch
was to click it, which deleted it outright, and clicking a port was the only way
to start one.

- Drag from a port to another to patch, with a dashed rubber band following the
  pointer. Clicking one port then another still works; Escape drops a
  half-drawn patch.
- A click on a cable now selects it (accent halo, fat invisible hit area);
  Delete/Backspace or the new header Unplug button removes it.
- Leaving patch mode restores the cable visibility it was entered with, instead
  of stranding the canvas on "always" with every plate faded until a reload.
- Draw every port at one fixed size — the 1U switch/patch-panel socket — rather
  than scaling it with plate height. Drops the now-unused template portSize.

ha-relevant: no
This commit is contained in:
Pouzor
2026-08-09 20:35:23 +02:00
committed by Pouzor - Rémy Jardient
parent 3138cda378
commit ff740f7466
15 changed files with 762 additions and 67 deletions
@@ -6,7 +6,7 @@
* much of the cabling is on screen.
*/
import { useState } from 'react'
import { Cable, Link2, Plus, X } from 'lucide-react'
import { Cable, Link2, Plus, Trash2, X } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { useRackStore } from '@/rack/store'
@@ -34,6 +34,8 @@ export function RackToolbarActions() {
const setCableTypeFilter = useRackStore((s) => s.setCableTypeFilter)
const cableDraft = useRackStore((s) => s.cableDraft)
const cancelCableDraft = useRackStore((s) => s.cancelCableDraft)
const selectedCableId = useRackStore((s) => s.selectedCableId)
const removeSelectedCable = useRackStore((s) => s.removeSelectedCable)
const importCables = useRackStore((s) => s.importCablesFromNetwork)
const networkImportDone = useRackStore((s) => s.networkImportDone)
const designs = useDesignStore((s) => s.designs)
@@ -65,7 +67,7 @@ export function RackToolbarActions() {
variant="ghost"
className={`${ghost} ${cableMode ? 'text-[#00d4ff]' : ''}`}
onClick={toggleCableMode}
title="Click a port, then another, to patch. Click a cable to remove it."
title="Drag from one port to another to patch, or click both in turn. Click a cable to select it, then Delete to unplug."
>
<Cable size={14} /> {cableMode ? 'Exit patching' : 'Patch'}
</Button>
@@ -81,6 +83,18 @@ export function RackToolbarActions() {
</Button>
)}
{cableMode && selectedCableId && (
<Button
size="sm"
variant="ghost"
className={`${ghost} text-[#f85149]`}
onClick={removeSelectedCable}
title="Unplug the selected cable (Delete)"
>
<Trash2 size={14} /> Unplug
</Button>
)}
<select
className={selectClass}
aria-label="Cable visibility"
@@ -0,0 +1,41 @@
/**
* Rack header actions. The Unplug button is the visible half of cable
* selection — the keyboard shortcut alone would be undiscoverable.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { RackToolbarActions } from '../RackToolbarActions'
import { useRackStore } from '@/rack/store'
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
beforeEach(() => {
useRackStore.getState().loadDemo()
})
const unplug = () => screen.queryByRole('button', { name: /unplug/i })
describe('RackToolbarActions cable selection', () => {
it('hides Unplug outside patch mode', () => {
useRackStore.getState().selectCable(useRackStore.getState().cables[0].id)
render(<RackToolbarActions />)
expect(unplug()).toBeNull()
})
it('hides Unplug in patch mode while no cable is selected', () => {
useRackStore.getState().toggleCableMode()
render(<RackToolbarActions />)
expect(unplug()).toBeNull()
})
it('unplugs the selected cable', () => {
useRackStore.getState().toggleCableMode()
const cable = useRackStore.getState().cables[0]
useRackStore.getState().selectCable(cable.id)
render(<RackToolbarActions />)
fireEvent.click(unplug()!)
expect(useRackStore.getState().cables.some((c) => c.id === cable.id)).toBe(false)
expect(unplug()).toBeNull()
})
})
+1 -1
View File
@@ -54,7 +54,7 @@ types in `@/types/rack`, narrowing every enum on the way in.
- Double-click a plate → the same modal in edit mode: label, faceplate, U/height/column/width, status, colour, port list, Unmount. Single click only selects.
- Double-click empty rack chrome → `RackSettingsModal` (name, location, U height, 19"/10", numbering direction, frame/rail/interior colours, U numbers, enclosed, delete).
- Growing a device — by hand or by picking a taller plate — relocates it to the nearest slot that takes the new size. Only a rack with no such slot rejects the edit, and says so.
- **Patch mode**: click port A then port B to cable them; click a cable to remove it. Rack dragging is disabled while in patch mode.
- **Patch mode**: drag from port A to port B to cable them — a dashed rubber band follows the pointer, exactly like dragging an edge on the logical canvas. Clicking A then B still works; Escape drops a half-drawn patch. Click a cable to select it (it gets an accent halo), then press Delete/Backspace — or the header's Unplug button — to remove it; Escape or a pane click deselects. Rack dragging is disabled while in patch mode.
- **Import links**: one shot per canvas, guarded by `networkImportDone`. Reads the physical edges (ethernet/fibre/vlan/cluster) of every non-rack design and matches them on `nodeId`.
- **New device**: the modal's *New device* source adds a Device Inventory entry (tagged `rack`), for gear no scan will ever discover. The left-rail tray only carries accessories, as a drag source.
@@ -0,0 +1,97 @@
/**
* Cabling overlay: in patch mode a click selects a cable rather than deleting
* it outright, so a misclick costs nothing and Delete is the destructive step.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, fireEvent } from '@testing-library/react'
import { CableLayer } from '../components/CableLayer'
import { useRackStore } from '../store'
vi.mock('@xyflow/react', async () => {
const { mockReactFlow } = await import('@/test/mocks')
const React = await import('react')
return mockReactFlow({
ViewportPortal: ({ children }: { children?: React.ReactNode }) =>
React.createElement('div', null, children),
})
})
/** The fat invisible click targets, one per drawn cable. */
const hitPaths = (container: HTMLElement) =>
Array.from(container.querySelectorAll<SVGPathElement>('path[stroke="transparent"]'))
beforeEach(() => {
useRackStore.getState().loadDemo()
})
describe('CableLayer selection', () => {
it('offers no click target outside patch mode', () => {
useRackStore.getState().setCableVisibility('always')
const { container } = render(<CableLayer />)
expect(hitPaths(container)).toHaveLength(0)
})
it('selects a cable on click in patch mode', () => {
useRackStore.getState().toggleCableMode()
const { container } = render(<CableLayer />)
const paths = hitPaths(container)
expect(paths.length).toBe(useRackStore.getState().cables.length)
fireEvent.click(paths[0])
expect(useRackStore.getState().selectedCableId).toBe(useRackStore.getState().cables[0].id)
})
it('deselects when the selected cable is clicked again', () => {
useRackStore.getState().toggleCableMode()
const { container } = render(<CableLayer />)
fireEvent.click(hitPaths(container)[0])
fireEvent.click(hitPaths(container)[0])
expect(useRackStore.getState().selectedCableId).toBeNull()
})
it('leaves the cable in place — clicking never deletes', () => {
useRackStore.getState().toggleCableMode()
const before = useRackStore.getState().cables.length
const { container } = render(<CableLayer />)
fireEvent.click(hitPaths(container)[0])
expect(useRackStore.getState().cables).toHaveLength(before)
})
it('draws no rubber band until the drag moves', () => {
const store = useRackStore.getState()
store.toggleCableMode()
const port = store.devices[0].ports[0]
store.startCableDrag(store.devices[0].id, port.id)
const { queryByTestId } = render(<CableLayer />)
expect(queryByTestId('cable-draft')).toBeNull()
})
it('draws a rubber band from the armed port to the pointer', () => {
const store = useRackStore.getState()
store.toggleCableMode()
const device = store.devices[0]
store.startCableDrag(device.id, device.ports[0].id)
store.moveCableDrag({ x: 420, y: 300 })
const { getByTestId } = render(<CableLayer />)
const band = getByTestId('cable-draft')
const end = band.querySelectorAll('circle')[1]
expect(end.getAttribute('cx')).toBe('420')
expect(end.getAttribute('cy')).toBe('300')
expect(band.querySelector('path')!.getAttribute('stroke-dasharray')).toBe('5 4')
})
it('draws the selected cable with a highlight halo', () => {
useRackStore.getState().toggleCableMode()
const { container, rerender } = render(<CableLayer />)
const strokeWidths = () =>
Array.from(container.querySelectorAll<SVGPathElement>('path')).map((p) =>
p.getAttribute('stroke-width'),
)
expect(strokeWidths()).not.toContain('7')
fireEvent.click(hitPaths(container)[0])
rerender(<CableLayer />)
expect(strokeWidths()).toContain('7')
})
})
@@ -28,6 +28,49 @@ function draw(faceplateId: string) {
}
}
/** Every RJ45 silhouette drawn on a plate, as its path `d`. */
function rj45Paths(faceplateId: string, height: number) {
const plate = getFaceplate(faceplateId)
const ports = plate.ports.map((p, i) => ({ ...p, id: `p${i}` }))
const { container } = render(
<Faceplate
faceplateId={faceplateId}
label="gear"
status="online"
ports={ports}
width={400}
height={height}
revealed
/>,
)
return Array.from(container.querySelectorAll('g'))
.filter((g) => g.querySelector('title')?.textContent?.endsWith('· rj45'))
.map((g) => g.querySelector('path')?.getAttribute('d'))
.filter(Boolean)
}
describe('Faceplate — port artwork', () => {
it('draws every RJ45 at the same size, whatever the plate', () => {
// A 1U switch, a 1U patch panel and a 4U NAS: same socket on all three.
const reference = rj45Paths('switch-24', 24)[0]
expect(reference).toBeTruthy()
for (const [id, height] of [
['patch-24', 24],
['server-1u', 24],
['nas-2u', 48],
['server-4u-storage', 96],
] as const) {
const paths = rj45Paths(id, height)
expect(paths.length).toBeGreaterThan(0)
for (const d of paths) expect(d).toBe(reference)
}
})
it('keeps the socket the same size on a plate stretched to 4U', () => {
expect(rj45Paths('switch-24', 96)[0]).toBe(rj45Paths('switch-24', 24)[0])
})
})
describe('Faceplate — name band', () => {
it('keeps rack gear labelled across the middle', () => {
const { led, text } = draw('server-1u')
+107 -1
View File
@@ -19,7 +19,10 @@ vi.mock('@xyflow/react', async () => {
BackgroundVariant: { Dots: 'dots' },
Controls: () => null,
ViewportPortal: () => null,
useReactFlow: () => ({ setViewport: vi.fn() }),
useReactFlow: () => ({
setViewport: vi.fn(),
screenToFlowPosition: (p: { x: number; y: number }) => p,
}),
})
})
@@ -58,3 +61,106 @@ describe('RackCanvas empty state', () => {
expect(screen.queryByText('This rack canvas is empty.')).toBeNull()
})
})
describe('RackCanvas cable keyboard shortcuts', () => {
const selectFirstCable = () => {
const store = useRackStore.getState()
store.loadDemo()
store.toggleCableMode()
const cable = useRackStore.getState().cables[0]
useRackStore.getState().selectCable(cable.id)
return cable
}
it('unplugs the selected cable on Delete', () => {
const cable = selectFirstCable()
render(<RackCanvas />)
fireEvent.keyDown(window, { key: 'Delete' })
expect(useRackStore.getState().cables.some((c) => c.id === cable.id)).toBe(false)
})
it('unplugs the selected cable on Backspace', () => {
const cable = selectFirstCable()
render(<RackCanvas />)
fireEvent.keyDown(window, { key: 'Backspace' })
expect(useRackStore.getState().cables.some((c) => c.id === cable.id)).toBe(false)
})
it('deselects on Escape without removing the cable', () => {
const cable = selectFirstCable()
render(<RackCanvas />)
fireEvent.keyDown(window, { key: 'Escape' })
expect(useRackStore.getState().selectedCableId).toBeNull()
expect(useRackStore.getState().cables.some((c) => c.id === cable.id)).toBe(true)
})
it('ignores Delete while typing in an input', () => {
const cable = selectFirstCable()
render(
<>
<RackCanvas />
<input data-testid="typing" />
</>,
)
fireEvent.keyDown(screen.getByTestId('typing'), { key: 'Delete' })
expect(useRackStore.getState().cables.some((c) => c.id === cable.id)).toBe(true)
})
it('drops a half-drawn patch on Escape', () => {
const store = useRackStore.getState()
store.loadDemo()
store.toggleCableMode()
const device = useRackStore.getState().devices[0]
useRackStore.getState().startCableDrag(device.id, device.ports[0].id)
render(<RackCanvas />)
fireEvent.keyDown(window, { key: 'Escape' })
expect(useRackStore.getState().cableDraft).toBeNull()
})
it('does nothing on Delete with no cable selected', () => {
useRackStore.getState().loadDemo()
const before = useRackStore.getState().cables.length
render(<RackCanvas />)
fireEvent.keyDown(window, { key: 'Delete' })
expect(useRackStore.getState().cables).toHaveLength(before)
})
})
describe('RackCanvas cable dragging', () => {
const armPort = () => {
const store = useRackStore.getState()
store.loadDemo()
store.toggleCableMode()
const device = useRackStore.getState().devices[0]
useRackStore.getState().startCableDrag(device.id, device.ports[0].id)
}
it('tracks the pointer while a patch is being dragged', () => {
armPort()
render(<RackCanvas />)
fireEvent.pointerMove(window, { clientX: 64, clientY: 128 })
// The mocked screenToFlowPosition is the identity.
expect(useRackStore.getState().cableDrag).toEqual({
pointer: { x: 64, y: 128 },
moved: true,
})
})
it('cancels the draft when the drag is released on nothing', () => {
armPort()
render(<RackCanvas />)
fireEvent.pointerMove(window, { clientX: 64, clientY: 128 })
fireEvent.pointerUp(window)
expect(useRackStore.getState().cableDraft).toBeNull()
expect(useRackStore.getState().cableDrag).toBeNull()
})
it('keeps the port armed when the press never moved', () => {
armPort()
const armed = useRackStore.getState().cableDraft
render(<RackCanvas />)
fireEvent.pointerUp(window)
expect(useRackStore.getState().cableDraft).toEqual(armed)
expect(useRackStore.getState().cableDrag).toBeNull()
})
})
@@ -3,7 +3,7 @@
* its LED from the Device Inventory's live `node_status`.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { render, screen, fireEvent } from '@testing-library/react'
import { RackFlowNode } from '../components/RackFlowNode'
import { useRackStore } from '../store'
import { rackPalette } from '../rackTheme'
@@ -73,3 +73,66 @@ describe('RackFlowNode status', () => {
expect(ledFill(device.label)).toBe(palette().status.online)
})
})
describe('RackFlowNode patching', () => {
/** Two mounted ports in this rack that nothing is plugged into yet. */
function freePorts() {
const patched = new Set(
store().cables.flatMap((c) => [c.from.portId, c.to.portId]),
)
const found = store()
.devices.filter((d) => d.rackId === store().racks[0].id)
.flatMap((d) => d.ports.filter((p) => !patched.has(p.id)).map((p) => ({ device: d, port: p })))
return { a: found[0], b: found[found.length - 1] }
}
/** The port's group, found through the <title> the plate gives it. */
function portGroup(label: string, portLabel: string, portType: string) {
const plate = screen.getByTitle(new RegExp(`^${label} ·`))
const title = Array.from(plate.querySelectorAll('title')).find(
(t) => t.textContent === `${portLabel} · ${portType}`,
)
if (!title) throw new Error(`no port ${portLabel} on ${label}`)
return title.parentElement!
}
it('ignores a press on a port outside patch mode', () => {
renderRack()
const { a } = freePorts()
fireEvent.pointerDown(portGroup(a.device.label, a.port.label, a.port.type))
expect(store().cableDraft).toBeNull()
expect(store().cableDrag).toBeNull()
})
it('patches a cable dragged from one port to another', () => {
store().toggleCableMode()
renderRack()
const { a, b } = freePorts()
const before = store().cables.length
fireEvent.pointerDown(portGroup(a.device.label, a.port.label, a.port.type))
expect(store().cableDraft).toEqual({ deviceId: a.device.id, portId: a.port.id })
fireEvent.pointerUp(portGroup(b.device.label, b.port.label, b.port.type))
expect(store().cables).toHaveLength(before + 1)
const created = store().cables[store().cables.length - 1]
expect(created.from.portId).toBe(a.port.id)
expect(created.to.portId).toBe(b.port.id)
})
it('still patches with two separate clicks', () => {
store().toggleCableMode()
renderRack()
const { a, b } = freePorts()
const before = store().cables.length
const portA = portGroup(a.device.label, a.port.label, a.port.type)
fireEvent.pointerDown(portA)
fireEvent.pointerUp(portA)
expect(store().cableDraft).toEqual({ deviceId: a.device.id, portId: a.port.id })
expect(store().cables).toHaveLength(before)
fireEvent.pointerDown(portGroup(b.device.label, b.port.label, b.port.type))
expect(store().cables).toHaveLength(before + 1)
})
})
+135
View File
@@ -273,6 +273,120 @@ describe('cables', () => {
expect(store().cables).toHaveLength(before + 1)
})
it('patches by dragging from one port to another', () => {
const { a, b } = freePorts()
const before = store().cables.length
store().startCableDrag(a.deviceId, a.portId)
expect(store().cableDraft).toEqual(a)
store().moveCableDrag({ x: 120, y: 80 })
expect(store().cableDrag).toEqual({ pointer: { x: 120, y: 80 }, moved: true })
store().endCableDrag(b)
expect(store().cables).toHaveLength(before + 1)
expect(store().cableDraft).toBeNull()
expect(store().cableDrag).toBeNull()
})
it('drops the draft when a drag is released on nothing', () => {
const { a } = freePorts()
const before = store().cables.length
store().startCableDrag(a.deviceId, a.portId)
store().moveCableDrag({ x: 10, y: 10 })
store().endCableDrag(null)
expect(store().cables).toHaveLength(before)
expect(store().cableDraft).toBeNull()
expect(store().cableDrag).toBeNull()
})
it('keeps the port armed when a press never moved, so click-then-click works', () => {
const { a, b } = freePorts()
const before = store().cables.length
store().startCableDrag(a.deviceId, a.portId)
store().endCableDrag(null)
expect(store().cableDraft).toEqual(a)
expect(store().cableDrag).toBeNull()
store().startCableDrag(b.deviceId, b.portId)
expect(store().cables).toHaveLength(before + 1)
expect(store().cableDraft).toBeNull()
})
it('disarms when the armed port is pressed again', () => {
const { a } = freePorts()
const before = store().cables.length
store().startCableDrag(a.deviceId, a.portId)
store().startCableDrag(a.deviceId, a.portId)
expect(store().cableDraft).toBeNull()
expect(store().cables).toHaveLength(before)
})
it('ignores a release with no drag in flight', () => {
const { a, b } = freePorts()
const before = store().cables.length
store().endCableDrag(b)
expect(store().cables).toHaveLength(before)
expect(store().cableDraft).toBeNull()
// And a move without a drag changes nothing.
store().moveCableDrag({ x: 1, y: 1 })
expect(store().cableDrag).toBeNull()
expect(a).toBeTruthy()
})
it('cancels a draft and its drag together', () => {
const { a } = freePorts()
store().startCableDrag(a.deviceId, a.portId)
store().moveCableDrag({ x: 5, y: 5 })
store().cancelCableDraft()
expect(store().cableDraft).toBeNull()
expect(store().cableDrag).toBeNull()
})
it('selects a cable and unplugs it with removeSelectedCable', () => {
const cable = store().cables[0]
store().selectCable(cable.id)
expect(store().selectedCableId).toBe(cable.id)
store().removeSelectedCable()
expect(store().cables.some((c) => c.id === cable.id)).toBe(false)
expect(store().selectedCableId).toBeNull()
})
it('removeSelectedCable is a no-op with nothing selected', () => {
const before = store().cables.length
store().removeSelectedCable()
expect(store().cables).toHaveLength(before)
})
it('clears the cable selection when the cable is removed by id', () => {
const cable = store().cables[0]
store().selectCable(cable.id)
store().removeCable(cable.id)
expect(store().selectedCableId).toBeNull()
})
it('keeps the cable selection when another cable is removed', () => {
const [first, second] = store().cables
store().selectCable(first.id)
store().removeCable(second.id)
expect(store().selectedCableId).toBe(first.id)
})
it('drops the cable selection when a device or rack is selected', () => {
store().selectCable(store().cables[0].id)
store().selectDevice(store().devices[0].id)
expect(store().selectedCableId).toBeNull()
store().selectCable(store().cables[0].id)
store().selectRack('rack-main')
expect(store().selectedCableId).toBeNull()
})
it('drops the cable selection when leaving patch mode', () => {
store().toggleCableMode()
store().selectCable(store().cables[0].id)
store().toggleCableMode()
expect(store().cableMode).toBe(false)
expect(store().selectedCableId).toBeNull()
})
it('turns cables on when entering patch mode', () => {
expect(store().cableVisibility).toBe('hover')
store().toggleCableMode()
@@ -280,6 +394,27 @@ describe('cables', () => {
expect(store().cableVisibility).toBe('always')
})
it('restores the previous cable visibility when leaving patch mode', () => {
store().toggleCableMode()
store().toggleCableMode()
expect(store().cableMode).toBe(false)
expect(store().cableVisibility).toBe('hover')
})
it('keeps a visibility the user picked while patching', () => {
store().toggleCableMode()
store().setCableVisibility('hidden')
store().toggleCableMode()
expect(store().cableVisibility).toBe('hidden')
})
it('leaves an always-on canvas alone across patch mode', () => {
store().setCableVisibility('always')
store().toggleCableMode()
store().toggleCableMode()
expect(store().cableVisibility).toBe('always')
})
it('imports links from the network canvas once', () => {
// Mount the inventory item the hints reference; the other end is racked already.
store().mountFromInventory('inv-sw8', 'rack-main', { uStart: 4 })
+78 -17
View File
@@ -7,6 +7,7 @@
*/
import { ViewportPortal } from '@xyflow/react'
import { portPosition } from '../layout'
import { useRackPalette } from '../rackTheme'
import { useRackStore } from '../store'
import type { Cable, Port, Rack, RackDevice } from '@/types'
@@ -32,7 +33,11 @@ export function CableLayer() {
const hoveredDeviceId = useRackStore((s) => s.hoveredDeviceId)
const selectedDeviceId = useRackStore((s) => s.selectedDeviceId)
const cableMode = useRackStore((s) => s.cableMode)
const removeCable = useRackStore((s) => s.removeCable)
const selectedCableId = useRackStore((s) => s.selectedCableId)
const selectCable = useRackStore((s) => s.selectCable)
const cableDraft = useRackStore((s) => s.cableDraft)
const cableDrag = useRackStore((s) => s.cableDrag)
const palette = useRackPalette()
if (visibility === 'hidden' && !cableMode) return null
@@ -63,7 +68,12 @@ export function CableLayer() {
visible.push({ cable, from, to })
}
if (visible.length === 0) return null
// Rubber band: the patch being dragged out of its source port.
const dragFrom = cableDraft && resolve(cableDraft.deviceId, cableDraft.portId)
const dragTo = cableDrag?.pointer ?? null
const draftLine = dragFrom && dragTo ? { from: dragFrom, to: dragTo } : null
if (visible.length === 0 && !draftLine) return null
return (
<ViewportPortal>
@@ -80,35 +90,86 @@ export function CableLayer() {
}}
>
{visible.map(({ cable, from, to }) => {
const dimmed = focus != null && cable.from.deviceId !== focus && cable.to.deviceId !== focus
const selected = cableMode && cable.id === selectedCableId
const dimmed =
!selected && focus != null && cable.from.deviceId !== focus && cable.to.deviceId !== focus
const d = cablePath(from, to)
return (
<g key={cable.id} opacity={dimmed ? 0.28 : 1}>
{selected && (
<path
d={d}
fill="none"
stroke={palette.accent}
strokeWidth={7}
strokeLinecap="round"
opacity={0.45}
style={{ pointerEvents: 'none' }}
/>
)}
<path
d={cablePath(from, to)}
d={d}
fill="none"
stroke="#0d1117"
strokeWidth={3.5}
strokeLinecap="round"
/>
<path
d={cablePath(from, to)}
d={d}
fill="none"
stroke={cable.color}
strokeWidth={1.8}
stroke={selected ? palette.accent : cable.color}
strokeWidth={selected ? 2.6 : 1.8}
strokeLinecap="round"
style={{ pointerEvents: cableMode ? 'stroke' : 'none', cursor: cableMode ? 'pointer' : undefined }}
onClick={() => cableMode && removeCable(cable.id)}
>
<title>
{cable.label ? `${cable.label} — ${cable.type}` : cable.type}
{cableMode ? ' (click to remove)' : ''}
</title>
</path>
<circle cx={from.x} cy={from.y} r={2.2} fill={cable.color} />
<circle cx={to.x} cy={to.y} r={2.2} fill={cable.color} />
style={{ pointerEvents: 'none' }}
/>
{/* Fat invisible hit area — a 1.8px path is hard to hit at low zoom. */}
{cableMode && (
<path
d={d}
fill="none"
stroke="transparent"
strokeWidth={14}
strokeLinecap="round"
style={{ pointerEvents: 'stroke', cursor: 'pointer' }}
onClick={(e) => {
// The pane click behind would clear the selection again.
e.stopPropagation()
selectCable(selected ? null : cable.id)
}}
>
<title>
{cable.label ? `${cable.label} — ${cable.type}` : cable.type}
{' (click to select, Delete to remove)'}
</title>
</path>
)}
<circle cx={from.x} cy={from.y} r={selected ? 3.2 : 2.2} fill={selected ? palette.accent : cable.color} />
<circle cx={to.x} cy={to.y} r={selected ? 3.2 : 2.2} fill={selected ? palette.accent : cable.color} />
</g>
)
})}
{draftLine && (
<g data-testid="cable-draft" style={{ pointerEvents: 'none' }}>
<path
d={cablePath(draftLine.from, draftLine.to)}
fill="none"
stroke={palette.accent}
strokeWidth={2}
strokeDasharray="5 4"
strokeLinecap="round"
/>
<circle cx={draftLine.from.x} cy={draftLine.from.y} r={3} fill={palette.accent} />
<circle
cx={draftLine.to.x}
cy={draftLine.to.y}
r={3.5}
fill="none"
stroke={palette.accent}
strokeWidth={1.5}
/>
</g>
)}
</svg>
</ViewportPortal>
)
+34 -13
View File
@@ -7,6 +7,7 @@
*/
import { memo, useId } from 'react'
import { getFaceplate } from '../faceplates'
import { U_PX } from '../layout'
import { useRackPalette } from '../rackTheme'
import type { DeviceStatus, FaceplateElement, Port, PortType } from '@/types'
@@ -18,13 +19,17 @@ interface PortShapeSize {
h: number
}
/** Fixed-ish port artwork, nudged by plate height so 1U panels stay legible. */
function portShapeSize(size: 'sm' | 'md', height: number, type: PortType): PortShapeSize {
const w =
size === 'sm'
? Math.max(5, Math.min(8, height * 0.3))
: Math.max(7, Math.min(12, height * 0.42))
return type === 'rj45' ? { w, h: w * 0.92 } : { w: w * 1.15, h: w * 0.62 }
/**
* Port artwork is the same size on every plate — a socket is a socket, whether
* it sits on a 1U switch or a 4U NAS. The reference is a 1U patch panel, the
* densest thing that has to stay legible.
*/
const PORT_W = U_PX * 0.3
function portShapeSize(type: PortType): PortShapeSize {
return type === 'rj45'
? { w: PORT_W, h: PORT_W * 0.92 }
: { w: PORT_W * 1.15, h: PORT_W * 0.62 }
}
/** RJ45 silhouette: body with the latch slot cut into the top, centred on (0,0). */
@@ -160,12 +165,14 @@ interface Props {
revealed?: boolean
/** Fade the plate so cables behind it stay readable. */
transparent?: boolean
/** Ports are clickable (patch mode). */
/** Ports take the pointer (patch mode). */
interactivePorts?: boolean
/** Port id -> colour of the cable plugged into it. */
patchedPorts?: Map<string, string>
draftPortId?: string | null
onPortClick?: (portId: string) => void
/** Press arms the port; release over another one closes the patch. */
onPortPointerDown?: (portId: string) => void
onPortPointerUp?: (portId: string) => void
}
export const Faceplate = memo(function Faceplate({
@@ -182,7 +189,8 @@ export const Faceplate = memo(function Faceplate({
interactivePorts,
patchedPorts,
draftPortId,
onPortClick,
onPortPointerDown,
onPortPointerUp,
}: Props) {
const template = getFaceplate(faceplateId)
const palette = useRackPalette()
@@ -253,7 +261,7 @@ export const Faceplate = memo(function Faceplate({
const cy = port.y * height
const patchColor = patchedPorts?.get(port.id)
const isDraft = draftPortId === port.id
const { w, h } = portShapeSize(template.portSize ?? 'md', height, port.type)
const { w, h } = portShapeSize(port.type)
const stroke = isDraft ? palette.accent : patchColor ?? palette.portBezel
return (
@@ -264,15 +272,28 @@ export const Faceplate = memo(function Faceplate({
cursor: interactivePorts ? 'crosshair' : undefined,
pointerEvents: interactivePorts ? 'auto' : 'none',
}}
onClick={
onPointerDown={
interactivePorts
? (e) => {
// Keep the press off the plate: it would start an HTML5
// drag of the mount instead of a cable.
e.stopPropagation()
e.preventDefault()
onPortPointerDown?.(port.id)
}
: undefined
}
onPointerUp={
interactivePorts
? (e) => {
e.stopPropagation()
onPortClick?.(port.id)
onPortPointerUp?.(port.id)
}
: undefined
}
>
{/* Grab area: a 6px socket is too small to aim a cable at. */}
{interactivePorts && <circle r={Math.max(w, h) * 0.85} fill="transparent" />}
{port.type === 'rj45' ? (
<path
d={rj45Path(w, h)}
+47 -1
View File
@@ -39,9 +39,17 @@ export function RackCanvas() {
const setViewport = useRackStore((s) => s.setViewport)
const addRack = useRackStore((s) => s.addRack)
const loadDemo = useRackStore((s) => s.loadDemo)
const selectCable = useRackStore((s) => s.selectCable)
const removeSelectedCable = useRackStore((s) => s.removeSelectedCable)
const selectedCableId = useRackStore((s) => s.selectedCableId)
const dragging = useRackStore((s) => s.cableDrag != null)
const moveCableDrag = useRackStore((s) => s.moveCableDrag)
const endCableDrag = useRackStore((s) => s.endCableDrag)
const cableDraft = useRackStore((s) => s.cableDraft)
const cancelCableDraft = useRackStore((s) => s.cancelCableDraft)
const palette = useRackPalette()
const { setViewport: applyViewport } = useReactFlow()
const { setViewport: applyViewport, screenToFlowPosition } = useReactFlow()
// Mounts set to "check device" read their LED from the inventory's
// `node_status`, which only a refetch moves.
@@ -55,6 +63,44 @@ export function RackCanvas() {
if (storedViewport.zoom > 0) void applyViewport(storedViewport)
}, [designId, loading, storedViewport, applyViewport])
// A patch dragged out of a port follows the pointer until it is released.
// Both listeners are on the window: the drop target is often a port on
// another rack, and a release outside the canvas must still end the drag.
useEffect(() => {
if (!dragging) return
const move = (e: PointerEvent) =>
moveCableDrag(screenToFlowPosition({ x: e.clientX, y: e.clientY }))
// The port's own pointerup runs first and clears the drag, so reaching here
// means the cable was released on nothing.
const up = () => endCableDrag(null)
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
return () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
}
}, [dragging, moveCableDrag, endCableDrag, screenToFlowPosition])
// Delete/Backspace unplugs the selected cable; Escape deselects it, or drops
// a half-drawn patch.
useEffect(() => {
if (!selectedCableId && !cableDraft) return
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null
const tag = target?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) return
if (selectedCableId && (e.key === 'Delete' || e.key === 'Backspace')) {
e.preventDefault()
removeSelectedCable()
} else if (e.key === 'Escape') {
selectCable(null)
cancelCableDraft()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [selectedCableId, cableDraft, removeSelectedCable, selectCable, cancelCableDraft])
const nodes: Node[] = useMemo(
() =>
racks.map((rack) => ({
@@ -50,7 +50,8 @@ export function RackFlowNode({ id }: NodeProps) {
const selectDevice = useRackStore((s) => s.selectDevice)
const selectRack = useRackStore((s) => s.selectRack)
const hoverDevice = useRackStore((s) => s.hoverDevice)
const pickPort = useRackStore((s) => s.pickPort)
const startCableDrag = useRackStore((s) => s.startCableDrag)
const endCableDrag = useRackStore((s) => s.endCableDrag)
const openDeviceEditor = useRackStore((s) => s.openDeviceEditor)
const openRackEditor = useRackStore((s) => s.openRackEditor)
const mountFromInventory = useRackStore((s) => s.mountFromInventory)
@@ -302,7 +303,8 @@ export function RackFlowNode({ id }: NodeProps) {
interactivePorts={cableMode}
patchedPorts={patchedPorts}
draftPortId={isDraftDevice ? cableDraft.portId : null}
onPortClick={(portId) => pickPort(device.id, portId)}
onPortPointerDown={(portId) => startCableDrag(device.id, portId)}
onPortPointerUp={(portId) => endCableDrag({ deviceId: device.id, portId })}
/>
</div>
)
-16
View File
@@ -121,7 +121,6 @@ export const FACEPLATES: FaceplateTemplate[] = [
colSpan: RACK_COLUMNS,
statusLed: true,
labelBox: LABEL_LEFT,
portSize: 'md',
elements: [
{ kind: 'panel', fill: STEEL, stroke: '#0d1117' },
{ kind: 'vents', x: 0.32, y: 0.22, w: 0.42, h: 0.56, cols: 16, rows: 2, fill: VENT },
@@ -137,7 +136,6 @@ export const FACEPLATES: FaceplateTemplate[] = [
colSpan: RACK_COLUMNS,
statusLed: true,
labelBox: LABEL_LEFT,
portSize: 'md',
elements: [
{ kind: 'panel', fill: STEEL, stroke: '#0d1117' },
{ kind: 'bays', x: 0.32, y: 0.18, w: 0.42, h: 0.64, cols: 4, rows: 1, fill: BLACK_BOX },
@@ -153,7 +151,6 @@ export const FACEPLATES: FaceplateTemplate[] = [
colSpan: RACK_COLUMNS,
statusLed: true,
labelBox: LABEL_LEFT,
portSize: 'md',
elements: [
{ kind: 'panel', fill: STEEL, stroke: '#0d1117' },
{ kind: 'bays', x: 0.32, y: 0.12, w: 0.42, h: 0.76, cols: 4, rows: 2, fill: BLACK_BOX },
@@ -172,7 +169,6 @@ export const FACEPLATES: FaceplateTemplate[] = [
colSpan: RACK_COLUMNS,
statusLed: true,
labelBox: { x: 0.055, w: 0.18 },
portSize: 'md',
elements: [
{ kind: 'panel', fill: STEEL_LIGHT, stroke: '#0d1117' },
{ kind: 'bays', x: 0.26, y: 0.1, w: 0.5, h: 0.8, cols: 4, rows: 3, fill: BLACK_BOX },
@@ -188,7 +184,6 @@ export const FACEPLATES: FaceplateTemplate[] = [
colSpan: RACK_COLUMNS / 2,
statusLed: true,
labelBox: LABEL_SMALL,
portSize: 'md',
elements: [
{ kind: 'panel', fill: STEEL_LIGHT, stroke: '#0d1117' },
{ kind: 'vents', x: 0.54, y: 0.25, w: 0.24, h: 0.5, cols: 8, rows: 2, fill: VENT },
@@ -204,7 +199,6 @@ export const FACEPLATES: FaceplateTemplate[] = [
colSpan: RACK_COLUMNS / 3,
statusLed: true,
labelBox: { x: 0.14, w: 0.42 },
portSize: 'md',
elements: [
{ kind: 'panel', fill: STEEL_LIGHT, stroke: '#0d1117' },
{ kind: 'vents', x: 0.6, y: 0.3, w: 0.16, h: 0.4, cols: 5, rows: 2, fill: VENT },
@@ -223,7 +217,6 @@ export const FACEPLATES: FaceplateTemplate[] = [
statusLed: true,
alwaysShowPorts: true,
labelBox: LABEL_LEFT,
portSize: 'md',
elements: [
{ kind: 'panel', fill: BLACK_BOX, stroke: '#0d1117' },
{ kind: 'strip', x: 0.32, y: 0.16, w: 0.42, h: 0.68, fill: '#11161d' },
@@ -243,7 +236,6 @@ export const FACEPLATES: FaceplateTemplate[] = [
statusLed: true,
alwaysShowPorts: true,
labelBox: LABEL_TIGHT,
portSize: 'sm',
elements: [
{ kind: 'panel', fill: BLACK_BOX, stroke: '#0d1117' },
{ kind: 'strip', x: 0.22, y: 0.12, w: 0.55, h: 0.76, fill: '#11161d' },
@@ -263,7 +255,6 @@ export const FACEPLATES: FaceplateTemplate[] = [
statusLed: true,
alwaysShowPorts: true,
labelBox: LABEL_TIGHT,
portSize: 'sm',
elements: [
{ kind: 'panel', fill: BLACK_BOX, stroke: '#0d1117' },
{ kind: 'strip', x: 0.21, y: 0.12, w: 0.56, h: 0.76, fill: '#11161d' },
@@ -282,7 +273,6 @@ export const FACEPLATES: FaceplateTemplate[] = [
colSpan: RACK_COLUMNS,
statusLed: true,
labelBox: LABEL_LEFT,
portSize: 'md',
elements: [
{ kind: 'panel', fill: '#232a34', stroke: '#0d1117' },
{ kind: 'strip', x: 0.34, y: 0.16, w: 0.4, h: 0.68, fill: '#11161d' },
@@ -301,7 +291,6 @@ export const FACEPLATES: FaceplateTemplate[] = [
colSpan: RACK_COLUMNS,
alwaysShowPorts: true,
labelBox: { x: 0.02, w: 0.16 },
portSize: 'sm',
elements: [
{ kind: 'panel', fill: '#3a3f47', stroke: '#0d1117' },
{ kind: 'strip', x: 0.2, y: 0.14, w: 0.78, h: 0.72, fill: '#22262c' },
@@ -317,7 +306,6 @@ export const FACEPLATES: FaceplateTemplate[] = [
colSpan: RACK_COLUMNS,
alwaysShowPorts: true,
labelBox: { x: 0.02, w: 0.16 },
portSize: 'sm',
elements: [
{ kind: 'panel', fill: '#3a3f47', stroke: '#0d1117' },
{ kind: 'strip', x: 0.2, y: 0.16, w: 0.6, h: 0.68, fill: '#22262c' },
@@ -335,7 +323,6 @@ export const FACEPLATES: FaceplateTemplate[] = [
colSpan: RACK_COLUMNS,
statusLed: true,
labelBox: LABEL_LEFT,
portSize: 'md',
elements: [
{ kind: 'panel', fill: '#262c35', stroke: '#0d1117' },
{ kind: 'bays', x: 0.32, y: 0.12, w: 0.42, h: 0.76, cols: 4, rows: 2, fill: BLACK_BOX },
@@ -359,7 +346,6 @@ export const FACEPLATES: FaceplateTemplate[] = [
colSpan: RACK_COLUMNS / 6,
statusLed: true,
labelBox: { x: 0.26, w: 0.4, y: NAS_BAND },
portSize: 'md',
elements: [
{ kind: 'panel', fill: '#262c35', stroke: '#0d1117' },
nasBays(2, RACK_COLUMNS / 6),
@@ -375,7 +361,6 @@ export const FACEPLATES: FaceplateTemplate[] = [
colSpan: RACK_COLUMNS / 3,
statusLed: true,
labelBox: { x: 0.13, w: 0.42, y: NAS_BAND },
portSize: 'md',
elements: [
{ kind: 'panel', fill: '#262c35', stroke: '#0d1117' },
nasBays(4),
@@ -391,7 +376,6 @@ export const FACEPLATES: FaceplateTemplate[] = [
colSpan: RACK_COLUMNS / 3,
statusLed: true,
labelBox: { x: 0.13, w: 0.42, y: NAS_BAND },
portSize: 'md',
elements: [
{ kind: 'panel', fill: '#262c35', stroke: '#0d1117' },
nasBays(5),
+95 -11
View File
@@ -93,11 +93,25 @@ interface RackState {
// UI
selectedDeviceId: string | null
selectedRackId: string | null
/** Cable picked in patch mode, so Delete/Backspace can unplug it. */
selectedCableId: string | null
hoveredDeviceId: string | null
cableVisibility: CableVisibility
/** Port-picking mode: click a port, then another, to create a cable. */
/**
* What the visibility was before patch mode forced cables on, so leaving
* patch mode puts the canvas back the way the user had it. Cleared as soon
* as the user picks a visibility by hand — their choice outlives the mode.
*/
cableVisibilityBeforePatch: CableVisibility | null
/** Port-picking mode: drag port to port, or click one then another. */
cableMode: boolean
cableDraft: CableDraft | null
/**
* A patch being dragged out of `cableDraft`'s port. `pointer` is in flow
* coordinates, so the rubber band lives in the same space as the cables.
* `moved` separates a drag from a plain click on the same port.
*/
cableDrag: { pointer: { x: number; y: number } | null; moved: boolean } | null
cableTypeFilter: CableType | 'all'
/**
* Open editor dialogs. Device and rack settings live in modals, so any
@@ -170,11 +184,22 @@ interface RackState {
setViewport: (v: Viewport) => void
selectDevice: (id: string | null) => void
selectRack: (id: string | null) => void
selectCable: (id: string | null) => void
/** Unplug whatever is selected. No-op when nothing is. */
removeSelectedCable: () => void
hoverDevice: (id: string | null) => void
setCableVisibility: (v: CableVisibility) => void
setCableTypeFilter: (t: CableType | 'all') => void
toggleCableMode: () => void
pickPort: (deviceId: string, portId: string) => void
/**
* Press on a port: arms it as the cable source, or — when another port is
* already armed — closes the patch, so click-then-click still works.
*/
startCableDrag: (deviceId: string, portId: string) => void
moveCableDrag: (pointer: { x: number; y: number }) => void
/** Release: patch onto `target`, or drop the draft if the pointer travelled. */
endCableDrag: (target: CableDraft | null) => void
cancelCableDraft: () => void
openDeviceEditor: (deviceId?: string | null) => void
closeDeviceEditor: () => void
@@ -203,10 +228,13 @@ function emptyState() {
editSeq: 0,
selectedDeviceId: null,
selectedRackId: null,
selectedCableId: null,
hoveredDeviceId: null,
cableVisibility: 'hover' as CableVisibility,
cableVisibilityBeforePatch: null,
cableMode: false,
cableDraft: null,
cableDrag: null,
cableTypeFilter: 'all' as const,
networkImportDone: false,
deviceEditor: null,
@@ -615,7 +643,11 @@ export const useRackStore = create<RackState>((set, get) => {
updateCable: (id, patch) =>
edit((s) => ({ cables: s.cables.map((c) => (c.id === id ? { ...c, ...patch } : c)) })),
removeCable: (id) => edit((s) => ({ cables: s.cables.filter((c) => c.id !== id) })),
removeCable: (id) =>
edit((s) => ({
cables: s.cables.filter((c) => c.id !== id),
selectedCableId: s.selectedCableId === id ? null : s.selectedCableId,
})),
importCablesFromNetwork: (hints) => {
const { devices, networkImportDone } = get()
@@ -652,18 +684,35 @@ export const useRackStore = create<RackState>((set, get) => {
// Pan/zoom is persisted, but nudging the view is not an edit worth a Save
// badge — the logical canvas treats the viewport the same way.
setViewport: (v) => set({ viewport: v }),
selectDevice: (id) => set({ selectedDeviceId: id, selectedRackId: null }),
selectRack: (id) => set({ selectedRackId: id, selectedDeviceId: null }),
selectDevice: (id) => set({ selectedDeviceId: id, selectedRackId: null, selectedCableId: null }),
selectRack: (id) => set({ selectedRackId: id, selectedDeviceId: null, selectedCableId: null }),
selectCable: (id) => set({ selectedCableId: id }),
removeSelectedCable: () => {
const id = get().selectedCableId
if (!id) return
get().removeCable(id)
},
hoverDevice: (id) => set({ hoveredDeviceId: id }),
setCableVisibility: (v) => set({ cableVisibility: v }),
setCableVisibility: (v) => set({ cableVisibility: v, cableVisibilityBeforePatch: null }),
setCableTypeFilter: (t) => set({ cableTypeFilter: t }),
toggleCableMode: () =>
set((s) => ({
cableMode: !s.cableMode,
cableDraft: null,
cableVisibility: !s.cableMode ? 'always' : s.cableVisibility,
})),
set((s) => {
const entering = !s.cableMode
return {
cableMode: entering,
cableDraft: null,
cableDrag: null,
selectedCableId: null,
// Patch mode forces every cable on; leaving it hands the canvas back
// to whatever the user was looking at before.
cableVisibility: entering
? 'always'
: (s.cableVisibilityBeforePatch ?? s.cableVisibility),
cableVisibilityBeforePatch: entering ? s.cableVisibility : null,
}
}),
pickPort: (deviceId, portId) => {
const { cableDraft } = get()
@@ -675,7 +724,42 @@ export const useRackStore = create<RackState>((set, get) => {
set({ cableDraft: null })
},
cancelCableDraft: () => set({ cableDraft: null }),
startCableDrag: (deviceId, portId) => {
const { cableDraft } = get()
if (cableDraft) {
const samePort = cableDraft.deviceId === deviceId && cableDraft.portId === portId
// Pressing the armed port again disarms it; pressing another closes the
// patch — that is the click-then-click flow, kept alongside dragging.
if (!samePort) get().addCable(cableDraft, { deviceId, portId })
set({ cableDraft: null, cableDrag: null })
return
}
set({ cableDraft: { deviceId, portId }, cableDrag: { pointer: null, moved: false } })
},
moveCableDrag: (pointer) => {
if (!get().cableDrag) return
set({ cableDrag: { pointer, moved: true } })
},
endCableDrag: (target) => {
const { cableDrag, cableDraft } = get()
if (!cableDrag) return
const onAnotherPort =
target &&
cableDraft &&
(target.deviceId !== cableDraft.deviceId || target.portId !== cableDraft.portId)
if (onAnotherPort) {
get().addCable(cableDraft, target)
set({ cableDraft: null, cableDrag: null })
return
}
// A drag released on nothing is a cancel; a press that never moved is the
// first half of a click-then-click patch, so the source stays armed.
set(cableDrag.moved ? { cableDraft: null, cableDrag: null } : { cableDrag: null })
},
cancelCableDraft: () => set({ cableDraft: null, cableDrag: null }),
openDeviceEditor: (deviceId = null) =>
set({ deviceEditor: { deviceId }, selectedDeviceId: deviceId }),
-2
View File
@@ -112,8 +112,6 @@ export interface FaceplateTemplate {
alwaysShowPorts?: boolean
/** Name band. Ports and artwork must stay clear of it. */
labelBox: LabelBox
/** Port artwork size. Dense panels use 'sm'. */
portSize?: 'sm' | 'md'
/** Devices get a status LED; accessories do not. */
statusLed?: boolean
}