feat(rack): edit devices in a modal, file rack gear in the Device Inventory
The rack canvas loses its right rail: a mount is now edited in its own dialog, like a network node, and the canvas keeps the full width. Devices: - `RackDeviceModal` adds and edits a mount — label, faceplate, U position, height, column, width, status, colour, ports, unmount. Adding offers three sources: an existing Device Inventory entry, a new device created here, or a rack-only accessory. Opened by `+ Device` in the left rail or a double-click on a plate. - `RackSettingsModal` takes over rack chrome (name, location, capacity, 19"/10", numbering, colours, delete), on a double-click of the chassis. - The left rail's `+ Rack` becomes `+ Device`; `+ Rack` stays in the header. Inventory: - Gear created from a rack canvas lands in the Device Inventory with `discovery_source: "rack"`, so it inherits the existing search, filters, hide and delete instead of a parallel list in the sidebar. New "Rack devices" source filter and badge. - It is never placed on a logical canvas: approve returns 409, bulk-approve skips it with `match: "rack"`, and the UI drops the Approve button and reports the skip. - `InventoryTray` becomes `AccessoryTray` — blanks, shelves and cable managers only, still a drag source. Fixes: - A taller faceplate (or a hand-typed height) no longer silently keeps the old geometry when it collides: the device relocates to the nearest slot that takes the new size, and only a rack with no such slot refuses the edit. This is why a 1U and a 2U plate rendered at the same height and the field looked locked. - The empty-state buttons sat under `.react-flow__renderer` (z-index 4), so the pane swallowed their clicks as a canvas drag. Tests: rack device/settings modals, empty-state stacking, relocate-vs-refuse in the store, rack source bucketing, inventory approve gating (frontend + backend). ha-relevant: maybe The modal-based editors and the rack store are portable; the inventory tagging rides on the standalone FastAPI scan routes. Discuss before porting.
This commit is contained in:
committed by
Pouzor - Rémy Jardient
parent
a0d16e1454
commit
e2da642554
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isRackDesign ? (
|
||||
<RackInspector />
|
||||
) : (
|
||||
(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />
|
||||
{/* 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) && (
|
||||
<DetailPanel onEdit={handleEditNode} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 bg-[#39d353]/15 text-[#39d353] hover:bg-[#39d353]/25 border border-[#39d353]/30"
|
||||
onClick={handleApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
{/* Rack gear is mounted from a rack canvas, never approved onto a
|
||||
logical one — so it gets no Approve button at all. */}
|
||||
{!rackOnly && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="flex-1 bg-[#39d353]/15 text-[#39d353] hover:bg-[#39d353]/25 border border-[#39d353]/30"
|
||||
onClick={handleApprove}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
||||
@@ -18,7 +18,7 @@ import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
|
||||
import { buildMacProperty } from '@/utils/macProperty'
|
||||
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
|
||||
import { getCenteredPosition } from '@/utils/viewportCenter'
|
||||
import { sourceBuckets, orderedSources, SOURCE_META, type SourceBucket } from '@/utils/pendingSources'
|
||||
import { sourceBuckets, orderedSources, isRackDevice, SOURCE_META, type SourceBucket } from '@/utils/pendingSources'
|
||||
|
||||
interface PendingDevicesModalProps {
|
||||
open: boolean
|
||||
@@ -276,6 +276,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
// force=true is sent only after the user confirms they want a duplicate node
|
||||
// on this design (the backend otherwise 409s to let us ask).
|
||||
const approveDevice = async (device: PendingDevice, force = false) => {
|
||||
// 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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => 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
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
|
||||
@@ -162,3 +162,30 @@ describe('PendingDeviceModal', () => {
|
||||
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(
|
||||
<PendingDeviceModal device={rackDevice} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
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(
|
||||
<PendingDeviceModal device={makeDevice()} onClose={vi.fn()} onApprove={vi.fn()} onHide={vi.fn()} onIgnore={vi.fn()} />
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Approve' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(<PendingDevicesModal {...baseProps} />)
|
||||
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(<PendingDevicesModal {...baseProps} />)
|
||||
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(<PendingDevicesModal {...baseProps} />)
|
||||
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(<PendingDevicesModal {...baseProps} />)
|
||||
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())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 ? <InventoryTray /> : !collapsed && <div className="flex-1" />}
|
||||
{isRack && !collapsed ? <AccessoryTray /> : !collapsed && <div className="flex-1" />}
|
||||
|
||||
{/* Stats footer — hidden in standalone (no scan / live status to count) */}
|
||||
{!collapsed && isRack && <RackStats />}
|
||||
@@ -291,7 +289,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
||||
{isRack ? (
|
||||
<SidebarItem icon={Plus} label="Add Rack" collapsed={collapsed} onClick={() => addRack({ style: rackPalette.defaultRackStyle })} />
|
||||
<SidebarItem icon={Plus} label="Add Device" collapsed={collapsed} onClick={() => openDeviceEditor()} />
|
||||
) : (
|
||||
<>
|
||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} dataTour="add-node" />
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* The empty-state overlay is a child of `<ReactFlow>`, 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(<RackCanvas />)
|
||||
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(<RackCanvas />)
|
||||
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(<RackCanvas />)
|
||||
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(<RackCanvas />)
|
||||
expect(screen.queryByText('This rack canvas is empty.')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -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(<RackDeviceModal />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RackDeviceModal — adding', () => {
|
||||
it('mounts an entry picked from the Device Inventory', async () => {
|
||||
store().openDeviceEditor()
|
||||
render(<RackDeviceModal />)
|
||||
|
||||
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(<RackDeviceModal />)
|
||||
|
||||
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(<RackDeviceModal />)
|
||||
|
||||
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(<RackDeviceModal />)
|
||||
|
||||
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(<RackDeviceModal />)
|
||||
|
||||
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(<RackDeviceModal />)
|
||||
|
||||
// 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(<RackDeviceModal />)
|
||||
|
||||
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(<RackDeviceModal />)
|
||||
|
||||
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(<RackDeviceModal />)
|
||||
|
||||
// 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)
|
||||
})
|
||||
})
|
||||
@@ -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(<RackSettingsModal />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('edits name and capacity live', () => {
|
||||
store().openRackEditor('rack-main')
|
||||
render(<RackSettingsModal />)
|
||||
|
||||
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(<RackSettingsModal />)
|
||||
expect(screen.getByText(/U used of 18U/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('deletes the rack and closes', () => {
|
||||
store().openRackEditor('rack-main')
|
||||
render(<RackSettingsModal />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete rack' }))
|
||||
|
||||
expect(store().racks).toHaveLength(0)
|
||||
expect(store().devices).toHaveLength(0)
|
||||
expect(store().rackEditorId).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-2 pb-2 text-sm text-foreground">
|
||||
<h2 className="pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Accessories
|
||||
</h2>
|
||||
<p className="mb-2 text-[11px] leading-snug text-muted-foreground">
|
||||
Drag onto a rack. Devices come from the Device Inventory — use + Device.
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{accessories.map((plate) => (
|
||||
<li
|
||||
key={plate.id}
|
||||
draggable
|
||||
onDragStart={(e) =>
|
||||
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}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<InventoryDevice['status'], string> = {
|
||||
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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-2 pb-2 text-sm text-foreground">
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Unracked ({unracked.length})
|
||||
</h2>
|
||||
{!STANDALONE && (
|
||||
<button
|
||||
type="button"
|
||||
title="Refresh from the Device Inventory"
|
||||
aria-label="Refresh inventory"
|
||||
onClick={() => void refreshInventory()}
|
||||
className="p-1 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-2 text-[11px] leading-snug text-muted-foreground">
|
||||
Drag onto a rack. Unmounting never deletes the device from the inventory.
|
||||
</p>
|
||||
|
||||
<ul className="mb-3 space-y-1">
|
||||
{unracked.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={(e) =>
|
||||
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}`}
|
||||
>
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ background: STATUS_DOT[item.status] }}
|
||||
/>
|
||||
<span className="truncate text-xs">{item.label}</span>
|
||||
<span className="ml-auto shrink-0 font-mono text-[10px] text-muted-foreground">
|
||||
{getFaceplate(item.suggestedFaceplateId).uHeight}U
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{unracked.length === 0 && (
|
||||
<li className="px-2 py-1 text-[11px] text-muted-foreground">Everything is racked.</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{adding ? (
|
||||
<form onSubmit={handleCreate} className="mb-3 space-y-1">
|
||||
<input
|
||||
autoFocus
|
||||
value={draftName}
|
||||
onChange={(e) => 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]"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !draftName.trim()}
|
||||
className="flex-1 rounded border border-[#00d4ff] px-2 py-1 text-[11px] text-[#00d4ff] hover:bg-[#00d4ff]/10 disabled:opacity-40 cursor-pointer"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setAdding(false); setDraftName('') }}
|
||||
className="rounded border border-border px-2 py-1 text-[11px] text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => 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"
|
||||
>
|
||||
<PlusCircle size={12} /> New device
|
||||
</button>
|
||||
)}
|
||||
|
||||
<h2 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Accessories
|
||||
</h2>
|
||||
<ul className="mb-3 space-y-1">
|
||||
{accessories.map((plate) => (
|
||||
<li
|
||||
key={plate.id}
|
||||
draggable
|
||||
onDragStart={(e) =>
|
||||
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}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{racked.length > 0 && (
|
||||
<>
|
||||
<h2 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Racked ({racked.length})
|
||||
</h2>
|
||||
<ul className="space-y-1">
|
||||
{racked.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="flex items-center gap-2 px-2 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ background: STATUS_DOT[item.status] }}
|
||||
/>
|
||||
<span className="truncate">{item.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={[]}
|
||||
@@ -102,7 +105,9 @@ export function RackCanvas() {
|
||||
<Controls />
|
||||
<CableLayer />
|
||||
{!loading && racks.length === 0 && (
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-3 text-center">
|
||||
// z-10 clears .react-flow__renderer (z-index 4); without it the pane sits
|
||||
// on top and swallows the clicks as a canvas drag.
|
||||
<div className="pointer-events-none absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 text-center">
|
||||
<p className="text-sm text-[#8b949e]">This rack canvas is empty.</p>
|
||||
<div className="pointer-events-auto flex gap-2">
|
||||
<button
|
||||
@@ -123,5 +128,9 @@ export function RackCanvas() {
|
||||
</div>
|
||||
)}
|
||||
</ReactFlow>
|
||||
{/* Editors live outside the flow so a dialog is never clipped by it. */}
|
||||
<RackDeviceModal />
|
||||
<RackSettingsModal />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className={fieldLabel}>{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 <DeviceForm key={editor.deviceId ?? 'new'} deviceId={editor.deviceId} onClose={close} />
|
||||
}
|
||||
|
||||
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<Source>(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<DeviceStatus>(device?.status ?? 'unknown')
|
||||
const [color, setColor] = useState<string | undefined>(device?.color)
|
||||
const [ports, setLocalPorts] = useState<Port[]>(
|
||||
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<string | null> {
|
||||
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 (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-md border-[#30363d] bg-[#161b22] text-foreground max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
{isEdit ? 'Edit Device' : 'Add Device'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={(e) => void handleSubmit(e)} className="mt-2 flex flex-col gap-3">
|
||||
{!isEdit && (
|
||||
<Field label="Source">
|
||||
<div className="flex gap-1">
|
||||
{(
|
||||
[
|
||||
['inventory', 'From inventory'],
|
||||
['new', 'New device'],
|
||||
['accessory', 'Accessory'],
|
||||
] as [Source, string][]
|
||||
).map(([value, text]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setSource(value)}
|
||||
className={`flex-1 cursor-pointer rounded border px-2 py-1 text-xs ${
|
||||
source === value
|
||||
? 'border-[#00d4ff] text-[#00d4ff]'
|
||||
: 'border-[#30363d] text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{showInventoryPicker && (
|
||||
<Field label="Device Inventory entry">
|
||||
<select
|
||||
className={inputClass}
|
||||
aria-label="Device Inventory entry"
|
||||
value={inventoryId}
|
||||
onChange={(e) => pickInventory(e.target.value)}
|
||||
>
|
||||
<option value="">Select a device…</option>
|
||||
{unracked.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
{item.ip ? ` · ${item.ip}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{unracked.length === 0 && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Everything in the inventory is already racked — create a new device instead.
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{!isEdit && source === 'new' && (
|
||||
<Field label="IP (optional)">
|
||||
<input
|
||||
className={`${inputClass} font-mono`}
|
||||
aria-label="IP"
|
||||
value={newIp}
|
||||
onChange={(e) => setNewIp(e.target.value)}
|
||||
placeholder="192.168.1.10"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{!isEdit && racks.length > 1 && (
|
||||
<Field label="Rack">
|
||||
<select
|
||||
className={inputClass}
|
||||
aria-label="Rack"
|
||||
value={rackId}
|
||||
onChange={(e) => setRackId(e.target.value)}
|
||||
>
|
||||
{racks.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="Label">
|
||||
<input
|
||||
className={inputClass}
|
||||
aria-label="Label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={source === 'accessory' ? getFaceplate(faceplateId).label : 'Device name'}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Faceplate">
|
||||
<select
|
||||
className={inputClass}
|
||||
aria-label="Faceplate"
|
||||
value={faceplateId}
|
||||
onChange={(e) => pickFaceplate(e.target.value)}
|
||||
>
|
||||
{groups.map(({ group, items }) => (
|
||||
<optgroup key={group} label={group}>
|
||||
{items.map((plate) => (
|
||||
<option key={plate.id} value={plate.id}>
|
||||
{plate.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{plateChanged && isEdit && (
|
||||
<p className="text-[11px] text-[#e3b341]">
|
||||
Changing the plate replaces its ports and drops their patches.
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="U position">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className={inputClass}
|
||||
aria-label="U position"
|
||||
value={uStart}
|
||||
onChange={(e) => setUStart(Number(e.target.value) || 1)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Height (U)">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={12}
|
||||
className={inputClass}
|
||||
aria-label="Height (U)"
|
||||
value={uHeight}
|
||||
onChange={(e) => setUHeight(Number(e.target.value) || 1)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Column">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={RACK_COLUMNS - 1}
|
||||
className={inputClass}
|
||||
aria-label="Column"
|
||||
value={colStart}
|
||||
onChange={(e) => setColStart(Number(e.target.value) || 0)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={`Width (/${RACK_COLUMNS})`}>
|
||||
<select
|
||||
className={inputClass}
|
||||
aria-label="Width"
|
||||
value={colSpan}
|
||||
onChange={(e) => setColSpan(Number(e.target.value))}
|
||||
>
|
||||
<option value={RACK_COLUMNS}>Full width</option>
|
||||
<option value={RACK_COLUMNS / 2}>Half width</option>
|
||||
<option value={RACK_COLUMNS / 3}>Third width</option>
|
||||
<option value={RACK_COLUMNS / 4}>Quarter width</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Status">
|
||||
<select
|
||||
className={inputClass}
|
||||
aria-label="Status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as DeviceStatus)}
|
||||
>
|
||||
<option value="online">Online</option>
|
||||
<option value="offline">Offline</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Colour override">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
aria-label="Colour override"
|
||||
className="h-8 flex-1 cursor-pointer rounded border border-[#30363d] bg-[#21262d]"
|
||||
value={color ?? DEFAULT_COLOR}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setColor(undefined)}
|
||||
className="rounded border border-[#30363d] px-2 py-1 text-[11px] text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className={fieldLabel}>Ports ({ports.length})</Label>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add port"
|
||||
className="cursor-pointer rounded border border-[#30363d] px-2 py-0.5 text-xs hover:border-[#00d4ff]"
|
||||
onClick={() =>
|
||||
setLocalPorts((p) => [
|
||||
...p,
|
||||
{ id: generateUUID(), label: `p${p.length + 1}`, type: 'rj45', x: 0.5, y: 0.5 },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus size={12} className="inline" /> Add
|
||||
</button>
|
||||
</div>
|
||||
<ul className="max-h-40 space-y-1 overflow-y-auto">
|
||||
{ports.map((port) => (
|
||||
<li key={port.id} className="flex items-center gap-1">
|
||||
<input
|
||||
className={`${inputClass} flex-1`}
|
||||
aria-label={`Port ${port.label} label`}
|
||||
value={port.label}
|
||||
onChange={(e) =>
|
||||
setLocalPorts((list) =>
|
||||
list.map((p) => (p.id === port.id ? { ...p, label: e.target.value } : p)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<select
|
||||
className={`${inputClass} w-24`}
|
||||
aria-label={`Port ${port.label} type`}
|
||||
value={port.type}
|
||||
onChange={(e) =>
|
||||
setLocalPorts((list) =>
|
||||
list.map((p) =>
|
||||
p.id === port.id ? { ...p, type: e.target.value as PortType } : p,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{(['rj45', 'sfp', 'sfp+'] as PortType[]).map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove port ${port.label}`}
|
||||
className="cursor-pointer px-1 text-xs text-muted-foreground hover:text-[#f85149]"
|
||||
onClick={() => setLocalPorts((list) => list.filter((p) => p.id !== port.id))}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{ports.length === 0 && (
|
||||
<li className="text-[11px] text-muted-foreground">No port on this plate.</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{isEdit && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleUnmount}
|
||||
className="cursor-pointer text-[#f85149] hover:bg-[#f8514922] hover:text-[#f85149]"
|
||||
>
|
||||
Unmount{device?.deviceId ? ' (stays in inventory)' : ''}
|
||||
</Button>
|
||||
)}
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} className="cursor-pointer">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="cursor-pointer bg-[#00d4ff] text-[#0d1117] hover:bg-[#00b8e0]"
|
||||
>
|
||||
{isEdit ? 'Save' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
<div
|
||||
@@ -267,6 +270,10 @@ export function RackFlowNode({ id }: NodeProps) {
|
||||
e.stopPropagation()
|
||||
selectDevice(device.id)
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation()
|
||||
openDeviceEditor(device.id)
|
||||
}}
|
||||
onMouseEnter={() => hoverDevice(device.id)}
|
||||
onMouseLeave={() => hoverDevice(null)}
|
||||
style={{
|
||||
|
||||
@@ -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 (
|
||||
<div className="mb-3">
|
||||
<span className={labelClass}>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<h2 className="mb-3 text-sm font-semibold">Rack settings</h2>
|
||||
|
||||
<Field label="Name">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={rack.name}
|
||||
onChange={(e) => updateRack(rack.id, { name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Location">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={rack.location ?? ''}
|
||||
onChange={(e) => updateRack(rack.id, { location: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={`Height — ${used}U used of ${rack.uHeight}U`}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={48}
|
||||
className={inputClass}
|
||||
value={rack.uHeight}
|
||||
onChange={(e) => updateRack(rack.id, { uHeight: Number(e.target.value) || 1 })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Width standard">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={rack.widthStandard}
|
||||
onChange={(e) =>
|
||||
updateRack(rack.id, { widthStandard: e.target.value as RackWidthStandard })
|
||||
}
|
||||
>
|
||||
<option value="19">19 inch</option>
|
||||
<option value="10">10 inch (mini)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="U numbering">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={rack.numbering}
|
||||
onChange={(e) => updateRack(rack.id, { numbering: e.target.value as RackNumbering })}
|
||||
>
|
||||
<option value="bottom-up">1 at the bottom</option>
|
||||
<option value="top-down">1 at the top</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<h3 className="mb-2 mt-4 text-xs font-semibold uppercase tracking-wide text-[#6e7681]">
|
||||
Style
|
||||
</h3>
|
||||
{(['frame', 'rail', 'interior'] as const).map((key) => (
|
||||
<Field key={key} label={key}>
|
||||
<input
|
||||
type="color"
|
||||
className="h-8 w-full cursor-pointer rounded border border-[#21262d] bg-[#0d1117]"
|
||||
value={rack.style[key]}
|
||||
onChange={(e) => updateRackStyle(rack.id, { [key]: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
<label className="mb-2 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rack.style.showNumbers}
|
||||
onChange={(e) => updateRackStyle(rack.id, { showNumbers: e.target.checked })}
|
||||
/>
|
||||
Show U numbers
|
||||
</label>
|
||||
<label className="mb-4 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rack.style.enclosed}
|
||||
onChange={(e) => updateRackStyle(rack.id, { enclosed: e.target.checked })}
|
||||
/>
|
||||
Enclosed cabinet
|
||||
</label>
|
||||
|
||||
<button
|
||||
className="w-full rounded border border-[#f85149] px-2 py-1.5 text-sm text-[#f85149] hover:bg-[#f8514922]"
|
||||
onClick={() => removeRack(rack.id)}
|
||||
>
|
||||
Delete rack
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<h2 className="mb-1 text-sm font-semibold">{device.label}</h2>
|
||||
<p className="mb-3 text-[11px] text-[#6e7681]">
|
||||
{device.deviceId
|
||||
? device.nodeId
|
||||
? 'From the Device Inventory · on a logical canvas'
|
||||
: 'From the Device Inventory'
|
||||
: 'Rack-only accessory'}
|
||||
</p>
|
||||
|
||||
<Field label="Label">
|
||||
<input
|
||||
className={inputClass}
|
||||
value={device.label}
|
||||
onChange={(e) => updateDevice(device.id, { label: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Faceplate">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={device.faceplateId}
|
||||
onChange={(e) => applyFaceplate(device.id, e.target.value)}
|
||||
>
|
||||
{faceplateGroups().map(({ group, items }) => (
|
||||
<optgroup key={group} label={group}>
|
||||
{items.map((plate) => (
|
||||
<option key={plate.id} value={plate.id}>
|
||||
{plate.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="U position">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className={inputClass}
|
||||
value={device.uStart}
|
||||
onChange={(e) => updateDevice(device.id, { uStart: Number(e.target.value) || 1 })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Height (U)">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className={inputClass}
|
||||
value={device.uHeight}
|
||||
onChange={(e) => updateDevice(device.id, { uHeight: Number(e.target.value) || 1 })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Column">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={RACK_COLUMNS - 1}
|
||||
className={inputClass}
|
||||
value={device.colStart}
|
||||
onChange={(e) => updateDevice(device.id, { colStart: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={`Width (/${RACK_COLUMNS})`}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={device.colSpan}
|
||||
onChange={(e) => updateDevice(device.id, { colSpan: Number(e.target.value) })}
|
||||
>
|
||||
<option value={RACK_COLUMNS}>Full width</option>
|
||||
<option value={RACK_COLUMNS / 2}>Half width</option>
|
||||
<option value={RACK_COLUMNS / 3}>Third width</option>
|
||||
<option value={RACK_COLUMNS / 4}>Quarter width</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Status">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={device.status}
|
||||
onChange={(e) => updateDevice(device.id, { status: e.target.value as DeviceStatus })}
|
||||
>
|
||||
<option value="online">Online</option>
|
||||
<option value="offline">Offline</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Colour override">
|
||||
<input
|
||||
type="color"
|
||||
className="h-8 w-full cursor-pointer rounded border border-[#21262d] bg-[#0d1117]"
|
||||
value={device.color ?? '#2b323c'}
|
||||
onChange={(e) => updateDevice(device.id, { color: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-[#6e7681]">
|
||||
Ports ({device.ports.length})
|
||||
</h3>
|
||||
<button
|
||||
className="rounded border border-[#21262d] px-2 py-0.5 text-xs hover:border-[#00d4ff]"
|
||||
onClick={() =>
|
||||
addPort(device.id, {
|
||||
label: `p${device.ports.length + 1}`,
|
||||
type: 'rj45',
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
})
|
||||
}
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
<ul className="max-h-56 space-y-1 overflow-y-auto">
|
||||
{device.ports.map((port) => {
|
||||
const cable = cableOf(port.id)
|
||||
return (
|
||||
<li key={port.id} className="flex items-center gap-1">
|
||||
<input
|
||||
className={`${inputClass} flex-1`}
|
||||
value={port.label}
|
||||
onChange={(e) => updatePort(device.id, port.id, { label: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
className={`${inputClass} w-24`}
|
||||
value={port.type}
|
||||
onChange={(e) =>
|
||||
updatePort(device.id, port.id, { type: e.target.value as PortType })
|
||||
}
|
||||
>
|
||||
{(['rj45', 'sfp', 'sfp+'] as PortType[]).map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span
|
||||
className="size-2 shrink-0 rounded-full"
|
||||
style={{ background: cable ? cable.color : '#30363d' }}
|
||||
title={cable ? `Patched — ${CABLE_TYPE_LABELS[cable.type as CableType]}` : 'Free'}
|
||||
/>
|
||||
<button
|
||||
className="px-1 text-xs text-[#6e7681] hover:text-[#f85149]"
|
||||
onClick={() => removePort(device.id, port.id)}
|
||||
title="Remove port"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
className="mt-4 w-full rounded border border-[#f85149] px-2 py-1.5 text-sm text-[#f85149] hover:bg-[#f8514922]"
|
||||
onClick={() => unmountDevice(device.id)}
|
||||
>
|
||||
Unmount {device.deviceId ? '(stays in inventory)' : ''}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RackInspector() {
|
||||
const selectedDeviceId = useRackStore((s) => s.selectedDeviceId)
|
||||
const selectedRackId = useRackStore((s) => s.selectedRackId)
|
||||
|
||||
return (
|
||||
<aside className="w-72 shrink-0 overflow-y-auto border-l border-border bg-[#161b22] p-3 text-sm text-foreground">
|
||||
{selectedDeviceId ? (
|
||||
<DeviceSettings deviceId={selectedDeviceId} />
|
||||
) : selectedRackId ? (
|
||||
<RackSettings rackId={selectedRackId} />
|
||||
) : (
|
||||
<p className="text-[11px] text-[#6e7681]">
|
||||
Select a rack or a mounted device to edit it.
|
||||
</p>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open onOpenChange={(o) => !o && close()}>
|
||||
<DialogContent className="max-w-md border-[#30363d] bg-[#161b22] text-foreground max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold">Rack settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-2 flex flex-col gap-3">
|
||||
<Field label="Name">
|
||||
<input
|
||||
className={inputClass}
|
||||
aria-label="Rack name"
|
||||
value={rack.name}
|
||||
onChange={(e) => updateRack(rack.id, { name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Location">
|
||||
<input
|
||||
className={inputClass}
|
||||
aria-label="Location"
|
||||
value={rack.location ?? ''}
|
||||
onChange={(e) => updateRack(rack.id, { location: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={`Height — ${used}U used of ${rack.uHeight}U`}>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={48}
|
||||
className={inputClass}
|
||||
aria-label="Rack height"
|
||||
value={rack.uHeight}
|
||||
onChange={(e) => updateRack(rack.id, { uHeight: Number(e.target.value) || 1 })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Width standard">
|
||||
<select
|
||||
className={inputClass}
|
||||
aria-label="Width standard"
|
||||
value={rack.widthStandard}
|
||||
onChange={(e) =>
|
||||
updateRack(rack.id, { widthStandard: e.target.value as RackWidthStandard })
|
||||
}
|
||||
>
|
||||
<option value="19">19 inch</option>
|
||||
<option value="10">10 inch (mini)</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="U numbering">
|
||||
<select
|
||||
className={inputClass}
|
||||
aria-label="U numbering"
|
||||
value={rack.numbering}
|
||||
onChange={(e) => updateRack(rack.id, { numbering: e.target.value as RackNumbering })}
|
||||
>
|
||||
<option value="bottom-up">1 at the bottom</option>
|
||||
<option value="top-down">1 at the top</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(['frame', 'rail', 'interior'] as const).map((key) => (
|
||||
<Field key={key} label={key}>
|
||||
<input
|
||||
type="color"
|
||||
aria-label={key}
|
||||
className="h-8 w-full cursor-pointer rounded border border-[#30363d] bg-[#21262d]"
|
||||
value={rack.style[key]}
|
||||
onChange={(e) => updateRackStyle(rack.id, { [key]: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rack.style.showNumbers}
|
||||
onChange={(e) => updateRackStyle(rack.id, { showNumbers: e.target.checked })}
|
||||
/>
|
||||
Show U numbers
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rack.style.enclosed}
|
||||
onChange={(e) => updateRackStyle(rack.id, { enclosed: e.target.checked })}
|
||||
/>
|
||||
Enclosed cabinet
|
||||
</label>
|
||||
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
removeRack(rack.id)
|
||||
close()
|
||||
}}
|
||||
className="cursor-pointer text-[#f85149] hover:bg-[#f8514922] hover:text-[#f85149]"
|
||||
>
|
||||
Delete rack
|
||||
</Button>
|
||||
<Button type="button" onClick={close} className="ml-auto cursor-pointer">
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
server: 'server-1u-bays',
|
||||
|
||||
+112
-48
@@ -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<void>
|
||||
@@ -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<Placement>) => 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<Placement> & { faceplateId?: string },
|
||||
) => string | null
|
||||
/** Mount a rack-only accessory (blank panel, shelf…). */
|
||||
mountAccessory: (faceplateId: string, rackId: string, desired: Partial<Placement>) => string | null
|
||||
moveDevice: (deviceId: string, rackId: string, desired: Placement) => boolean
|
||||
updateDevice: (id: string, patch: Partial<Omit<RackDevice, 'id' | 'ports'>>) => 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<Omit<RackDevice, 'id' | 'ports'>>) => 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<Port, 'id'>) => void
|
||||
updatePort: (deviceId: string, portId: string, patch: Partial<Omit<Port, 'id'>>) => 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<Port, 'id'>)[]) => 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<RackState>((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<RackState>((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<RackState>((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<RackState>((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<RackState>((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<RackState>((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<RackState>((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({
|
||||
|
||||
@@ -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> = {}): 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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<SourceBucket, { color: string; label: string }> = {
|
||||
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<SourceBucket> {
|
||||
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<SourceBucket> {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user