Files
homelable/frontend/src/rack/components/RackFlowNode.tsx
T
Pouzor e2da642554 feat(rack): edit devices in a modal, file rack gear in the Device Inventory
The rack canvas loses its right rail: a mount is now edited in its own dialog,
like a network node, and the canvas keeps the full width.

Devices:
- `RackDeviceModal` adds and edits a mount — label, faceplate, U position,
  height, column, width, status, colour, ports, unmount. Adding offers three
  sources: an existing Device Inventory entry, a new device created here, or a
  rack-only accessory. Opened by `+ Device` in the left rail or a double-click
  on a plate.
- `RackSettingsModal` takes over rack chrome (name, location, capacity, 19"/10",
  numbering, colours, delete), on a double-click of the chassis.
- The left rail's `+ Rack` becomes `+ Device`; `+ Rack` stays in the header.

Inventory:
- Gear created from a rack canvas lands in the Device Inventory with
  `discovery_source: "rack"`, so it inherits the existing search, filters, hide
  and delete instead of a parallel list in the sidebar. New "Rack devices"
  source filter and badge.
- It is never placed on a logical canvas: approve returns 409, bulk-approve
  skips it with `match: "rack"`, and the UI drops the Approve button and reports
  the skip.
- `InventoryTray` becomes `AccessoryTray` — blanks, shelves and cable managers
  only, still a drag source.

Fixes:
- A taller faceplate (or a hand-typed height) no longer silently keeps the old
  geometry when it collides: the device relocates to the nearest slot that takes
  the new size, and only a rack with no such slot refuses the edit. This is why
  a 1U and a 2U plate rendered at the same height and the field looked locked.
- The empty-state buttons sat under `.react-flow__renderer` (z-index 4), so the
  pane swallowed their clicks as a canvas drag.

Tests: rack device/settings modals, empty-state stacking, relocate-vs-refuse in
the store, rack source bucketing, inventory approve gating (frontend + backend).

ha-relevant: maybe
The modal-based editors and the rack store are portable; the inventory tagging
rides on the standalone FastAPI scan routes. Discuss before porting.
2026-08-09 20:35:23 +02:00

330 lines
11 KiB
TypeScript

/** One rack = one React Flow node. The U grid inside is rendered by hand. */
import { useCallback, useState } from 'react'
import { useReactFlow, type NodeProps } from '@xyflow/react'
import { getFaceplate } from '../faceplates'
import {
HEADER_PX,
INTERIOR_TOP_PX,
NUMBER_GUTTER_PX,
RAIL_PX,
U_PX,
columnWidth,
deviceBox,
findSlot,
innerWidth,
interiorLeft,
rackHeight,
rackWidth,
uLabel,
xToCol,
yToU,
} from '../layout'
import { useRackStore } from '../store'
import { useRackPalette } from '../rackTheme'
import { RACK_COLUMNS } from '@/types'
import { Faceplate } from './Faceplate'
import { endDrag, getDragPayload, parseDragPayload, startDrag } from './dragPayload'
interface DropPreview {
uStart: number
uHeight: number
colStart: number
colSpan: number
valid: boolean
}
export function RackFlowNode({ id }: NodeProps) {
const palette = useRackPalette()
const rack = useRackStore((s) => s.racks.find((r) => r.id === id))
const devices = useRackStore((s) => s.devices)
const cables = useRackStore((s) => s.cables)
const selectedDeviceId = useRackStore((s) => s.selectedDeviceId)
const selectedRackId = useRackStore((s) => s.selectedRackId)
const hoveredDeviceId = useRackStore((s) => s.hoveredDeviceId)
const cableMode = useRackStore((s) => s.cableMode)
const cableDraft = useRackStore((s) => s.cableDraft)
const cableVisibility = useRackStore((s) => s.cableVisibility)
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 openDeviceEditor = useRackStore((s) => s.openDeviceEditor)
const openRackEditor = useRackStore((s) => s.openRackEditor)
const mountFromInventory = useRackStore((s) => s.mountFromInventory)
const mountAccessory = useRackStore((s) => s.mountAccessory)
const moveDevice = useRackStore((s) => s.moveDevice)
const { getZoom } = useReactFlow()
const [preview, setPreview] = useState<DropPreview | null>(null)
const mounted = devices.filter((d) => d.rackId === id)
const localPoint = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
const zoom = getZoom() || 1
return { x: (e.clientX - rect.left) / zoom, y: (e.clientY - rect.top) / zoom }
},
[getZoom],
)
const onDragOver = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
if (!rack) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const payload = getDragPayload()
if (!payload) return
const { x, y } = localPoint(e)
const source =
payload.kind === 'device'
? devices.find((d) => d.id === payload.id)
: getFaceplate(payload.faceplateId)
if (!source) return
const { uHeight, colSpan } = source
const desired = {
uStart: Math.max(1, yToU(rack, y) - Math.floor(uHeight / 2)),
uHeight,
colStart: Math.min(xToCol(rack, x), RACK_COLUMNS - colSpan),
colSpan,
}
const slot = findSlot(
rack,
devices,
desired,
payload.kind === 'device' ? payload.id : undefined,
)
setPreview(slot ? { ...slot, valid: true } : { ...desired, valid: false })
},
[rack, devices, localPoint],
)
const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
if (!rack) return
e.preventDefault()
const payload = parseDragPayload(e.dataTransfer)
setPreview(null)
endDrag()
if (!payload) return
const { x, y } = localPoint(e)
const col = xToCol(rack, x)
if (payload.kind === 'inventory') {
mountFromInventory(payload.id, rack.id, { uStart: yToU(rack, y), colStart: col })
} else if (payload.kind === 'accessory') {
mountAccessory(payload.id, rack.id, { uStart: yToU(rack, y), colStart: col })
} else {
const device = devices.find((d) => d.id === payload.id)
if (!device) return
const desired = {
uStart: Math.max(1, yToU(rack, y) - Math.floor(device.uHeight / 2)),
uHeight: device.uHeight,
colStart: Math.min(col, RACK_COLUMNS - device.colSpan),
colSpan: device.colSpan,
}
const slot = findSlot(rack, devices, desired, device.id)
if (slot) moveDevice(device.id, rack.id, slot)
}
},
[rack, devices, localPoint, mountFromInventory, mountAccessory, moveDevice],
)
if (!rack) return null
const width = rackWidth(rack)
const height = rackHeight(rack)
const gutter = rack.style.showNumbers ? NUMBER_GUTTER_PX : 0
const col = columnWidth(rack)
const patchedPorts = new Map<string, string>()
for (const cable of cables) {
patchedPorts.set(cable.from.portId, cable.color)
patchedPorts.set(cable.to.portId, cable.color)
}
const cablesOn = cableVisibility === 'always' || cableMode
// Ports of non-patch gear stay hidden until the plate is focused, or until
// cables are on globally — a cable must never end on an invisible port.
const revealAll = cablesOn
return (
<div
className="rack-node"
style={{ width, height, position: 'relative' }}
onDragOver={onDragOver}
onDragLeave={() => setPreview(null)}
onDrop={onDrop}
onClick={() => selectRack(rack.id)}
onDoubleClick={() => openRackEditor(rack.id)}
>
{/* Chassis */}
<div
style={{
position: 'absolute',
inset: 0,
left: gutter,
background: rack.style.frame,
border: `2px solid ${selectedRackId === rack.id ? palette.accent : palette.plateOutline}`,
borderRadius: 6,
boxShadow: rack.style.enclosed ? 'inset 0 0 0 4px rgba(0,0,0,0.35)' : undefined,
}}
/>
{/* Interior */}
<div
style={{
position: 'absolute',
left: interiorLeft(rack),
top: INTERIOR_TOP_PX,
width: innerWidth(rack),
height: rack.uHeight * U_PX,
background: rack.style.interior,
}}
/>
{/* Rails */}
{[interiorLeft(rack) - RAIL_PX, interiorLeft(rack) + innerWidth(rack)].map((x) => (
<div
key={x}
style={{
position: 'absolute',
left: x,
top: INTERIOR_TOP_PX,
width: RAIL_PX,
height: rack.uHeight * U_PX,
background: rack.style.rail,
backgroundImage:
'repeating-linear-gradient(to bottom, rgba(0,0,0,0.45) 0 2px, transparent 2px 8px)',
}}
/>
))}
{/* U number gutter + slot separators */}
{Array.from({ length: rack.uHeight }, (_, i) => {
const u = rack.uHeight - i
return (
<div key={u}>
{rack.style.showNumbers && (
<div
style={{
position: 'absolute',
left: 0,
top: INTERIOR_TOP_PX + i * U_PX,
width: gutter - 4,
height: U_PX,
fontSize: 9,
lineHeight: `${U_PX}px`,
textAlign: 'right',
color: palette.muted,
fontFamily: 'JetBrains Mono, monospace',
}}
>
{uLabel(rack, u)}
</div>
)}
<div
style={{
position: 'absolute',
left: interiorLeft(rack),
top: INTERIOR_TOP_PX + i * U_PX,
width: innerWidth(rack),
height: 1,
background: 'rgba(255,255,255,0.04)',
}}
/>
</div>
)
})}
{/* Drop preview */}
{preview && (
<div
style={{
position: 'absolute',
left: interiorLeft(rack) + preview.colStart * col,
top: INTERIOR_TOP_PX + (rack.uHeight - preview.uStart - preview.uHeight + 1) * U_PX,
width: preview.colSpan * col,
height: preview.uHeight * U_PX,
border: `1.5px dashed ${preview.valid ? palette.status.online : palette.status.offline}`,
background: preview.valid ? 'rgba(57,211,83,0.12)' : 'rgba(248,81,73,0.12)',
borderRadius: 3,
pointerEvents: 'none',
}}
/>
)}
{/* Mounted gear */}
{mounted.map((device) => {
const box = deviceBox(rack, device)
const isDraftDevice = cableDraft?.deviceId === device.id
const focused = hoveredDeviceId === device.id || selectedDeviceId === device.id
return (
<div
key={device.id}
className="nodrag"
draggable={!cableMode}
onDragStart={(e) => startDrag(e.dataTransfer, { kind: 'device', id: device.id })}
onDragEnd={endDrag}
onClick={(e) => {
e.stopPropagation()
selectDevice(device.id)
}}
onDoubleClick={(e) => {
e.stopPropagation()
openDeviceEditor(device.id)
}}
onMouseEnter={() => hoverDevice(device.id)}
onMouseLeave={() => hoverDevice(null)}
style={{
position: 'absolute',
left: box.x,
top: box.y,
width: box.width,
height: box.height,
cursor: cableMode ? 'default' : 'grab',
}}
title={`${device.label} · ${device.uHeight}U · U${device.uStart}`}
>
<Faceplate
faceplateId={device.faceplateId}
label={device.label}
status={device.status}
ports={device.ports}
width={box.width}
height={box.height}
colorOverride={device.color}
selected={selectedDeviceId === device.id}
revealed={revealAll || focused}
transparent={cablesOn && !focused}
interactivePorts={cableMode}
patchedPorts={patchedPorts}
draftPortId={isDraftDevice ? cableDraft.portId : null}
onPortClick={(portId) => pickPort(device.id, portId)}
/>
</div>
)
})}
{/* Name plate — inside the chassis so it never gets clipped by fitView */}
<div
style={{
position: 'absolute',
left: gutter + 12,
top: 6,
height: HEADER_PX,
lineHeight: `${HEADER_PX}px`,
fontSize: 12,
color: palette.text,
fontFamily: 'Inter, system-ui, sans-serif',
pointerEvents: 'none',
}}
>
{rack.name}
{rack.location && <span style={{ color: palette.muted }}> · {rack.location}</span>}
<span style={{ color: palette.muted }}> · {rack.uHeight}U</span>
</div>
</div>
)
}