diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 04733e7..faadefb 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -38,6 +38,15 @@ def _ip_tokens(ip: str | None) -> list[str]: return [t.strip() for t in ip.split(",") if t.strip()] if ip else [] +def _is_rack_only(device: PendingDevice) -> bool: + """True for inventory entries created from a rack canvas. + + They describe a mount — a patch panel, a shelf, a chassis — not a host to + document on a logical canvas, so they are never approved onto one. + """ + return device.discovery_source == "rack" or "rack" in (device.discovery_sources or []) + + def _is_wireless(node_type: str | None) -> bool: """Zigbee + Z-Wave mesh devices share online status / no ICMP check.""" return node_type in _ZIGBEE_TYPES or node_type in _ZWAVE_TYPES @@ -332,8 +341,8 @@ async def create_pending( vendor=body.vendor, properties=body.properties, status="pending", - discovery_source="manual", - discovery_sources=["manual"], + discovery_source=body.discovery_source, + discovery_sources=[body.discovery_source], ) db.add(device) await db.commit() @@ -410,6 +419,14 @@ async def bulk_approve_devices( approved_devices: list[PendingDevice] = [] skipped_devices: list[dict[str, Any]] = [] for device in devices: + # Rack-only gear belongs to a rack canvas, never to a logical one. + if _is_rack_only(device): + skipped_devices.append({ + "device_id": device.id, + "label": device.hostname or device.friendly_name or "device", + "match": "rack", "value": "rack device", "_ref": None, + }) + continue # Record which identifier collided so the caller can explain each skip # (and, for existing on-canvas nodes, link to the node already there). ip_hit = next((t for t in _ip_tokens(device.ip) if t in placed_ips), None) @@ -578,6 +595,11 @@ async def approve_device( # device is off-limits here. if device.status == "hidden": raise HTTPException(status_code=409, detail="Device is hidden") + if _is_rack_only(device): + raise HTTPException( + status_code=409, + detail="Rack devices cannot be placed on a logical canvas", + ) wireless = _is_wireless(node_data.type) # A device already on THIS design (matched by ieee, ip OR mac) is NOT placed diff --git a/backend/app/schemas/scan.py b/backend/app/schemas/scan.py index 52bdbbb..018567a 100644 --- a/backend/app/schemas/scan.py +++ b/backend/app/schemas/scan.py @@ -47,12 +47,22 @@ class PendingDeviceResponse(BaseModel): model_config = {"from_attributes": True} +"""Sources a hand-made inventory entry may claim. + +``rack`` marks gear created from a rack canvas. Those rows live in the Device +Inventory like any other, but they describe a physical mount rather than a host +on the network, so they are never placed on a logical canvas. +""" +MANUAL_SOURCES = {"manual", "rack"} + + class PendingDeviceCreate(BaseModel): """Manually add an entry to the Device Inventory. Used when the user documents hardware no scan can find — a dumb switch, a patch panel, a machine that is powered off. Lands with - `discovery_source="manual"` so the inventory filters can tell it apart. + `discovery_source="manual"` (or `"rack"`) so the inventory filters can tell + it apart. """ hostname: str @@ -62,6 +72,14 @@ class PendingDeviceCreate(BaseModel): model: str | None = None vendor: str | None = None properties: list[Any] = [] + discovery_source: str = "manual" + + @field_validator("discovery_source") + @classmethod + def _known_source(cls, v: str) -> str: + if v not in MANUAL_SOURCES: + raise ValueError(f"discovery_source must be one of {sorted(MANUAL_SOURCES)}") + return v class ScanRunResponse(BaseModel): diff --git a/backend/tests/test_racks.py b/backend/tests/test_racks.py index b4d72f4..174e052 100644 --- a/backend/tests/test_racks.py +++ b/backend/tests/test_racks.py @@ -274,6 +274,72 @@ class TestInventory: assert any(d["id"] == created["id"] for d in listed) +class TestRackSourcedInventory: + """Gear created from a rack canvas shares the Device Inventory, but never a + logical canvas: it documents a mount, not a host.""" + + async def _rack_device(self, client: AsyncClient, headers, hostname: str = "patch-house"): + return ( + await client.post( + "/api/v1/scan/pending", + json={"hostname": hostname, "discovery_source": "rack"}, + headers=headers, + ) + ).json() + + async def test_records_the_rack_source(self, client: AsyncClient, headers): + created = await self._rack_device(client, headers) + assert created["discovery_source"] == "rack" + assert created["discovery_sources"] == ["rack"] + + async def test_rejects_an_unknown_source(self, client: AsyncClient, headers): + res = await client.post( + "/api/v1/scan/pending", + json={"hostname": "spoof", "discovery_source": "zigbee"}, + headers=headers, + ) + assert res.status_code == 422 + + async def test_approve_refuses_rack_gear(self, client: AsyncClient, headers): + created = await self._rack_device(client, headers) + res = await client.post( + f"/api/v1/scan/pending/{created['id']}/approve", + json={"type": "generic", "label": "patch-house"}, + headers=headers, + ) + assert res.status_code == 409 + assert "rack" in res.json()["detail"].lower() + + async def test_bulk_approve_skips_rack_gear(self, client: AsyncClient, headers): + rack_device = await self._rack_device(client, headers) + scanned = ( + await client.post( + "/api/v1/scan/pending", + json={"hostname": "nuc", "ip": "192.168.1.77", "suggested_type": "server"}, + headers=headers, + ) + ).json() + + res = await client.post( + "/api/v1/scan/pending/bulk-approve", + json={"device_ids": [rack_device["id"], scanned["id"]]}, + headers=headers, + ) + body = res.json() + assert body["approved"] == 1 + assert body["device_ids"] == [scanned["id"]] + skipped = next(s for s in body["skipped_devices"] if s["device_id"] == rack_device["id"]) + assert skipped["match"] == "rack" + + async def test_rack_gear_is_offered_to_the_rack_inventory(self, client: AsyncClient, headers): + design_id = await _design(client, headers) + created = await self._rack_device(client, headers, hostname="blank-panel") + items = ( + await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers) + ).json()["items"] + assert any(i["id"] == created["id"] for i in items) + + class TestDesignLifecycle: async def test_deleting_a_design_removes_its_rack_rows(self, client: AsyncClient, headers): keeper = await _design(client, headers, name="Keeper", design_type="network") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 65f415a..457ea81 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -53,7 +53,6 @@ import { DEMO_SCAN_RUNS, DEMO_PENDING_DEVICES } from '@/walkthrough/demoTourData import { useStatusPolling } from '@/hooks/useStatusPolling' import { bootstrapAuth } from '@/auth/bootstrap' import { RackCanvas } from '@/rack/components/RackCanvas' -import { RackInspector } from '@/rack/components/RackInspector' import { useRackStore } from '@/rack/store' import type { NodeData, EdgeData, CustomStyleDef, DesignType, FloorMapConfig, NodeType } from '@/types' import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types' @@ -1050,10 +1049,10 @@ export default function App() { /> )} - {isRackDesign ? ( - - ) : ( - (selectedNodeId || selectedNodeIds.length > 1) && + {/* Rack designs have no right rail: a mount is edited in its own + modal, so the canvas keeps the full width. */} + {!isRackDesign && (selectedNodeId || selectedNodeIds.length > 1) && ( + )} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2478b75..feb271f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -117,6 +117,8 @@ export const scanApi = { suggested_type?: string | null model?: string | null vendor?: string | null + /** "manual" (default) or "rack" for gear created from a rack canvas. */ + discovery_source?: 'manual' | 'rack' }) => api.post<{ id: string; hostname: string | null }>('/scan/pending', data), hidden: () => api.get('/scan/hidden'), runs: () => api.get('/scan/runs'), diff --git a/frontend/src/components/modals/PendingDeviceModal.tsx b/frontend/src/components/modals/PendingDeviceModal.tsx index 8f98975..1ea10b3 100644 --- a/frontend/src/components/modals/PendingDeviceModal.tsx +++ b/frontend/src/components/modals/PendingDeviceModal.tsx @@ -1,6 +1,7 @@ import { Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { isRackDevice } from '@/utils/pendingSources' import type { NodeProperty } from '@/types' interface Service { @@ -99,6 +100,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle const isZigbee = device.discovery_source === 'zigbee' + const rackOnly = isRackDevice(device) const titleLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'Pending device' const handleApprove = () => { onApprove(device) } @@ -178,13 +180,17 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor {/* Actions */}
- + {/* Rack gear is mounted from a rack canvas, never approved onto a + logical one — so it gets no Approve button at all. */} + {!rackOnly && ( + + )} +
setDraftName(e.target.value)} - placeholder="Device name" - aria-label="Device name" - className="w-full rounded border border-border bg-[#0d1117] px-2 py-1 text-xs text-foreground outline-none focus:border-[#00d4ff]" - /> -
- - -
- - ) : ( - - )} - -

- Accessories -

-
    - {accessories.map((plate) => ( -
  • - startDrag(e.dataTransfer, { - kind: 'accessory', - id: plate.id, - faceplateId: plate.id, - }) - } - onDragEnd={endDrag} - className="cursor-grab rounded border border-border bg-[#161b22] px-2 py-1.5 text-xs hover:border-[#00d4ff]" - > - {plate.label} -
  • - ))} -
- - {racked.length > 0 && ( - <> -

- Racked ({racked.length}) -

-
    - {racked.map((item) => ( -
  • - - {item.label} -
  • - ))} -
- - )} - - ) -} diff --git a/frontend/src/rack/components/RackCanvas.tsx b/frontend/src/rack/components/RackCanvas.tsx index 4a29d6a..bbda87a 100644 --- a/frontend/src/rack/components/RackCanvas.tsx +++ b/frontend/src/rack/components/RackCanvas.tsx @@ -20,7 +20,9 @@ import { rackHeight, rackWidth } from '../layout' import { useRackStore } from '../store' import { useRackPalette } from '../rackTheme' import { CableLayer } from './CableLayer' +import { RackDeviceModal } from './RackDeviceModal' import { RackFlowNode } from './RackFlowNode' +import { RackSettingsModal } from './RackSettingsModal' const nodeTypes = { rack: RackFlowNode } @@ -86,6 +88,7 @@ export function RackCanvas() { } return ( + <> {!loading && racks.length === 0 && ( -
+ // z-10 clears .react-flow__renderer (z-index 4); without it the pane sits + // on top and swallows the clicks as a canvas drag. +

This rack canvas is empty.

+ ))} +
+ + )} + + {showInventoryPicker && ( + + + {unracked.length === 0 && ( +

+ Everything in the inventory is already racked — create a new device instead. +

+ )} +
+ )} + + {!isEdit && source === 'new' && ( + + setNewIp(e.target.value)} + placeholder="192.168.1.10" + /> + + )} + + {!isEdit && racks.length > 1 && ( + + + + )} + + + setLabel(e.target.value)} + placeholder={source === 'accessory' ? getFaceplate(faceplateId).label : 'Device name'} + /> + + + + + {plateChanged && isEdit && ( +

+ Changing the plate replaces its ports and drops their patches. +

+ )} +
+ +
+ + setUStart(Number(e.target.value) || 1)} + /> + + + setUHeight(Number(e.target.value) || 1)} + /> + + + setColStart(Number(e.target.value) || 0)} + /> + + + + +
+ + + + + + +
+ setColor(e.target.value)} + /> + +
+
+ +
+ + +
+
    + {ports.map((port) => ( +
  • + + setLocalPorts((list) => + list.map((p) => (p.id === port.id ? { ...p, label: e.target.value } : p)), + ) + } + /> + + +
  • + ))} + {ports.length === 0 && ( +
  • No port on this plate.
  • + )} +
+ +
+ {isEdit && ( + + )} +
+ + +
+
+ + + + ) +} diff --git a/frontend/src/rack/components/RackFlowNode.tsx b/frontend/src/rack/components/RackFlowNode.tsx index 9741ece..d304d55 100644 --- a/frontend/src/rack/components/RackFlowNode.tsx +++ b/frontend/src/rack/components/RackFlowNode.tsx @@ -49,6 +49,8 @@ export function RackFlowNode({ id }: NodeProps) { 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) @@ -156,6 +158,7 @@ export function RackFlowNode({ id }: NodeProps) { onDragLeave={() => setPreview(null)} onDrop={onDrop} onClick={() => selectRack(rack.id)} + onDoubleClick={() => openRackEditor(rack.id)} > {/* Chassis */}
{ + e.stopPropagation() + openDeviceEditor(device.id) + }} onMouseEnter={() => hoverDevice(device.id)} onMouseLeave={() => hoverDevice(null)} style={{ diff --git a/frontend/src/rack/components/RackInspector.tsx b/frontend/src/rack/components/RackInspector.tsx deleted file mode 100644 index f0b161b..0000000 --- a/frontend/src/rack/components/RackInspector.tsx +++ /dev/null @@ -1,328 +0,0 @@ -/** Right rail: settings for the selected rack or the selected mounted device. */ -import { faceplateGroups } from '../faceplates' -import { RACK_COLUMNS } from '@/types' -import { freeUnits } from '../layout' -import { useRackStore } from '../store' -import { CABLE_TYPE_LABELS } from '../rackDefaults' -import type { CableType, DeviceStatus, PortType, RackNumbering, RackWidthStandard } from '@/types' - -const inputClass = - 'w-full rounded border border-[#21262d] bg-[#0d1117] px-2 py-1 text-sm text-[#c9d1d9] outline-none focus:border-[#00d4ff]' -const labelClass = 'mb-1 block text-[11px] uppercase tracking-wide text-[#6e7681]' - -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
- {label} - {children} -
- ) -} - -function RackSettings({ rackId }: { rackId: string }) { - const rack = useRackStore((s) => s.racks.find((r) => r.id === rackId)) - const devices = useRackStore((s) => s.devices) - const updateRack = useRackStore((s) => s.updateRack) - const updateRackStyle = useRackStore((s) => s.updateRackStyle) - const removeRack = useRackStore((s) => s.removeRack) - - if (!rack) return null - const used = rack.uHeight - freeUnits(rack, devices) - - return ( -
-

Rack settings

- - - updateRack(rack.id, { name: e.target.value })} - /> - - - updateRack(rack.id, { location: e.target.value })} - /> - - - updateRack(rack.id, { uHeight: Number(e.target.value) || 1 })} - /> - - - - - - - - -

- Style -

- {(['frame', 'rail', 'interior'] as const).map((key) => ( - - updateRackStyle(rack.id, { [key]: e.target.value })} - /> - - ))} - - - - -
- ) -} - -function DeviceSettings({ deviceId }: { deviceId: string }) { - const device = useRackStore((s) => s.devices.find((d) => d.id === deviceId)) - const cables = useRackStore((s) => s.cables) - const updateDevice = useRackStore((s) => s.updateDevice) - const applyFaceplate = useRackStore((s) => s.applyFaceplate) - const unmountDevice = useRackStore((s) => s.unmountDevice) - const addPort = useRackStore((s) => s.addPort) - const updatePort = useRackStore((s) => s.updatePort) - const removePort = useRackStore((s) => s.removePort) - - if (!device) return null - - const cableOf = (portId: string) => - cables.find( - (c) => - (c.from.deviceId === device.id && c.from.portId === portId) || - (c.to.deviceId === device.id && c.to.portId === portId), - ) - - return ( -
-

{device.label}

-

- {device.deviceId - ? device.nodeId - ? 'From the Device Inventory · on a logical canvas' - : 'From the Device Inventory' - : 'Rack-only accessory'} -

- - - updateDevice(device.id, { label: e.target.value })} - /> - - - - - - -
- - updateDevice(device.id, { uStart: Number(e.target.value) || 1 })} - /> - - - updateDevice(device.id, { uHeight: Number(e.target.value) || 1 })} - /> - - - updateDevice(device.id, { colStart: Number(e.target.value) || 0 })} - /> - - - - -
- - - - - - - updateDevice(device.id, { color: e.target.value })} - /> - - -
-

- Ports ({device.ports.length}) -

- -
-
    - {device.ports.map((port) => { - const cable = cableOf(port.id) - return ( -
  • - updatePort(device.id, port.id, { label: e.target.value })} - /> - - - -
  • - ) - })} -
- - -
- ) -} - -export function RackInspector() { - const selectedDeviceId = useRackStore((s) => s.selectedDeviceId) - const selectedRackId = useRackStore((s) => s.selectedRackId) - - return ( - - ) -} diff --git a/frontend/src/rack/components/RackSettingsModal.tsx b/frontend/src/rack/components/RackSettingsModal.tsx new file mode 100644 index 0000000..a6fbf94 --- /dev/null +++ b/frontend/src/rack/components/RackSettingsModal.tsx @@ -0,0 +1,152 @@ +/** + * Rack settings — name, capacity, chrome. + * + * Same reasoning as `RackDeviceModal`: the rack canvas has no right rail, so the + * settings live in a dialog. Opened by double-clicking a rack's chassis. + */ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { useRackStore } from '../store' +import { freeUnits } from '../layout' +import type { RackNumbering, RackWidthStandard } from '@/types' + +const inputClass = + 'w-full rounded border border-[#30363d] bg-[#21262d] px-2 py-1 text-sm text-foreground outline-none focus:border-[#00d4ff]' + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} + +export function RackSettingsModal() { + const rackId = useRackStore((s) => s.rackEditorId) + const close = useRackStore((s) => s.closeRackEditor) + const rack = useRackStore((s) => s.racks.find((r) => r.id === s.rackEditorId)) + const devices = useRackStore((s) => s.devices) + const updateRack = useRackStore((s) => s.updateRack) + const updateRackStyle = useRackStore((s) => s.updateRackStyle) + const removeRack = useRackStore((s) => s.removeRack) + + if (!rackId || !rack) return null + const used = rack.uHeight - freeUnits(rack, devices) + + return ( + !o && close()}> + + + Rack settings + + +
+ + updateRack(rack.id, { name: e.target.value })} + /> + + + updateRack(rack.id, { location: e.target.value })} + /> + + + updateRack(rack.id, { uHeight: Number(e.target.value) || 1 })} + /> + + +
+ + + + + + +
+ +
+ {(['frame', 'rail', 'interior'] as const).map((key) => ( + + updateRackStyle(rack.id, { [key]: e.target.value })} + /> + + ))} +
+ + + + +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/rack/faceplates.ts b/frontend/src/rack/faceplates.ts index d1f5a5a..cca42d2 100644 --- a/frontend/src/rack/faceplates.ts +++ b/frontend/src/rack/faceplates.ts @@ -386,7 +386,7 @@ export function getFaceplate(id: string): FaceplateTemplate { * * Keyed on the device's discovery `suggested_type`, which is free-form — an * unknown or missing type falls back to a plain 1U server plate, which the user - * can swap in the inspector. + * can swap in the device modal. */ const FACEPLATE_BY_DEVICE_TYPE: Record = { server: 'server-1u-bays', diff --git a/frontend/src/rack/store.ts b/frontend/src/rack/store.ts index 7a8a64e..26d4646 100644 --- a/frontend/src/rack/store.ts +++ b/frontend/src/rack/store.ts @@ -99,6 +99,13 @@ interface RackState { cableMode: boolean cableDraft: CableDraft | null cableTypeFilter: CableType | 'all' + /** + * Open editor dialogs. Device and rack settings live in modals, so any + * component (sidebar, canvas, header) can open them without prop drilling. + * `deviceId: null` means "add a device". + */ + deviceEditor: { deviceId: string | null } | null + rackEditorId: string | null // Persistence loadDesign: (designId: string) => Promise @@ -117,20 +124,35 @@ interface RackState { removeRack: (id: string) => void // Devices - /** Mount an inventory entry. Returns the new device id, or null if no room. */ - mountFromInventory: (inventoryId: string, rackId: string, desired: Partial) => string | null + /** + * Mount an inventory entry. Returns the new device id, or null if no room. + * `faceplateId` overrides the one suggested from the discovery type. + */ + mountFromInventory: ( + inventoryId: string, + rackId: string, + desired: Partial & { faceplateId?: string }, + ) => string | null /** Mount a rack-only accessory (blank panel, shelf…). */ mountAccessory: (faceplateId: string, rackId: string, desired: Partial) => string | null moveDevice: (deviceId: string, rackId: string, desired: Placement) => boolean - updateDevice: (id: string, patch: Partial>) => void + /** + * Patch a mounted device. A geometry change that no longer fits where it is + * relocates to the nearest free slot rather than being dropped; `false` means + * the rack had no room at all and nothing changed. + */ + updateDevice: (id: string, patch: Partial>) => boolean /** Unmounts from the rack. The inventory entry survives. */ unmountDevice: (id: string) => void - applyFaceplate: (deviceId: string, faceplateId: string) => void + /** Swaps the plate, resizing to its U height — relocating if need be. */ + applyFaceplate: (deviceId: string, faceplateId: string) => boolean // Ports addPort: (deviceId: string, port: Omit) => void updatePort: (deviceId: string, portId: string, patch: Partial>) => void removePort: (deviceId: string, portId: string) => void + /** Replace the whole port list, keeping ids of ports that carry one. */ + setPorts: (deviceId: string, ports: (Port | Omit)[]) => void // Cables addCable: ( @@ -154,6 +176,10 @@ interface RackState { toggleCableMode: () => void pickPort: (deviceId: string, portId: string) => void cancelCableDraft: () => void + openDeviceEditor: (deviceId?: string | null) => void + closeDeviceEditor: () => void + openRackEditor: (rackId: string) => void + closeRackEditor: () => void /** Seed the demo rack. Used by tests and by the empty-canvas sample button. */ loadDemo: () => void reset: () => void @@ -183,6 +209,8 @@ function emptyState() { cableDraft: null, cableTypeFilter: 'all' as const, networkImportDone: false, + deviceEditor: null, + rackEditorId: null, } } @@ -300,6 +328,9 @@ export const useRackStore = create((set, get) => { ip, mac, suggested_type: type, + // Files it under "Rack devices" in the Device Inventory, and keeps + // it off the logical canvases. + discovery_source: 'rack', }) item.id = res.data.id } catch { @@ -353,6 +384,8 @@ export const useRackStore = create((set, get) => { ), inventory: withRackedFlags(s.inventory, devices), selectedRackId: s.selectedRackId === id ? null : s.selectedRackId, + rackEditorId: s.rackEditorId === id ? null : s.rackEditorId, + deviceEditor: s.deviceEditor && doomed.has(s.deviceEditor.deviceId ?? '') ? null : s.deviceEditor, } }), @@ -363,7 +396,7 @@ export const useRackStore = create((set, get) => { const item = inventory.find((i) => i.id === inventoryId) if (!rack || !item) return null - const plate = getFaceplate(item.suggestedFaceplateId) + const plate = getFaceplate(desired.faceplateId ?? item.suggestedFaceplateId) const slot = findSlot(rack, devices, { uStart: desired.uStart ?? 1, uHeight: desired.uHeight ?? plate.uHeight, @@ -434,20 +467,29 @@ export const useRackStore = create((set, get) => { return true }, - updateDevice: (id, patch) => - edit((s) => { - const device = s.devices.find((d) => d.id === id) - const rack = device && s.racks.find((r) => r.id === (patch.rackId ?? device.rackId)) - if (!device || !rack) return {} - const next = { ...device, ...patch } - const geometryChanged = - next.uStart !== device.uStart || - next.uHeight !== device.uHeight || - next.colStart !== device.colStart || - next.colSpan !== device.colSpan - if (geometryChanged && !canPlace(rack, s.devices, next, id)) return {} - return { devices: s.devices.map((d) => (d.id === id ? next : d)) } - }), + updateDevice: (id, patch) => { + const { devices, racks } = get() + const device = devices.find((d) => d.id === id) + const rack = device && racks.find((r) => r.id === (patch.rackId ?? device.rackId)) + if (!device || !rack) return false + + const next = { ...device, ...patch } + const geometryChanged = + next.uStart !== device.uStart || + next.uHeight !== device.uHeight || + next.colStart !== device.colStart || + next.colSpan !== device.colSpan + if (geometryChanged && !canPlace(rack, devices, next, id)) { + // Growing a device usually collides with whatever sits above it. Move it + // to the nearest slot that takes the new size instead of ignoring the + // edit — a silent no-op reads as "the field is locked". + const slot = findSlot(rack, devices, next, id) + if (!slot) return false + Object.assign(next, slot) + } + edit((s) => ({ devices: s.devices.map((d) => (d.id === id ? next : d)) })) + return true + }, unmountDevice: (id) => edit((s) => { @@ -457,38 +499,40 @@ export const useRackStore = create((set, get) => { cables: s.cables.filter((c) => c.from.deviceId !== id && c.to.deviceId !== id), inventory: withRackedFlags(s.inventory, devices), selectedDeviceId: s.selectedDeviceId === id ? null : s.selectedDeviceId, + deviceEditor: s.deviceEditor?.deviceId === id ? null : s.deviceEditor, } }), - applyFaceplate: (deviceId, faceplateId) => - edit((s) => { - const device = s.devices.find((d) => d.id === deviceId) - const rack = device && s.racks.find((r) => r.id === device.rackId) - if (!device || !rack) return {} - const plate = getFaceplate(faceplateId) - const resized = { - ...device, - faceplateId, - uHeight: plate.uHeight, - colSpan: plate.colSpan, - colStart: Math.min(device.colStart, RACK_COLUMNS - plate.colSpan), - ports: withIds(plate.ports), - } - if (!canPlace(rack, s.devices, resized, deviceId)) { - // Keep the plate but leave the geometry alone when it no longer fits. - return { - devices: s.devices.map((d) => - d.id === deviceId ? { ...d, faceplateId, ports: withIds(plate.ports) } : d, - ), - } - } - return { - devices: s.devices.map((d) => (d.id === deviceId ? resized : d)), - cables: s.cables.filter( - (c) => c.from.deviceId !== deviceId && c.to.deviceId !== deviceId, - ), - } - }), + applyFaceplate: (deviceId, faceplateId) => { + const { devices, racks } = get() + const device = devices.find((d) => d.id === deviceId) + const rack = device && racks.find((r) => r.id === device.rackId) + if (!device || !rack) return false + + const plate = getFaceplate(faceplateId) + const resized = { + ...device, + faceplateId, + uHeight: plate.uHeight, + colSpan: plate.colSpan, + colStart: Math.min(device.colStart, RACK_COLUMNS - plate.colSpan), + ports: withIds(plate.ports), + } + if (!canPlace(rack, devices, resized, deviceId)) { + // A taller plate almost always overlaps the neighbour above. Relocate + // rather than keep the old height — otherwise swapping a 1U plate for a + // 2U one looks like it did nothing. + const slot = findSlot(rack, devices, resized, deviceId) + if (!slot) return false + Object.assign(resized, slot) + } + edit((s) => ({ + devices: s.devices.map((d) => (d.id === deviceId ? resized : d)), + // The old ports are gone, so their patches have no endpoint left. + cables: s.cables.filter((c) => c.from.deviceId !== deviceId && c.to.deviceId !== deviceId), + })) + return true + }, // --- Ports -------------------------------------------------------------- addPort: (deviceId, port) => @@ -519,6 +563,20 @@ export const useRackStore = create((set, get) => { ), })), + setPorts: (deviceId, ports) => + edit((s) => { + const next: Port[] = ports.map((p) => ('id' in p ? p : { ...p, id: generateUUID() })) + const kept = new Set(next.map((p) => p.id)) + return { + devices: s.devices.map((d) => (d.id === deviceId ? { ...d, ports: next } : d)), + cables: s.cables.filter( + (c) => + !(c.from.deviceId === deviceId && !kept.has(c.from.portId)) && + !(c.to.deviceId === deviceId && !kept.has(c.to.portId)), + ), + } + }), + // --- Cables ------------------------------------------------------------- addCable: (from, to, options) => { const { devices, cables } = get() @@ -619,6 +677,12 @@ export const useRackStore = create((set, get) => { cancelCableDraft: () => set({ cableDraft: null }), + openDeviceEditor: (deviceId = null) => + set({ deviceEditor: { deviceId }, selectedDeviceId: deviceId }), + closeDeviceEditor: () => set({ deviceEditor: null }), + openRackEditor: (rackId) => set({ rackEditorId: rackId, selectedRackId: rackId }), + closeRackEditor: () => set({ rackEditorId: null }), + loadDemo: () => { const devices = demoDevices() set({ diff --git a/frontend/src/utils/__tests__/pendingSources.test.ts b/frontend/src/utils/__tests__/pendingSources.test.ts index a75b862..a0f862e 100644 --- a/frontend/src/utils/__tests__/pendingSources.test.ts +++ b/frontend/src/utils/__tests__/pendingSources.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { sourceBuckets, orderedSources } from '../pendingSources' +import { sourceBuckets, orderedSources, isRackDevice } from '../pendingSources' import type { PendingDevice } from '@/components/modals/PendingDeviceModal' function device(overrides: Partial = {}): PendingDevice { @@ -47,3 +47,22 @@ describe('orderedSources', () => { expect(orderedSources(device({ discovery_sources: ['proxmox', 'arp'] }))).toEqual(['ip', 'proxmox']) }) }) + +describe('isRackDevice', () => { + it('recognises gear created from a rack canvas', () => { + expect(isRackDevice(device({ discovery_sources: ['rack'] }))).toBe(true) + expect(isRackDevice(device({ discovery_source: 'rack' }))).toBe(true) + }) + + it('leaves scanned and hand-added devices alone', () => { + expect(isRackDevice(device({ discovery_sources: ['arp'] }))).toBe(false) + expect(isRackDevice(device({ discovery_source: 'manual' }))).toBe(false) + expect(isRackDevice(device())).toBe(false) + }) + + it('does not fold rack into the ip bucket', () => { + // Rack gear has to be filterable on its own — that is the point of the + // "Rack devices" section in the inventory. + expect([...sourceBuckets(device({ discovery_source: 'rack' }))]).toEqual(['rack']) + }) +}) diff --git a/frontend/src/utils/pendingSources.ts b/frontend/src/utils/pendingSources.ts index a2e4f1b..51323f1 100644 --- a/frontend/src/utils/pendingSources.ts +++ b/frontend/src/utils/pendingSources.ts @@ -7,17 +7,18 @@ */ import type { PendingDevice } from '@/components/modals/PendingDeviceModal' -export type SourceBucket = 'ip' | 'zigbee' | 'zwave' | 'proxmox' +export type SourceBucket = 'ip' | 'zigbee' | 'zwave' | 'proxmox' | 'rack' export const SOURCE_META: Record = { zigbee: { color: '#00d4ff', label: 'ZIGBEE' }, zwave: { color: '#ff6e00', label: 'Z-WAVE' }, proxmox: { color: '#e57000', label: 'PROXMOX' }, ip: { color: '#a855f7', label: 'IP' }, + rack: { color: '#39d353', label: 'RACK' }, } // Stable badge order (IP first — it's the primary discovery path). -const SOURCE_ORDER: SourceBucket[] = ['ip', 'proxmox', 'zigbee', 'zwave'] +const SOURCE_ORDER: SourceBucket[] = ['ip', 'proxmox', 'zigbee', 'zwave', 'rack'] /** Every source bucket that has observed this device. A device found by both an * IP scan and a Proxmox import returns {ip, proxmox}. */ @@ -30,6 +31,9 @@ export function sourceBuckets(d: PendingDevice): Set { if (s === 'zwave') buckets.add('zwave') else if (s === 'zigbee') buckets.add('zigbee') else if (s === 'proxmox') buckets.add('proxmox') + // Created from a rack canvas: inventory gear that never lands on a + // logical canvas. + else if (s === 'rack') buckets.add('rack') else buckets.add('ip') // arp / mdns / anything else → IP scan } if (buckets.size === 0) { @@ -40,6 +44,15 @@ export function sourceBuckets(d: PendingDevice): Set { return buckets } +/** + * Created from a rack canvas. Such an entry describes a mount (a chassis, a + * patch panel, a shelf), so it is never placed on a logical canvas — the + * approve paths refuse it on both sides of the wire. + */ +export function isRackDevice(d: PendingDevice): boolean { + return sourceBuckets(d).has('rack') +} + /** Ordered bucket list for badge rendering. */ export function orderedSources(d: PendingDevice): SourceBucket[] { const buckets = sourceBuckets(d)