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:
committed by
Pouzor - Rémy Jardient
parent
3138cda378
commit
ff740f7466
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user