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 */}
-
- Approve
-
+ {/* Rack gear is mounted from a rack canvas, never approved onto a
+ logical one — so it gets no Approve button at all. */}
+ {!rackOnly && (
+
+ Approve
+
+ )}
{
+ // Rack gear documents a mount, not a host: it stays out of logical canvases.
+ // The backend refuses it too — this is the friendly half of the guard.
+ if (isRackDevice(device)) {
+ toast.error('Rack devices belong to a rack canvas, not a logical one')
+ return
+ }
const fallbackLabel = deviceLabel(device)
const type = (device.suggested_type ?? 'generic') as NodeType
const zwave = isZwaveType(type)
@@ -360,7 +366,14 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
}
const handleBulkApprove = async () => {
- const ids = [...selectedIds]
+ const rackIds = new Set(devices.filter(isRackDevice).map((d) => d.id))
+ const ids = [...selectedIds].filter((id) => !rackIds.has(id))
+ const skippedRack = selectedIds.size - ids.length
+ if (skippedRack > 0) {
+ toast.error(
+ `${skippedRack} rack device${skippedRack > 1 ? 's' : ''} skipped — they belong to a rack canvas`,
+ )
+ }
if (ids.length === 0) return
try {
const res = await scanApi.bulkApprove(ids, activeDesignId)
@@ -551,6 +564,13 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
>
Proxmox
+ setSourceFilter('rack')}
+ className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'rack' ? 'bg-[#39d353]/20 text-[#39d353]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
+ title="Gear created from a rack canvas"
+ >
+ Rack devices
+
{
expect(onClose).toHaveBeenCalledOnce()
})
})
+
+describe('PendingDeviceModal — rack gear', () => {
+ const rackDevice = makeDevice({
+ id: 'dev-rack',
+ hostname: 'patch-house',
+ ip: null,
+ discovery_source: 'rack',
+ discovery_sources: ['rack'],
+ })
+
+ it('offers no Approve button — rack gear never lands on a logical canvas', () => {
+ render(
+
+ )
+ expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
+ // Hide and Delete still apply: the entry is inventory like any other.
+ expect(screen.getByRole('button', { name: 'Hide' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
+ })
+
+ it('keeps Approve for a scanned device', () => {
+ render(
+
+ )
+ expect(screen.getByRole('button', { name: 'Approve' })).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx
index cb0bbe7..e470746 100644
--- a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx
+++ b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx
@@ -107,6 +107,20 @@ const DEVICE_PROXMOX = {
discovered_at: '2026-01-04T00:00:00Z',
}
+const DEVICE_RACK = {
+ id: 'dev-rack',
+ ip: null,
+ hostname: 'patch-house',
+ mac: null,
+ os: null,
+ services: [],
+ suggested_type: null,
+ status: 'pending',
+ discovery_source: 'rack',
+ discovery_sources: ['rack'],
+ discovered_at: '2026-01-05T00:00:00Z',
+}
+
beforeEach(() => {
vi.clearAllMocks()
// Apply the selector when one is passed (setSelectedNode is read via a
@@ -549,4 +563,54 @@ describe('PendingDevicesModal', () => {
expect(inCard.queryByText('Created')).not.toBeInTheDocument()
})
})
+
+ // Gear created from a rack canvas lives in the same inventory (so it gets the
+ // search / filter / hide / delete tooling) but describes a mount, not a host.
+ describe('rack devices', () => {
+ beforeEach(() => {
+ mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_RACK] })
+ })
+
+ it('filters them into their own section', async () => {
+ render( )
+ await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
+ fireEvent.click(screen.getByRole('button', { name: 'Rack devices' }))
+ expect(screen.getByTestId('pending-card-dev-rack')).toBeInTheDocument()
+ expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
+ })
+
+ it('refuses to approve one onto a logical canvas', async () => {
+ const { toast } = await import('sonner')
+ render( )
+ await waitFor(() => expect(screen.getByTestId('pending-card-dev-rack')).toBeInTheDocument())
+ fireEvent.click(screen.getByTestId('pending-card-dev-rack'))
+ fireEvent.click(screen.getByTestId('do-approve'))
+
+ await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('rack canvas')))
+ expect(mockApprove).not.toHaveBeenCalled()
+ })
+
+ it('drops them from a bulk approve and says how many', async () => {
+ const { toast } = await import('sonner')
+ render( )
+ await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
+ fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
+ fireEvent.click(screen.getByTestId('pending-card-dev-a'))
+ fireEvent.click(screen.getByTestId('pending-card-dev-rack'))
+ fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
+
+ await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a'], null))
+ expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('1 rack device'))
+ })
+
+ it('approves nothing when the whole selection is rack gear', async () => {
+ render( )
+ await waitFor(() => expect(screen.getByTestId('pending-card-dev-rack')).toBeInTheDocument())
+ fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
+ fireEvent.click(screen.getByTestId('pending-card-dev-rack'))
+ fireEvent.click(screen.getByRole('button', { name: /Approve \(1\)/ }))
+
+ await waitFor(() => expect(mockBulkApprove).not.toHaveBeenCalled())
+ })
+ })
})
diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx
index 1d22120..049d9e0 100644
--- a/frontend/src/components/panels/Sidebar.tsx
+++ b/frontend/src/components/panels/Sidebar.tsx
@@ -9,9 +9,8 @@ import { authApi, designsApi, mediaApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
-import { InventoryTray } from '@/rack/components/InventoryTray'
+import { AccessoryTray } from '@/rack/components/AccessoryTray'
import { useRackStore } from '@/rack/store'
-import { useRackPalette } from '@/rack/rackTheme'
import { freeUnits } from '@/rack/layout'
import type { Design } from '@/types'
import { toast } from 'sonner'
@@ -51,9 +50,8 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
const [openSeq, setOpenSeq] = useState(0)
const { nodes, hasUnsavedChanges: canvasDirty, floorMap, setFloorMap } = useCanvasStore()
const floorMapEditNonce = useCanvasStore((s) => s.floorMapEditNonce)
- const rackPalette = useRackPalette()
const rackDirty = useRackStore((s) => s.hasUnsavedChanges)
- const addRack = useRackStore((s) => s.addRack)
+ const openDeviceEditor = useRackStore((s) => s.openDeviceEditor)
const hasUnsavedChanges = isRack ? rackDirty : canvasDirty
const handleLogout = useCallback(async () => {
@@ -267,7 +265,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
{/* The rack inventory is the sidebar's body: it scrolls, everything else
stays pinned. On a logical canvas the space is just a spacer. */}
- {isRack && !collapsed ? : !collapsed &&
}
+ {isRack && !collapsed ? : !collapsed &&
}
{/* Stats footer — hidden in standalone (no scan / live status to count) */}
{!collapsed && isRack && }
@@ -291,7 +289,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
{/* Actions */}
{isRack ? (
-
addRack({ style: rackPalette.defaultRackStyle })} />
+ openDeviceEditor()} />
) : (
<>
diff --git a/frontend/src/rack/README.md b/frontend/src/rack/README.md
index 5dd88b3..e3d3200 100644
--- a/frontend/src/rack/README.md
+++ b/frontend/src/rack/README.md
@@ -15,6 +15,7 @@ Create one from **New Canvas → Kind → Rack**.
| Horizontal | 12-column grid (`RACK_COLUMNS`). Full = 12, half = 6, third = 4, quarter = 3 — so 2 or 3 machines share one U. |
| Collision | `canPlace` / `findSlot` in `layout.ts`. A drop snaps to the nearest free slot; an impossible drop shows a red preview. |
| Inventory | A mount references a **Device Inventory** entry (`pending_devices`) via `deviceId`. Those rows survive approval *and* node deletion, so unracking never removes the device. Accessories (blank, shelf, cable manager) have `deviceId: null`. |
+| Rack-created gear | A device created from this canvas is a normal inventory row with `discovery_source: "rack"`, so it inherits the inventory's search, filters, hide and delete. It shows under the **Rack devices** source filter, and both `POST /scan/pending/{id}/approve` and bulk-approve refuse it — a mount is not a host to document on a logical canvas. |
| Canvas node link | `nodeId` is a second, optional link to a logical-canvas node, resolved server-side by IEEE then IP. It supplies live status and lets the network-link import match endpoints. Never required. |
| Faceplates | Declarative templates in `faceplates.ts`, drawn as SVG in unit coordinates so a plate scales with U height and rack width. Applying a template seeds ports; the user edits them afterwards. `suggestFaceplate()` picks one from the device's discovery type. |
| Ports | RJ45 and SFP/SFP+ only, drawn as real jack artwork at a fixed pixel size so plates of different U heights line up. Manual list per device. Power outlets are artwork, never a cable endpoint. |
@@ -44,19 +45,21 @@ types in `@/types/rack`, narrowing every enum on the way in.
## Interactions
+- **+ Device** (left rail) → `RackDeviceModal`. Source is either an existing **Device Inventory** entry, a new device created here (which lands in the inventory too), or a rack-only accessory.
- Drag from the sidebar tray onto a rack → snaps to a free U.
- Drag a mounted device inside the rack → same snapping, own slot ignored.
-- Click a device → inspector on the right (label, faceplate, U/height/column/width, status, colour, port list).
-- Click empty rack chrome → rack settings (name, location, U height, 19"/10", numbering direction, frame/rail/interior colours, U numbers, enclosed).
+- 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.
- **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**: adds a Device Inventory entry from the tray, for gear no scan will ever discover.
+- **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.
## Panel behaviour in rack mode
- **Header**: undo/redo, auto layout, YAML import/export, MD and live View are hidden. Add Rack, Patch, cable visibility, cable type filter and Import links replace them. PNG export and Save stay.
-- **Left rail**: the design switcher, Device Inventory, Scan History, Settings and Logout stay. The node/zone/text/scan/import block becomes the inventory tray plus Add Rack. The footer counts racks, mounts, cables and free U instead of online/offline nodes.
-- **Right rail**: `RackInspector` replaces `DetailPanel`, and is always visible rather than appearing on selection.
+- **Left rail**: the design switcher, Device Inventory, Scan History, Settings and Logout stay. The node/zone/text/scan/import block becomes the accessory tray plus **+ Device**. Devices themselves are not listed there — they live in the Device Inventory modal. The footer counts racks, mounts, cables and free U instead of online/offline nodes.
+- **Right rail**: none. A mount is edited in `RackDeviceModal`, a rack in `RackSettingsModal`, so the canvas keeps the full width.
## Known gaps
diff --git a/frontend/src/rack/__tests__/RackCanvas.test.tsx b/frontend/src/rack/__tests__/RackCanvas.test.tsx
new file mode 100644
index 0000000..4749569
--- /dev/null
+++ b/frontend/src/rack/__tests__/RackCanvas.test.tsx
@@ -0,0 +1,60 @@
+/**
+ * The empty-state overlay is a child of ``, i.e. a sibling of
+ * `.react-flow__renderer` (z-index 4). Without an explicit stacking context it
+ * paints *under* the pane, which then eats the clicks as a canvas drag — the
+ * buttons look enabled but do nothing.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { RackCanvas } from '../components/RackCanvas'
+import { useRackStore } from '../store'
+
+vi.mock('@xyflow/react', async () => {
+ const { mockReactFlow } = await import('@/test/mocks')
+ const React = await import('react')
+ return mockReactFlow({
+ ReactFlow: ({ children }: { children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'flow' }, children),
+ Background: () => null,
+ BackgroundVariant: { Dots: 'dots' },
+ Controls: () => null,
+ ViewportPortal: () => null,
+ useReactFlow: () => ({ setViewport: vi.fn() }),
+ })
+})
+
+beforeEach(() => {
+ useRackStore.getState().reset()
+})
+
+describe('RackCanvas empty state', () => {
+ it('lifts the overlay above the React Flow pane so its buttons are clickable', () => {
+ render( )
+ const overlay = screen.getByText('This rack canvas is empty.').parentElement!
+ expect(overlay.className).toContain('z-10')
+ // The wrapper stays click-through; only the button row takes pointers.
+ expect(overlay.className).toContain('pointer-events-none')
+ expect(screen.getByRole('button', { name: /add a rack/i }).parentElement!.className).toContain(
+ 'pointer-events-auto',
+ )
+ })
+
+ it('adds a rack from the empty state', () => {
+ render( )
+ fireEvent.click(screen.getByRole('button', { name: /add a rack/i }))
+ expect(useRackStore.getState().racks).toHaveLength(1)
+ })
+
+ it('loads the sample rack from the empty state', () => {
+ render( )
+ fireEvent.click(screen.getByRole('button', { name: /load a sample rack/i }))
+ expect(useRackStore.getState().racks).toHaveLength(1)
+ expect(useRackStore.getState().devices.length).toBeGreaterThan(0)
+ })
+
+ it('hides the overlay once a rack exists', () => {
+ useRackStore.getState().addRack()
+ render( )
+ expect(screen.queryByText('This rack canvas is empty.')).toBeNull()
+ })
+})
diff --git a/frontend/src/rack/__tests__/RackDeviceModal.test.tsx b/frontend/src/rack/__tests__/RackDeviceModal.test.tsx
new file mode 100644
index 0000000..64647c7
--- /dev/null
+++ b/frontend/src/rack/__tests__/RackDeviceModal.test.tsx
@@ -0,0 +1,178 @@
+/**
+ * The device modal is the only editor for a mount — the rack canvas has no
+ * right rail — so everything the old inspector did has to work from here.
+ */
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { render, screen, fireEvent, waitFor } from '@testing-library/react'
+import { RackDeviceModal } from '../components/RackDeviceModal'
+import { useRackStore } from '../store'
+
+const createPending = vi.fn()
+
+vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
+vi.mock('@/api/client', () => ({
+ racksApi: { load: vi.fn(), inventory: vi.fn(), save: vi.fn() },
+ scanApi: { createPending: (...args: unknown[]) => createPending(...args) },
+}))
+
+const store = () => useRackStore.getState()
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ store().loadDemo()
+})
+
+function submit() {
+ fireEvent.click(screen.getByRole('button', { name: /^(Add|Save)$/ }))
+}
+
+describe('RackDeviceModal — nothing to edit', () => {
+ it('renders nothing while no editor is open', () => {
+ const { container } = render( )
+ expect(container).toBeEmptyDOMElement()
+ })
+})
+
+describe('RackDeviceModal — adding', () => {
+ it('mounts an entry picked from the Device Inventory', async () => {
+ store().openDeviceEditor()
+ render( )
+
+ const unracked = store().inventory.find((i) => !i.racked)!
+ fireEvent.change(screen.getByLabelText('Device Inventory entry'), {
+ target: { value: unracked.id },
+ })
+ submit()
+
+ await waitFor(() =>
+ expect(store().devices.some((d) => d.deviceId === unracked.id)).toBe(true),
+ )
+ // The mount is a rack row; the inventory entry itself stays, now flagged.
+ expect(store().inventory.find((i) => i.id === unracked.id)!.racked).toBe(true)
+ expect(store().deviceEditor).toBeNull()
+ })
+
+ it('creates a brand new inventory entry from the canvas and mounts it', async () => {
+ createPending.mockResolvedValue({ data: { id: 'pending-1' } })
+ store().openDeviceEditor()
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'New device' }))
+ fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'tape-lib' } })
+ fireEvent.change(screen.getByLabelText('IP'), { target: { value: '192.168.1.99' } })
+ submit()
+
+ await waitFor(() => expect(createPending).toHaveBeenCalledTimes(1))
+ expect(createPending).toHaveBeenCalledWith(
+ expect.objectContaining({ hostname: 'tape-lib', ip: '192.168.1.99' }),
+ )
+ await waitFor(() =>
+ expect(store().devices.some((d) => d.label === 'tape-lib')).toBe(true),
+ )
+ expect(store().inventory.some((i) => i.id === 'pending-1')).toBe(true)
+ })
+
+ it('mounts an accessory without touching the inventory', async () => {
+ const before = store().inventory.length
+ store().openDeviceEditor()
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Accessory' }))
+ fireEvent.change(screen.getByLabelText('Faceplate'), { target: { value: 'blank-1u' } })
+ fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'filler' } })
+ submit()
+
+ await waitFor(() => expect(store().devices.some((d) => d.label === 'filler')).toBe(true))
+ const mounted = store().devices.find((d) => d.label === 'filler')!
+ expect(mounted.deviceId).toBeNull()
+ expect(store().inventory).toHaveLength(before)
+ })
+})
+
+describe('RackDeviceModal — editing', () => {
+ it('seeds every field from the mounted device', () => {
+ store().openDeviceEditor('dev-pve1')
+ render( )
+
+ const device = store().devices.find((d) => d.id === 'dev-pve1')!
+ expect(screen.getByLabelText('Label')).toHaveValue(device.label)
+ expect(screen.getByLabelText('U position')).toHaveValue(device.uStart)
+ expect(screen.getByLabelText('Height (U)')).toHaveValue(device.uHeight)
+ expect(screen.getByLabelText('Faceplate')).toHaveValue(device.faceplateId)
+ // The old right-panel fields all moved here.
+ expect(screen.getByLabelText('Status')).toBeInTheDocument()
+ expect(screen.getByLabelText('Colour override')).toBeInTheDocument()
+ expect(screen.getByText(`Ports (${device.ports.length})`)).toBeInTheDocument()
+ })
+
+ it('saves label, status and geometry in one go', async () => {
+ store().openDeviceEditor('dev-shelf')
+ render( )
+
+ fireEvent.change(screen.getByLabelText('Label'), { target: { value: 'kvm tray' } })
+ fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'online' } })
+ fireEvent.change(screen.getByLabelText('Height (U)'), { target: { value: '2' } })
+ submit()
+
+ await waitFor(() => expect(store().deviceEditor).toBeNull())
+ const device = store().devices.find((d) => d.id === 'dev-shelf')!
+ expect(device.label).toBe('kvm tray')
+ expect(device.status).toBe('online')
+ expect(device.uHeight).toBe(2)
+ })
+
+ it('applies a taller faceplate, height included', async () => {
+ store().openDeviceEditor('dev-shelf')
+ render( )
+
+ // Regression: a 2U plate used to leave the device 1U, so 1U and 2U plates
+ // rendered at the same height and the field looked locked.
+ fireEvent.change(screen.getByLabelText('Faceplate'), { target: { value: 'ups-2u' } })
+ expect(screen.getByLabelText('Height (U)')).toHaveValue(2)
+ submit()
+
+ await waitFor(() => expect(store().deviceEditor).toBeNull())
+ const device = store().devices.find((d) => d.id === 'dev-shelf')!
+ expect(device.faceplateId).toBe('ups-2u')
+ expect(device.uHeight).toBe(2)
+ })
+
+ it('edits the port list and commits it on save', async () => {
+ store().openDeviceEditor('dev-pve1')
+ render( )
+
+ const before = store().devices.find((d) => d.id === 'dev-pve1')!.ports.length
+ fireEvent.click(screen.getByRole('button', { name: 'Add port' }))
+ expect(screen.getByText(`Ports (${before + 1})`)).toBeInTheDocument()
+ submit()
+
+ await waitFor(() =>
+ expect(store().devices.find((d) => d.id === 'dev-pve1')!.ports).toHaveLength(before + 1),
+ )
+ })
+
+ it('unmounts without dropping the inventory entry', async () => {
+ store().openDeviceEditor('dev-pve1')
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: /Unmount/ }))
+
+ await waitFor(() => expect(store().devices.some((d) => d.id === 'dev-pve1')).toBe(false))
+ expect(store().inventory.some((i) => i.id === 'inv-pve1')).toBe(true)
+ expect(store().deviceEditor).toBeNull()
+ })
+
+ it('reports a resize the rack cannot take instead of failing silently', async () => {
+ const { toast } = await import('sonner')
+ store().openDeviceEditor('dev-pve1')
+ render( )
+
+ // The demo rack's longest free run is 3U.
+ fireEvent.change(screen.getByLabelText('Height (U)'), { target: { value: '9' } })
+ submit()
+
+ await waitFor(() => expect(toast.error).toHaveBeenCalled())
+ expect(store().deviceEditor).not.toBeNull()
+ expect(store().devices.find((d) => d.id === 'dev-pve1')!.uHeight).toBe(2)
+ })
+})
diff --git a/frontend/src/rack/__tests__/RackSettingsModal.test.tsx b/frontend/src/rack/__tests__/RackSettingsModal.test.tsx
new file mode 100644
index 0000000..721cb88
--- /dev/null
+++ b/frontend/src/rack/__tests__/RackSettingsModal.test.tsx
@@ -0,0 +1,49 @@
+/** Rack settings moved out of the right rail into their own dialog. */
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { RackSettingsModal } from '../components/RackSettingsModal'
+import { useRackStore } from '../store'
+
+vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
+
+const store = () => useRackStore.getState()
+
+beforeEach(() => {
+ store().loadDemo()
+})
+
+describe('RackSettingsModal', () => {
+ it('renders nothing until a rack is opened', () => {
+ const { container } = render( )
+ expect(container).toBeEmptyDOMElement()
+ })
+
+ it('edits name and capacity live', () => {
+ store().openRackEditor('rack-main')
+ render( )
+
+ fireEvent.change(screen.getByLabelText('Rack name'), { target: { value: 'Garage' } })
+ fireEvent.change(screen.getByLabelText('Rack height'), { target: { value: '24' } })
+
+ const rack = store().racks.find((r) => r.id === 'rack-main')!
+ expect(rack.name).toBe('Garage')
+ expect(rack.uHeight).toBe(24)
+ })
+
+ it('reports how much of the rack is in use', () => {
+ store().openRackEditor('rack-main')
+ render( )
+ expect(screen.getByText(/U used of 18U/)).toBeInTheDocument()
+ })
+
+ it('deletes the rack and closes', () => {
+ store().openRackEditor('rack-main')
+ render( )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Delete rack' }))
+
+ expect(store().racks).toHaveLength(0)
+ expect(store().devices).toHaveLength(0)
+ expect(store().rackEditorId).toBeNull()
+ })
+})
diff --git a/frontend/src/rack/__tests__/persistence.test.ts b/frontend/src/rack/__tests__/persistence.test.ts
index b32fb5a..8316ca8 100644
--- a/frontend/src/rack/__tests__/persistence.test.ts
+++ b/frontend/src/rack/__tests__/persistence.test.ts
@@ -160,7 +160,7 @@ describe('save', () => {
})
describe('createInventoryDevice', () => {
- it('creates the inventory entry server-side and adds it to the tray', async () => {
+ it('creates the entry in the Device Inventory, tagged as rack gear', async () => {
await store().loadDesign('d1')
createPending.mockResolvedValue({ data: { id: 'inv-new', hostname: 'patch panel' } })
@@ -170,6 +170,9 @@ describe('createInventoryDevice', () => {
ip: null,
mac: null,
suggested_type: null,
+ // Files it under the inventory's "Rack devices" filter and keeps it off
+ // the logical canvases.
+ discovery_source: 'rack',
})
expect(created!.id).toBe('inv-new')
expect(store().inventory.some((i) => i.id === 'inv-new')).toBe(true)
diff --git a/frontend/src/rack/__tests__/store.test.ts b/frontend/src/rack/__tests__/store.test.ts
index ce399d3..d8626f6 100644
--- a/frontend/src/rack/__tests__/store.test.ts
+++ b/frontend/src/rack/__tests__/store.test.ts
@@ -132,12 +132,21 @@ describe('moving and resizing', () => {
expect(store().devices.find((d) => d.id === 'dev-fw')!.uStart).toBe(5)
})
- it('ignores a geometry edit that would collide', () => {
- // dev-pve1 is 2U at U11; growing it to 4U would run into dev-fw territory.
- store().updateDevice('dev-pve1', { uHeight: 5 })
+ it('refuses a geometry edit no slot in the rack can take', () => {
+ // The demo rack's longest free run is 3U (U4-U6), so 5U fits nowhere.
+ expect(store().updateDevice('dev-pve1', { uHeight: 5 })).toBe(false)
expect(store().devices.find((d) => d.id === 'dev-pve1')!.uHeight).toBe(2)
})
+ it('relocates a device that outgrows its own slot', () => {
+ // dev-shelf is 1U at U7 with dev-nas right above; growing it to 3U has to
+ // slide it down into the free U4-U6 run rather than silently do nothing.
+ expect(store().updateDevice('dev-shelf', { uHeight: 3 })).toBe(true)
+ const device = store().devices.find((d) => d.id === 'dev-shelf')!
+ expect(device.uHeight).toBe(3)
+ expect(device.uStart).toBe(5) // nearest fit, keeping its own U7
+ })
+
it('applies a non-geometry edit even in a tight rack', () => {
store().updateDevice('dev-pve1', { label: 'renamed' })
expect(store().devices.find((d) => d.id === 'dev-pve1')!.label).toBe('renamed')
@@ -151,10 +160,21 @@ describe('moving and resizing', () => {
expect(device.uHeight).toBe(1)
})
- it('keeps the old geometry when the new faceplate does not fit', () => {
- store().applyFaceplate('dev-sw24', 'server-4u-storage')
+ it('relocates rather than keep the old height when a taller plate collides', () => {
+ // dev-shelf is 1U at U7, hemmed in by dev-nas above. A 2U plate has to land
+ // somewhere it fits — keeping it 1U is how the height looked "locked".
+ expect(store().applyFaceplate('dev-shelf', 'ups-2u')).toBe(true)
+ const device = store().devices.find((d) => d.id === 'dev-shelf')!
+ expect(device.faceplateId).toBe('ups-2u')
+ expect(device.uHeight).toBe(2)
+ expect(device.uStart).not.toBe(7)
+ })
+
+ it('changes nothing when no slot in the rack takes the new plate', () => {
+ // 4U, and the longest free run is 3U.
+ expect(store().applyFaceplate('dev-sw24', 'server-4u-storage')).toBe(false)
const device = store().devices.find((d) => d.id === 'dev-sw24')!
- expect(device.faceplateId).toBe('server-4u-storage')
+ expect(device.faceplateId).toBe('switch-24')
expect(device.uHeight).toBe(1)
})
})
diff --git a/frontend/src/rack/components/AccessoryTray.tsx b/frontend/src/rack/components/AccessoryTray.tsx
new file mode 100644
index 0000000..1094408
--- /dev/null
+++ b/frontend/src/rack/components/AccessoryTray.tsx
@@ -0,0 +1,45 @@
+/**
+ * Sidebar section for rack designs: rack accessories, as drag sources.
+ *
+ * Devices themselves are not listed here — they live in the app's **Device
+ * Inventory** (`pending_devices`), under its "Rack devices" source filter, so
+ * they get the same search / filter / hide / delete tooling as scanned gear.
+ * Mounting one is the `+ Device` modal's job. Accessories (blanks, shelves,
+ * cable managers) are rack-only artwork with no inventory row, so they stay.
+ */
+import { FACEPLATES } from '../faceplates'
+import { endDrag, startDrag } from './dragPayload'
+
+export function AccessoryTray() {
+ const accessories = FACEPLATES.filter((f) => f.kind === 'accessory')
+
+ return (
+
+
+ Accessories
+
+
+ Drag onto a rack. Devices come from the Device Inventory — use + Device.
+
+
+ {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}
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/rack/components/InventoryTray.tsx b/frontend/src/rack/components/InventoryTray.tsx
deleted file mode 100644
index f93791f..0000000
--- a/frontend/src/rack/components/InventoryTray.tsx
+++ /dev/null
@@ -1,188 +0,0 @@
-/**
- * Sidebar section for rack designs: Device Inventory entries and rack
- * accessories, both drag sources for the canvas.
- *
- * The inventory is the app's Device Inventory — unmounting gear from a rack
- * never removes it here.
- */
-import { useState } from 'react'
-import { PlusCircle, RefreshCw } from 'lucide-react'
-import { toast } from 'sonner'
-import { FACEPLATES, getFaceplate } from '../faceplates'
-import { useRackStore } from '../store'
-import type { InventoryDevice } from '@/types'
-import { endDrag, startDrag } from './dragPayload'
-
-const STATUS_DOT: Record = {
- online: '#39d353',
- offline: '#f85149',
- unknown: '#8b949e',
-}
-
-const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
-
-export function InventoryTray() {
- const inventory = useRackStore((s) => s.inventory)
- const createInventoryDevice = useRackStore((s) => s.createInventoryDevice)
- const refreshInventory = useRackStore((s) => s.refreshInventory)
- const [adding, setAdding] = useState(false)
- const [draftName, setDraftName] = useState('')
- const [busy, setBusy] = useState(false)
-
- const unracked = inventory.filter((i) => !i.racked)
- const racked = inventory.filter((i) => i.racked)
- const accessories = FACEPLATES.filter((f) => f.kind === 'accessory')
-
- async function handleCreate(e: React.FormEvent) {
- e.preventDefault()
- const label = draftName.trim()
- if (!label) return
- setBusy(true)
- const created = await createInventoryDevice({ label })
- setBusy(false)
- if (!created) {
- toast.error('Could not add the device')
- return
- }
- setDraftName('')
- setAdding(false)
- toast.success(`${label} added to the inventory`)
- }
-
- return (
-
-
-
- Unracked ({unracked.length})
-
- {!STANDALONE && (
- void refreshInventory()}
- className="p-1 text-muted-foreground hover:text-foreground cursor-pointer"
- >
-
-
- )}
-
-
- Drag onto a rack. Unmounting never deletes the device from the inventory.
-
-
-
- {unracked.map((item) => (
-
- startDrag(e.dataTransfer, {
- kind: 'inventory',
- id: item.id,
- faceplateId: item.suggestedFaceplateId,
- })
- }
- onDragEnd={endDrag}
- className="flex cursor-grab items-center gap-2 rounded border border-border bg-[#161b22] px-2 py-1.5 hover:border-[#00d4ff]"
- title={`${item.type ?? 'device'}${item.ip ? ` · ${item.ip}` : ''} · ${getFaceplate(item.suggestedFaceplateId).label}`}
- >
-
- {item.label}
-
- {getFaceplate(item.suggestedFaceplateId).uHeight}U
-
-
- ))}
- {unracked.length === 0 && (
- Everything is racked.
- )}
-
-
- {adding ? (
-
- ) : (
-
setAdding(true)}
- className="mb-3 flex items-center gap-1.5 rounded px-2 py-1 text-[11px] text-[#00d4ff] hover:bg-[#00d4ff]/10 cursor-pointer"
- >
- New device
-
- )}
-
-
- 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.
)}
+ {/* Editors live outside the flow so a dialog is never clipped by it. */}
+
+
+ >
)
}
diff --git a/frontend/src/rack/components/RackDeviceModal.tsx b/frontend/src/rack/components/RackDeviceModal.tsx
new file mode 100644
index 0000000..2c5b0b7
--- /dev/null
+++ b/frontend/src/rack/components/RackDeviceModal.tsx
@@ -0,0 +1,485 @@
+/**
+ * Add / edit a mounted device.
+ *
+ * The rack canvas has no right rail: everything about a mount is edited here,
+ * the way `NodeModal` owns a network node. Opened from the sidebar (`+ Device`)
+ * or by double-clicking a plate.
+ *
+ * Creating offers the two sources the product recognises: an entry that already
+ * exists in the **Device Inventory**, or a brand new one created from this
+ * canvas (which lands in the inventory too). Accessories — blanks, shelves,
+ * cable managers — are rack-only and never touch the inventory.
+ */
+import { useEffect, useMemo, useState } from 'react'
+import { Plus, X } from 'lucide-react'
+import { toast } from 'sonner'
+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 { faceplateGroups, getFaceplate } from '../faceplates'
+import { generateUUID } from '@/utils/uuid'
+import { RACK_COLUMNS, type DeviceStatus, type Port, type PortType } from '@/types'
+
+const DEFAULT_COLOR = '#2b323c'
+
+type Source = 'inventory' | 'new' | 'accessory'
+
+const inputClass =
+ 'w-full rounded border border-[#30363d] bg-[#21262d] px-2 py-1 text-sm text-foreground outline-none focus:border-[#00d4ff]'
+const fieldLabel = 'text-xs text-muted-foreground'
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ )
+}
+
+export function RackDeviceModal() {
+ const editor = useRackStore((s) => s.deviceEditor)
+ const close = useRackStore((s) => s.closeDeviceEditor)
+ if (!editor) return null
+ // Remount per target so the form always re-seeds from the device it edits.
+ return
+}
+
+function DeviceForm({ deviceId, onClose }: { deviceId: string | null; onClose: () => void }) {
+ const device = useRackStore((s) => s.devices.find((d) => d.id === deviceId))
+ const racks = useRackStore((s) => s.racks)
+ const inventory = useRackStore((s) => s.inventory)
+ const mountFromInventory = useRackStore((s) => s.mountFromInventory)
+ const mountAccessory = useRackStore((s) => s.mountAccessory)
+ const createInventoryDevice = useRackStore((s) => s.createInventoryDevice)
+ const applyFaceplate = useRackStore((s) => s.applyFaceplate)
+ const updateDevice = useRackStore((s) => s.updateDevice)
+ const unmountDevice = useRackStore((s) => s.unmountDevice)
+ const setPorts = useRackStore((s) => s.setPorts)
+
+ const isEdit = !!device
+ const unracked = useMemo(() => inventory.filter((i) => !i.racked), [inventory])
+ const groups = useMemo(() => faceplateGroups(), [])
+
+ const [source, setSource] = useState(unracked.length > 0 ? 'inventory' : 'new')
+ const [inventoryId, setInventoryId] = useState(unracked[0]?.id ?? '')
+ const [newIp, setNewIp] = useState('')
+ const [rackId, setRackId] = useState(device?.rackId ?? racks[0]?.id ?? '')
+ const [label, setLabel] = useState(device?.label ?? '')
+ const [faceplateId, setFaceplateId] = useState(
+ device?.faceplateId ?? unracked[0]?.suggestedFaceplateId ?? 'server-1u',
+ )
+ const [uStart, setUStart] = useState(device?.uStart ?? 1)
+ const [uHeight, setUHeight] = useState(device?.uHeight ?? getFaceplate(faceplateId).uHeight)
+ const [colStart, setColStart] = useState(device?.colStart ?? 0)
+ const [colSpan, setColSpan] = useState(device?.colSpan ?? getFaceplate(faceplateId).colSpan)
+ const [status, setStatus] = useState(device?.status ?? 'unknown')
+ const [color, setColor] = useState(device?.color)
+ const [ports, setLocalPorts] = useState(
+ device?.ports ?? getFaceplate(faceplateId).ports.map((p) => ({ ...p, id: generateUUID() })),
+ )
+ const [plateChanged, setPlateChanged] = useState(false)
+ const [busy, setBusy] = useState(false)
+
+ // The inventory lives in the Device Inventory, which the user can edit from
+ // elsewhere (or a scan can grow) — pull a fresh list when adding.
+ const refreshInventory = useRackStore((s) => s.refreshInventory)
+ useEffect(() => {
+ if (!isEdit) void refreshInventory()
+ }, [isEdit, refreshInventory])
+
+ /** Swapping the plate reseeds size and ports — the old ports belong to it. */
+ function pickFaceplate(id: string) {
+ const plate = getFaceplate(id)
+ setFaceplateId(id)
+ setUHeight(plate.uHeight)
+ setColSpan(plate.colSpan)
+ setColStart((c) => Math.min(c, RACK_COLUMNS - plate.colSpan))
+ setLocalPorts(plate.ports.map((p) => ({ ...p, id: generateUUID() })))
+ setPlateChanged(true)
+ }
+
+ function pickInventory(id: string) {
+ setInventoryId(id)
+ const item = inventory.find((i) => i.id === id)
+ if (!item) return
+ if (!label.trim()) setLabel(item.label)
+ setStatus(item.status)
+ pickFaceplate(item.suggestedFaceplateId)
+ }
+
+ const geometry = { uStart, uHeight, colStart, colSpan }
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ setBusy(true)
+ try {
+ const id = isEdit ? commitEdit() : await commitCreate()
+ if (!id) return
+ setPorts(id, ports)
+ onClose()
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ function commitEdit(): string | null {
+ if (!device) return null
+ if (plateChanged && faceplateId !== device.faceplateId) applyFaceplate(device.id, faceplateId)
+ const name = label.trim() || device.label
+ if (!updateDevice(device.id, { ...geometry, label: name, status, color })) {
+ toast.error('No room in the rack for that size')
+ return null
+ }
+ return device.id
+ }
+
+ async function commitCreate(): Promise {
+ if (!rackId) {
+ toast.error('Add a rack first')
+ return null
+ }
+ const name = label.trim()
+
+ if (source === 'accessory') {
+ const id = mountAccessory(faceplateId, rackId, { uStart, colStart })
+ if (!id) return noRoom()
+ updateDevice(id, { ...geometry, label: name || getFaceplate(faceplateId).label, color })
+ return id
+ }
+
+ let entryId = inventoryId
+ if (source === 'new') {
+ if (!name) {
+ toast.error('Name the device first')
+ return null
+ }
+ const created = await createInventoryDevice({ label: name, ip: newIp.trim() || null })
+ if (!created) {
+ toast.error('Could not add the device to the inventory')
+ return null
+ }
+ entryId = created.id
+ }
+ if (!entryId) {
+ toast.error('Pick a device from the inventory')
+ return null
+ }
+
+ const id = mountFromInventory(entryId, rackId, { ...geometry, faceplateId })
+ if (!id) return noRoom()
+ // The mount takes its label and status from the inventory entry; only patch
+ // what the form actually overrides.
+ updateDevice(id, { ...geometry, status, color, ...(name ? { label: name } : {}) })
+ return id
+ }
+
+ function noRoom(): null {
+ toast.error('No free slot in this rack')
+ return null
+ }
+
+ function handleUnmount() {
+ if (!device) return
+ unmountDevice(device.id)
+ onClose()
+ }
+
+ const showInventoryPicker = !isEdit && source === 'inventory'
+
+ return (
+ !o && onClose()}>
+
+
+
+ {isEdit ? 'Edit Device' : 'Add Device'}
+
+
+
+
+
+
+ )
+}
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 })}
- />
-
-
-
- updateRack(rack.id, { widthStandard: e.target.value as RackWidthStandard })
- }
- >
- 19 inch
- 10 inch (mini)
-
-
-
- updateRack(rack.id, { numbering: e.target.value as RackNumbering })}
- >
- 1 at the bottom
- 1 at the top
-
-
-
-
- Style
-
- {(['frame', 'rail', 'interior'] as const).map((key) => (
-
- updateRackStyle(rack.id, { [key]: e.target.value })}
- />
-
- ))}
-
- updateRackStyle(rack.id, { showNumbers: e.target.checked })}
- />
- Show U numbers
-
-
- updateRackStyle(rack.id, { enclosed: e.target.checked })}
- />
- Enclosed cabinet
-
-
- removeRack(rack.id)}
- >
- Delete rack
-
-
- )
-}
-
-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 })}
- />
-
-
-
- applyFaceplate(device.id, e.target.value)}
- >
- {faceplateGroups().map(({ group, items }) => (
-
- {items.map((plate) => (
-
- {plate.label}
-
- ))}
-
- ))}
-
-
-
-
-
- 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, { colSpan: Number(e.target.value) })}
- >
- Full width
- Half width
- Third width
- Quarter width
-
-
-
-
-
- updateDevice(device.id, { status: e.target.value as DeviceStatus })}
- >
- Online
- Offline
- Unknown
-
-
-
-
- updateDevice(device.id, { color: e.target.value })}
- />
-
-
-
-
- Ports ({device.ports.length})
-
-
- addPort(device.id, {
- label: `p${device.ports.length + 1}`,
- type: 'rj45',
- x: 0.5,
- y: 0.5,
- })
- }
- >
- + Add
-
-
-
-
-
unmountDevice(device.id)}
- >
- Unmount {device.deviceId ? '(stays in inventory)' : ''}
-
-
- )
-}
-
-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 (
+
+ {label}
+ {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 })}
+ />
+
+
+
+
+
+ updateRack(rack.id, { widthStandard: e.target.value as RackWidthStandard })
+ }
+ >
+ 19 inch
+ 10 inch (mini)
+
+
+
+ updateRack(rack.id, { numbering: e.target.value as RackNumbering })}
+ >
+ 1 at the bottom
+ 1 at the top
+
+
+
+
+
+ {(['frame', 'rail', 'interior'] as const).map((key) => (
+
+ updateRackStyle(rack.id, { [key]: e.target.value })}
+ />
+
+ ))}
+
+
+
+ updateRackStyle(rack.id, { showNumbers: e.target.checked })}
+ />
+ Show U numbers
+
+
+ updateRackStyle(rack.id, { enclosed: e.target.checked })}
+ />
+ Enclosed cabinet
+
+
+
+ {
+ removeRack(rack.id)
+ close()
+ }}
+ className="cursor-pointer text-[#f85149] hover:bg-[#f8514922] hover:text-[#f85149]"
+ >
+ Delete rack
+
+
+ Done
+
+
+
+
+
+ )
+}
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)