feat(rack): show the logical view of a mounted device
The Edit Device modal left the column under the port list empty, while the logical canvas already held every technical fact about the same box. A mount that stands for a Device Inventory entry now prints them there: canvas name, type, hostname, IP, MAC, OS, the status check the node runs, the canvas it is drawn on, when it was last seen, and the services discovery fingerprinted on it. Read-only — the logical view owns them. `/racks/inventory` ships the node half as `node_*` alongside the inventory row's own mac/hostname/os/services, and the panel prefers the node value: the node is what the user curates, the inventory row is what discovery last saw and goes stale after a rename or a DHCP move. Rows neither side can fill are dropped, so a device on no canvas still shows what was reported and an accessory shows no panel at all. The new API fields are optional client-side, so an older backend reads back as "not on a canvas" rather than an empty node. ha-relevant: maybe
This commit is contained in:
committed by
Pouzor - Rémy Jardient
parent
57df3b9198
commit
e22152d2d3
@@ -15,6 +15,7 @@ from app.schemas.racks import (
|
||||
RackInventoryResponse,
|
||||
RackResponse,
|
||||
RackSaveRequest,
|
||||
RackServiceInfo,
|
||||
RackStateResponse,
|
||||
)
|
||||
|
||||
@@ -88,6 +89,32 @@ def _device_label(device: PendingDevice) -> str:
|
||||
)
|
||||
|
||||
|
||||
# A mount prints its services as a short list, not a port scan dump.
|
||||
_MAX_SERVICES = 12
|
||||
|
||||
|
||||
def _services(raw: Any) -> list[RackServiceInfo]:
|
||||
"""Fingerprinted services, deduped and trimmed for the rack's info panel."""
|
||||
out: list[RackServiceInfo] = []
|
||||
seen: set[tuple[int | None, str | None]] = set()
|
||||
for entry in raw or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
raw_port = entry.get("port")
|
||||
port = raw_port if isinstance(raw_port, int) else None
|
||||
raw_name = entry.get("service_name") or entry.get("name")
|
||||
name = str(raw_name) if raw_name else None
|
||||
if port is None and name is None:
|
||||
continue
|
||||
if (port, name) in seen:
|
||||
continue
|
||||
seen.add((port, name))
|
||||
out.append(RackServiceInfo(port=port, name=name))
|
||||
if len(out) == _MAX_SERVICES:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _ip_tokens(ip: str | None) -> list[str]:
|
||||
"""Split a device ``ip`` field into individual addresses.
|
||||
|
||||
@@ -263,6 +290,11 @@ async def rack_inventory(
|
||||
for node in nodes:
|
||||
for token in _ip_tokens(node.ip):
|
||||
by_ip.setdefault(token, node)
|
||||
# Which canvas the matched node lives on — the rack prints it so the user
|
||||
# knows where the logical twin of a mount is drawn.
|
||||
design_names = {
|
||||
d.id: d.name for d in (await db.execute(select(Design))).scalars().all()
|
||||
}
|
||||
|
||||
items = []
|
||||
for device in devices:
|
||||
@@ -271,6 +303,12 @@ async def rack_inventory(
|
||||
linked: Node | None = by_ieee.get(device.ieee_address) if device.ieee_address else None
|
||||
if linked is None:
|
||||
linked = next((by_ip[t] for t in _ip_tokens(device.ip) if t in by_ip), None)
|
||||
# The node is the richer record once a device has been approved onto a
|
||||
# canvas; the inventory row is what discovery last saw. Report both and
|
||||
# let the panel prefer the node.
|
||||
services = _services(device.services) or (
|
||||
_services(linked.services) if linked else []
|
||||
)
|
||||
items.append(
|
||||
RackInventoryItem(
|
||||
id=device.id,
|
||||
@@ -279,8 +317,24 @@ async def rack_inventory(
|
||||
ip=device.ip,
|
||||
status=device.status,
|
||||
discovery_source=device.discovery_source,
|
||||
mac=device.mac or device.ieee_address,
|
||||
hostname=device.hostname,
|
||||
os=device.os,
|
||||
services=services,
|
||||
node_id=linked.id if linked else None,
|
||||
node_status=linked.status if linked else None,
|
||||
node_label=linked.label if linked else None,
|
||||
node_type=linked.type if linked else None,
|
||||
node_ip=linked.ip if linked else None,
|
||||
node_mac=(linked.mac or linked.ieee_address) if linked else None,
|
||||
node_hostname=linked.hostname if linked else None,
|
||||
node_os=linked.os if linked else None,
|
||||
node_check_method=linked.check_method if linked else None,
|
||||
node_design_id=linked.design_id if linked else None,
|
||||
node_design_name=(
|
||||
design_names.get(linked.design_id) if linked and linked.design_id else None
|
||||
),
|
||||
node_last_seen=linked.last_seen if linked else None,
|
||||
racked=device.id in mounted,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
@@ -239,8 +240,20 @@ class RackStateResponse(BaseModel):
|
||||
viewport: dict[str, Any] = {}
|
||||
|
||||
|
||||
class RackServiceInfo(BaseModel):
|
||||
"""One service fingerprinted on a device, as the rack view prints it."""
|
||||
|
||||
port: int | None = None
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class RackInventoryItem(BaseModel):
|
||||
"""A Device Inventory entry offered to the rack tray."""
|
||||
"""A Device Inventory entry offered to the rack tray.
|
||||
|
||||
Carries the technical facts the logical canvas already knows about the same
|
||||
hardware (`node_*`), so a mount can print them without the user leaving the
|
||||
rack. Everything node-side is read-only here: the logical canvas owns it.
|
||||
"""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
@@ -248,9 +261,25 @@ class RackInventoryItem(BaseModel):
|
||||
ip: str | None = None
|
||||
status: str
|
||||
discovery_source: str | None = None
|
||||
# Inventory-side technical detail, from discovery.
|
||||
mac: str | None = None
|
||||
hostname: str | None = None
|
||||
os: str | None = None
|
||||
services: list[RackServiceInfo] = []
|
||||
# Live status of the matching canvas node, when the device is on one.
|
||||
node_id: str | None = None
|
||||
node_status: str | None = None
|
||||
# What that node holds — the logical view of the same box.
|
||||
node_label: str | None = None
|
||||
node_type: str | None = None
|
||||
node_ip: str | None = None
|
||||
node_mac: str | None = None
|
||||
node_hostname: str | None = None
|
||||
node_os: str | None = None
|
||||
node_check_method: str | None = None
|
||||
node_design_id: str | None = None
|
||||
node_design_name: str | None = None
|
||||
node_last_seen: datetime | None = None
|
||||
# True when this device is already mounted somewhere in this design.
|
||||
racked: bool = False
|
||||
|
||||
|
||||
@@ -461,6 +461,100 @@ class TestInventory:
|
||||
assert entry["node_id"] == node["id"]
|
||||
assert entry["node_status"] == "online"
|
||||
|
||||
async def test_reports_what_the_canvas_node_knows(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""The rack prints the logical view's facts, so the endpoint must ship them."""
|
||||
rack_design = await _design(client, headers)
|
||||
network = await _design(client, headers, name="Network", design_type="network")
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={
|
||||
"type": "nas",
|
||||
"label": "nas-truenas",
|
||||
"design_id": network,
|
||||
"ip": "192.168.1.9",
|
||||
"mac": "aa:bb:cc:dd:ee:ff",
|
||||
"hostname": "nas.lan",
|
||||
"os": "TrueNAS SCALE",
|
||||
"status": "online",
|
||||
"check_method": "http",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
# Straight to the table: the manual-create schema carries no os/services,
|
||||
# and a scan find does.
|
||||
db_session.add(
|
||||
PendingDevice(
|
||||
id="pd-nas",
|
||||
hostname="nas",
|
||||
ip="192.168.1.9",
|
||||
mac="aa:bb:cc:dd:ee:ff",
|
||||
os="TrueNAS",
|
||||
suggested_type="nas",
|
||||
services=[{"port": 445, "service_name": "smb", "category": "storage"}],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={rack_design}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["ip"] == "192.168.1.9")
|
||||
assert entry["mac"] == "aa:bb:cc:dd:ee:ff"
|
||||
assert entry["os"] == "TrueNAS"
|
||||
assert entry["services"] == [{"port": 445, "name": "smb"}]
|
||||
assert entry["node_label"] == "nas-truenas"
|
||||
assert entry["node_type"] == "nas"
|
||||
assert entry["node_hostname"] == "nas.lan"
|
||||
assert entry["node_os"] == "TrueNAS SCALE"
|
||||
assert entry["node_check_method"] == "http"
|
||||
assert entry["node_design_id"] == network
|
||||
assert entry["node_design_name"] == "Network"
|
||||
|
||||
async def test_leaves_the_node_fields_empty_without_a_match(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
design_id = await _design(client, headers)
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "pdu-main", "suggested_type": "generic"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["label"] == "pdu-main")
|
||||
assert entry["node_id"] is None
|
||||
assert entry["node_label"] is None
|
||||
assert entry["node_design_name"] is None
|
||||
assert entry["services"] == []
|
||||
|
||||
async def test_falls_back_to_the_node_services(self, client: AsyncClient, headers, db_session):
|
||||
"""An approved device keeps its fingerprint on the node, not the inventory row."""
|
||||
design_id = await _design(client, headers)
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={
|
||||
"type": "server",
|
||||
"label": "media",
|
||||
"ip": "192.168.1.63",
|
||||
"services": [{"port": 8096, "service_name": "jellyfin", "category": "media"}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
db_session.add(
|
||||
PendingDevice(id="pd-noservices", ip="192.168.1.63", suggested_type="server")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["id"] == "pd-noservices")
|
||||
assert entry["services"] == [{"port": 8096, "name": "jellyfin"}]
|
||||
|
||||
async def test_manual_entry_is_tagged_as_such(self, client: AsyncClient, headers):
|
||||
created = (
|
||||
await client.post(
|
||||
|
||||
@@ -91,6 +91,17 @@ Then set:
|
||||
click only selects it. **Unmount** takes the device out of the rack — it stays in
|
||||
the Device Inventory.
|
||||
|
||||
### Logical view 🔒
|
||||
|
||||
Under the port list, a mount that stands for a real device prints what the
|
||||
logical canvas already knows about the same box: canvas name, type, hostname,
|
||||
IP, MAC, OS, the status check it runs, the canvas it is drawn on, when it was
|
||||
last seen, and the services discovery fingerprinted on it.
|
||||
|
||||
It is read-only — edit those facts on the logical canvas, not in the rack. A
|
||||
device that is on no logical canvas still shows whatever discovery reported, and
|
||||
an accessory shows nothing at all.
|
||||
|
||||
### Placement rules
|
||||
|
||||
- A drop **snaps to the nearest free slot**; a placement that cannot work
|
||||
|
||||
@@ -18,6 +18,7 @@ Create one from **New Canvas → Kind → Rack**.
|
||||
| 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. Its `suggested_type` comes from the plate it wears (`deviceTypeForFaceplate`), since no scan is there to guess one: without it the row had no icon, no role badge and no place in the type filter. Passive gear uses the rack-only kinds `patch_panel` and `pdu` — the logical canvas has no node type for it, and `pdu` must not be `socket`, which is excluded from the rack inventory. |
|
||||
| Picking one | The **Device Inventory entry** field opens the real Device Inventory modal (`PendingDevicesModal` in picker mode: a card click returns the device, bulk/clear controls are hidden) rather than a flat `<select>`, so the user gets its search, source/type filters and per-card detail. It opens with the **Rackable** filter armed — hardware only, `UNRACKABLE_TYPES` in `utils/rackable.ts` mirroring `_UNRACKABLE_TYPES` in `racks.py`, with `test_rackable_sync.py` failing on drift. The rack side still has the last word: a device missing from its own list, or already mounted in this design, is refused with a toast. Standalone keeps the `<select>` — no backend, no inventory modal. |
|
||||
| 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. |
|
||||
| Logical view panel | `LinkedDevicePanel`, under the port list in `RackDeviceModal`: the facts the logical canvas holds about the same box — name, type, hostname, IP, MAC, OS, status check, the canvas it is drawn on, last seen, and the fingerprinted services. Read-only; the logical view owns them. `/racks/inventory` ships them as `node_*` alongside the inventory row's own `mac` / `hostname` / `os` / `services`, and the panel prefers the node value (curated) over the inventory one (what discovery last saw), dropping any row neither side can fill. A mount with no inventory entry — an accessory — gets no panel. |
|
||||
| Status | A mount stores a `MountStatus`: `online` / `offline` / `unknown` pinned by hand, or `auto` — "check device", i.e. follow the status check already configured on the matching canvas node (ping, http, ssh…). The rack runs no checker of its own; `auto` resolves through the inventory entry's `node_status` (`resolveDeviceStatus` in `deviceStatus.ts`) and falls back to `unknown` when nothing is behind it. The Status select offers `auto` only when the mount — or the entry being picked — resolves to a node, and drops back to `unknown` when that link is lost. `useAutoStatusRefresh` polls the inventory every 60 s while at least one mount is on `auto`, and only then; the refresh writes inventory only, so autosave stays quiet. |
|
||||
| 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. Chosen from `FaceplatePicker` — a visual catalog, not a `<select>`: every template is drawn with the canvas renderer at its real relative width and U height, grouped, searchable, and filterable by `kind` (accessories are offered only to accessories). |
|
||||
| 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. |
|
||||
@@ -52,7 +53,7 @@ types in `@/types/rack`, narrowing every enum on the way in.
|
||||
- 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.
|
||||
- The **Faceplate** field previews the current plate; clicking it opens `FaceplatePicker`. Switching the source between device and accessory swaps the plate to a sane default of the other kind.
|
||||
- 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 a plate → the same modal in edit mode: label, faceplate, U/height/column/width, status, colour, port list, the **Logical view** panel, 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.
|
||||
- Shrinking a **rack** follows the same rule: `updateRack` clamps `uHeight` to `[MIN_RACK_U, MAX_RACK_U]` (1–48; the backend tolerates 100) and relocates every mount the new height would push above the top rail, one at a time so two never land on the same slot. It returns false and changes nothing when one has nowhere to go. The number input's `min`/`max` are hints the browser does not enforce on typed input — a raw 999 used to make every save fail the backend's `1..100` check with no cause shown, and a shrink left plates drawn outside the chassis that nothing could drag back.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/** The logical-canvas facts printed beside a mount's ports. */
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { LinkedDevicePanel } from '../components/LinkedDevicePanel'
|
||||
import type { InventoryDevice } from '@/types'
|
||||
|
||||
function makeEntry(overrides: Partial<InventoryDevice> = {}): InventoryDevice {
|
||||
return {
|
||||
id: 'inv1',
|
||||
label: 'nas',
|
||||
type: 'nas',
|
||||
ip: '192.168.1.9',
|
||||
mac: 'aa:bb:cc:dd:ee:ff',
|
||||
hostname: 'nas.lan',
|
||||
os: 'TrueNAS',
|
||||
services: [{ port: 445, name: 'smb' }],
|
||||
status: 'online',
|
||||
nodeId: 'node1',
|
||||
node: {
|
||||
id: 'node1',
|
||||
label: 'nas-truenas',
|
||||
type: 'nas',
|
||||
ip: '192.168.1.9',
|
||||
mac: 'aa:bb:cc:dd:ee:ff',
|
||||
hostname: 'nas.lan',
|
||||
os: 'TrueNAS SCALE',
|
||||
checkMethod: 'http',
|
||||
designId: 'd1',
|
||||
designName: 'Network',
|
||||
lastSeen: null,
|
||||
},
|
||||
racked: true,
|
||||
suggestedFaceplateId: 'nas-2u',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('LinkedDevicePanel', () => {
|
||||
it('renders nothing for a mount with no inventory entry', () => {
|
||||
const { container } = render(<LinkedDevicePanel entry={null} status="unknown" />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('prints the linked node facts and its services', () => {
|
||||
render(<LinkedDevicePanel entry={makeEntry()} status="online" />)
|
||||
|
||||
expect(screen.getByText('nas-truenas')).toBeInTheDocument()
|
||||
expect(screen.getByText('192.168.1.9')).toBeInTheDocument()
|
||||
expect(screen.getByText('aa:bb:cc:dd:ee:ff')).toBeInTheDocument()
|
||||
expect(screen.getByText('nas.lan')).toBeInTheDocument()
|
||||
expect(screen.getByText('http')).toBeInTheDocument()
|
||||
expect(screen.getByText('Network')).toBeInTheDocument()
|
||||
expect(screen.getByText('smb:445')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('prefers the node value over the inventory row it disagrees with', () => {
|
||||
// The node is what the user curates; the inventory row is what discovery
|
||||
// last saw, and it goes stale after a rename or a DHCP move.
|
||||
const entry = makeEntry({
|
||||
ip: '192.168.1.99',
|
||||
node: { ...makeEntry().node!, ip: '192.168.1.9' },
|
||||
})
|
||||
render(<LinkedDevicePanel entry={entry} status="online" />)
|
||||
|
||||
expect(screen.getByText('192.168.1.9')).toBeInTheDocument()
|
||||
expect(screen.queryByText('192.168.1.99')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to the inventory row when the device is on no canvas', () => {
|
||||
render(<LinkedDevicePanel entry={makeEntry({ nodeId: null, node: null })} status="unknown" />)
|
||||
|
||||
expect(screen.getByText(/Not on a logical canvas/)).toBeInTheDocument()
|
||||
expect(screen.getByText('192.168.1.9')).toBeInTheDocument()
|
||||
expect(screen.getByText('TrueNAS')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the status it was handed, not the inventory row own', () => {
|
||||
// The plate resolves `auto` through the inventory; the panel must agree with
|
||||
// the LED next to it rather than resolve a second time.
|
||||
render(<LinkedDevicePanel entry={makeEntry()} status="offline" />)
|
||||
expect(screen.getByText('offline')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skips the rows nothing knows about', () => {
|
||||
const bare: InventoryDevice = {
|
||||
id: 'inv2',
|
||||
label: 'patch panel',
|
||||
type: null,
|
||||
status: 'unknown',
|
||||
nodeId: null,
|
||||
racked: false,
|
||||
suggestedFaceplateId: 'patch-24',
|
||||
}
|
||||
render(<LinkedDevicePanel entry={bare} status="unknown" />)
|
||||
|
||||
expect(screen.getByText(/Nothing known about this device yet/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('IP')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Services')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('prints a last-seen timestamp only when it parses', () => {
|
||||
const node = makeEntry().node!
|
||||
render(<LinkedDevicePanel entry={makeEntry({ node: { ...node, lastSeen: 'not-a-date' } })} status="online" />)
|
||||
expect(screen.queryByText('Last seen')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -573,4 +573,24 @@ describe('RackDeviceModal — editing', () => {
|
||||
await waitFor(() => expect(store().deviceEditor).toBeNull())
|
||||
expect(store().devices.find((d) => d.id === 'dev-pve1')!.status).toBe('unknown')
|
||||
})
|
||||
|
||||
it('prints what the logical canvas knows about the mounted device', () => {
|
||||
store().openDeviceEditor('dev-pve1')
|
||||
render(<RackDeviceModal />)
|
||||
|
||||
const entry = store().inventory.find((i) => i.id === 'inv-pve1')!
|
||||
const panel = screen.getByLabelText('Logical view')
|
||||
expect(panel).toHaveTextContent(entry.node!.hostname!)
|
||||
expect(panel).toHaveTextContent(entry.ip!)
|
||||
expect(panel).toHaveTextContent(entry.mac!)
|
||||
expect(panel).toHaveTextContent('proxmox')
|
||||
})
|
||||
|
||||
it('leaves an accessory without a logical view — it stands for no device', () => {
|
||||
store().openDeviceEditor('dev-shelf')
|
||||
render(<RackDeviceModal />)
|
||||
|
||||
expect(store().devices.find((d) => d.id === 'dev-shelf')!.deviceId).toBeNull()
|
||||
expect(screen.queryByLabelText('Logical view')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* What the logical canvas knows about a mounted device.
|
||||
*
|
||||
* A mount points at a Device Inventory entry, which the backend correlates with
|
||||
* a canvas node (IEEE, then IP). Everything here is read-only: the logical view
|
||||
* owns these facts, the rack only prints them so the user does not have to open
|
||||
* the other canvas to read an IP off it.
|
||||
*
|
||||
* Node values win over the inventory row's — the node is the record the user
|
||||
* curates, the inventory row is what discovery last saw — and each row is
|
||||
* dropped when neither side has anything to show.
|
||||
*/
|
||||
import { useRackPalette } from '../rackTheme'
|
||||
import type { DeviceStatus, InventoryDevice } from '@/types'
|
||||
|
||||
interface Row {
|
||||
label: string
|
||||
value: string
|
||||
/** Technical values (IP, MAC) are set in JetBrains Mono, per the design system. */
|
||||
mono?: boolean
|
||||
}
|
||||
|
||||
function firstOf(...values: (string | null | undefined)[]): string | null {
|
||||
for (const value of values) {
|
||||
const trimmed = value?.trim()
|
||||
if (trimmed) return trimmed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Absolute local time — a rack is read at a glance, "3 days ago" hides the date. */
|
||||
function formatSeen(iso: string | null | undefined): string | null {
|
||||
if (!iso) return null
|
||||
const date = new Date(iso)
|
||||
return Number.isNaN(date.getTime()) ? null : date.toLocaleString()
|
||||
}
|
||||
|
||||
function serviceLabel(service: { port: number | null; name: string | null }): string {
|
||||
if (service.name && service.port !== null) return `${service.name}:${service.port}`
|
||||
return service.name ?? String(service.port)
|
||||
}
|
||||
|
||||
export function LinkedDevicePanel({
|
||||
entry,
|
||||
status,
|
||||
}: {
|
||||
/** The inventory row behind the mount. Absent for accessories. */
|
||||
entry: InventoryDevice | null | undefined
|
||||
/** Status already resolved for the plate, so both agree on `auto`. */
|
||||
status: DeviceStatus
|
||||
}) {
|
||||
const palette = useRackPalette()
|
||||
if (!entry) return null
|
||||
|
||||
const node = entry.node ?? null
|
||||
const rows: Row[] = []
|
||||
const push = (label: string, value: string | null, mono = false) => {
|
||||
if (value) rows.push({ label, value, mono })
|
||||
}
|
||||
|
||||
push('Canvas name', firstOf(node?.label))
|
||||
push('Type', firstOf(node?.type, entry.type))
|
||||
push('Hostname', firstOf(node?.hostname, entry.hostname))
|
||||
push('IP', firstOf(node?.ip, entry.ip), true)
|
||||
push('MAC', firstOf(node?.mac, entry.mac), true)
|
||||
push('OS', firstOf(node?.os, entry.os))
|
||||
push('Check', firstOf(node?.checkMethod))
|
||||
push('Canvas', firstOf(node?.designName))
|
||||
push('Last seen', formatSeen(node?.lastSeen))
|
||||
|
||||
const services = entry.services ?? []
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Logical view"
|
||||
className="rounded border border-[#30363d] bg-[#0d1117] p-2.5"
|
||||
>
|
||||
<header className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium">Logical view</span>
|
||||
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: palette.status[status] }}
|
||||
/>
|
||||
{status}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{!node && (
|
||||
<p className="mb-2 text-[11px] text-muted-foreground">
|
||||
Not on a logical canvas — the facts below are what discovery reported.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rows.length === 0 && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Nothing known about this device yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||
{rows.map((row) => (
|
||||
<div key={row.label} className="contents">
|
||||
<dt className="text-[11px] text-muted-foreground">{row.label}</dt>
|
||||
<dd className={`truncate text-[11px] ${row.mono ? 'font-mono' : ''}`} title={row.value}>
|
||||
{row.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
{services.length > 0 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
<span className="text-[11px] text-muted-foreground">Services</span>
|
||||
<ul className="flex flex-wrap gap-1">
|
||||
{services.map((service) => (
|
||||
<li
|
||||
key={serviceLabel(service)}
|
||||
className="rounded border border-[#30363d] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
|
||||
>
|
||||
{serviceLabel(service)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import type { PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
import { useRackStore } from '../store'
|
||||
import { FaceplatePicker } from './FaceplatePicker'
|
||||
import { Faceplate } from './Faceplate'
|
||||
import { LinkedDevicePanel } from './LinkedDevicePanel'
|
||||
import { deviceTypeForFaceplate, getFaceplate } from '../faceplates'
|
||||
import { findSlot } from '../layout'
|
||||
import { canFollowNode, resolveDeviceStatus } from '../deviceStatus'
|
||||
@@ -581,6 +582,7 @@ function DeviceForm({ deviceId, onClose }: { deviceId: string | null; onClose: (
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className={fieldLabel}>Ports ({ports.length})</Label>
|
||||
@@ -647,6 +649,12 @@ function DeviceForm({ deviceId, onClose }: { deviceId: string | null; onClose: (
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* The column below the port list used to be dead space. A mount
|
||||
that stands for an inventory entry fills it with what the
|
||||
logical canvas knows about the same box. */}
|
||||
<LinkedDevicePanel entry={mountEntry} status={previewStatus} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
|
||||
@@ -8,7 +8,15 @@
|
||||
*/
|
||||
import { getFaceplate } from './faceplates'
|
||||
import { CABLE_COLORS, DEFAULT_RACK_STYLE } from './rackDefaults'
|
||||
import type { Cable, CableType, InventoryDevice, Port, Rack, RackDevice } from '@/types'
|
||||
import type {
|
||||
Cable,
|
||||
CableType,
|
||||
InventoryDevice,
|
||||
InventoryService,
|
||||
Port,
|
||||
Rack,
|
||||
RackDevice,
|
||||
} from '@/types'
|
||||
|
||||
let portSeq = 0
|
||||
function withIds(ports: Omit<Port, 'id'>[], deviceId: string): Port[] {
|
||||
@@ -22,12 +30,14 @@ interface DemoInventorySpec {
|
||||
ip?: string
|
||||
status: InventoryDevice['status']
|
||||
suggestedFaceplateId: string
|
||||
os?: string
|
||||
services?: InventoryService[]
|
||||
/** Omitted for gear that was never placed on a logical canvas. */
|
||||
onCanvas?: false
|
||||
}
|
||||
|
||||
const DEMO_INVENTORY: DemoInventorySpec[] = [
|
||||
{ id: 'inv-pve1', label: 'pve-01', type: 'proxmox', ip: '192.168.1.10', status: 'online', suggestedFaceplateId: 'server-2u-bays' },
|
||||
{ id: 'inv-pve1', label: 'pve-01', type: 'proxmox', ip: '192.168.1.10', status: 'online', suggestedFaceplateId: 'server-2u-bays', os: 'Debian 12', services: [{ port: 8006, name: 'proxmox' }, { port: 22, name: 'ssh' }] },
|
||||
{ id: 'inv-pve2', label: 'pve-02', type: 'proxmox', ip: '192.168.1.11', status: 'online', suggestedFaceplateId: 'server-1u-bays' },
|
||||
{ id: 'inv-nuc1', label: 'nuc-k3s-a', type: 'server', ip: '192.168.1.21', status: 'online', suggestedFaceplateId: 'sff-half' },
|
||||
{ id: 'inv-nuc2', label: 'nuc-k3s-b', type: 'server', ip: '192.168.1.22', status: 'online', suggestedFaceplateId: 'sff-half' },
|
||||
@@ -36,7 +46,7 @@ const DEMO_INVENTORY: DemoInventorySpec[] = [
|
||||
{ id: 'inv-sw24', label: 'sw-core-24', type: 'switch', ip: '192.168.1.2', status: 'online', suggestedFaceplateId: 'switch-24' },
|
||||
{ id: 'inv-sw8', label: 'sw-lab-8', type: 'switch', ip: '192.168.1.3', status: 'online', suggestedFaceplateId: 'switch-8' },
|
||||
{ id: 'inv-fw', label: 'opnsense', type: 'firewall', ip: '192.168.1.1', status: 'online', suggestedFaceplateId: 'router-1u' },
|
||||
{ id: 'inv-nas', label: 'nas-truenas', type: 'nas', ip: '192.168.1.40', status: 'online', suggestedFaceplateId: 'nas-2u' },
|
||||
{ id: 'inv-nas', label: 'nas-truenas', type: 'nas', ip: '192.168.1.40', status: 'online', suggestedFaceplateId: 'nas-2u', os: 'TrueNAS SCALE', services: [{ port: 445, name: 'smb' }, { port: 443, name: 'https' }] },
|
||||
{ id: 'inv-jbod', label: 'jbod-shelf', type: 'server', ip: '192.168.1.41', status: 'unknown', suggestedFaceplateId: 'server-4u-storage' },
|
||||
{ id: 'inv-ups', label: 'ups-eaton', type: 'ups', ip: '192.168.1.50', status: 'online', suggestedFaceplateId: 'ups-2u' },
|
||||
// Dumb hardware: documented by hand, never on a logical canvas.
|
||||
@@ -51,17 +61,49 @@ export function demoNodeId(inventoryId: string): string | null {
|
||||
return `node-${inventoryId}`
|
||||
}
|
||||
|
||||
/** Stable fake MAC, derived from the id so fixtures never shift between runs. */
|
||||
function demoMac(index: number): string {
|
||||
const pair = (n: number) => n.toString(16).padStart(2, '0')
|
||||
return `aa:bb:cc:${pair(index)}:${pair(index * 7 + 1)}:${pair(index * 13 + 2)}`
|
||||
}
|
||||
|
||||
export function demoInventory(): InventoryDevice[] {
|
||||
return DEMO_INVENTORY.map((spec) => ({
|
||||
id: spec.id,
|
||||
label: spec.label,
|
||||
type: spec.type,
|
||||
ip: spec.ip ?? null,
|
||||
status: spec.status,
|
||||
nodeId: demoNodeId(spec.id),
|
||||
racked: false,
|
||||
suggestedFaceplateId: spec.suggestedFaceplateId,
|
||||
}))
|
||||
return DEMO_INVENTORY.map((spec, index) => {
|
||||
const nodeId = demoNodeId(spec.id)
|
||||
const mac = demoMac(index)
|
||||
const hostname = `${spec.label}.lan`
|
||||
return {
|
||||
id: spec.id,
|
||||
label: spec.label,
|
||||
type: spec.type,
|
||||
ip: spec.ip ?? null,
|
||||
mac,
|
||||
hostname,
|
||||
os: spec.os ?? null,
|
||||
services: spec.services ?? [],
|
||||
status: spec.status,
|
||||
nodeId,
|
||||
// The demo canvas the sample gear is drawn on, so the mount's info panel
|
||||
// has something to show without a backend.
|
||||
node: nodeId
|
||||
? {
|
||||
id: nodeId,
|
||||
label: spec.label,
|
||||
type: spec.type,
|
||||
ip: spec.ip ?? null,
|
||||
mac,
|
||||
hostname,
|
||||
os: spec.os ?? null,
|
||||
checkMethod: 'ping',
|
||||
designId: 'demo-network',
|
||||
designName: 'Network',
|
||||
lastSeen: null,
|
||||
}
|
||||
: null,
|
||||
racked: false,
|
||||
suggestedFaceplateId: spec.suggestedFaceplateId,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function demoRacks(): Rack[] {
|
||||
|
||||
@@ -199,6 +199,36 @@ export type CableVisibility = 'hover' | 'always' | 'hidden'
|
||||
// Inventory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** One fingerprinted service, as the mount's info panel prints it. */
|
||||
export interface InventoryService {
|
||||
port: number | null
|
||||
name: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The logical-canvas view of the same hardware, read-only in the rack.
|
||||
*
|
||||
* Resolved server-side from the inventory entry (IEEE, then IP). Present only
|
||||
* once the device has been approved onto a canvas.
|
||||
*/
|
||||
export interface LinkedNodeInfo {
|
||||
id: string
|
||||
label: string | null
|
||||
/** `NodeType` on the canvas; free-form here, the rack only prints it. */
|
||||
type: string | null
|
||||
ip: string | null
|
||||
mac: string | null
|
||||
hostname: string | null
|
||||
os: string | null
|
||||
/** Status check the node runs — what `auto` on a mount actually follows. */
|
||||
checkMethod: string | null
|
||||
/** Canvas the node is drawn on. */
|
||||
designId: string | null
|
||||
designName: string | null
|
||||
/** ISO timestamp of the last successful check, when the node has one. */
|
||||
lastSeen: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A Device Inventory entry offered to the rack tray.
|
||||
*
|
||||
@@ -212,10 +242,18 @@ export interface InventoryDevice {
|
||||
/** `suggested_type` from discovery; free-form, may be unknown to the UI. */
|
||||
type: string | null
|
||||
ip?: string | null
|
||||
/** MAC, or the IEEE address for a mesh device. */
|
||||
mac?: string | null
|
||||
hostname?: string | null
|
||||
os?: string | null
|
||||
/** Services discovery fingerprinted, falling back to the canvas node's. */
|
||||
services?: InventoryService[]
|
||||
/** Live status of the matching canvas node, `unknown` when there is none. */
|
||||
status: DeviceStatus
|
||||
/** Matching canvas node, when the device is on a logical canvas. */
|
||||
nodeId: string | null
|
||||
/** What that node holds. Null when the device is on no canvas. */
|
||||
node?: LinkedNodeInfo | null
|
||||
/** Already mounted somewhere in this rack design. */
|
||||
racked: boolean
|
||||
/** Faceplate proposed when the device is dropped into a rack. */
|
||||
|
||||
@@ -186,6 +186,68 @@ describe('API → domain', () => {
|
||||
expect(item.status).toBe('unknown')
|
||||
expect(item.racked).toBe(true)
|
||||
})
|
||||
|
||||
it('carries the linked node detail the rack prints beside a mount', () => {
|
||||
const item = toInventoryDevice({
|
||||
id: 'inv3',
|
||||
label: 'nas',
|
||||
suggested_type: 'nas',
|
||||
ip: '192.168.1.9',
|
||||
status: 'approved',
|
||||
discovery_source: 'arp',
|
||||
mac: 'aa:bb:cc:dd:ee:ff',
|
||||
hostname: 'nas.lan',
|
||||
os: 'TrueNAS',
|
||||
services: [
|
||||
{ port: 445, name: 'smb' },
|
||||
// Neither a port nor a name: nothing to print.
|
||||
{ port: null, name: null },
|
||||
],
|
||||
node_id: 'node1',
|
||||
node_status: 'online',
|
||||
node_label: 'nas-truenas',
|
||||
node_type: 'nas',
|
||||
node_ip: '192.168.1.9',
|
||||
node_mac: 'aa:bb:cc:dd:ee:ff',
|
||||
node_hostname: 'nas.lan',
|
||||
node_os: 'TrueNAS SCALE',
|
||||
node_check_method: 'http',
|
||||
node_design_id: 'd1',
|
||||
node_design_name: 'Network',
|
||||
node_last_seen: '2026-08-08T10:00:00Z',
|
||||
racked: false,
|
||||
})
|
||||
|
||||
expect(item.mac).toBe('aa:bb:cc:dd:ee:ff')
|
||||
expect(item.hostname).toBe('nas.lan')
|
||||
expect(item.services).toEqual([{ port: 445, name: 'smb' }])
|
||||
expect(item.node).toMatchObject({
|
||||
id: 'node1',
|
||||
label: 'nas-truenas',
|
||||
os: 'TrueNAS SCALE',
|
||||
checkMethod: 'http',
|
||||
designName: 'Network',
|
||||
lastSeen: '2026-08-08T10:00:00Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports no linked node when the backend matched none', () => {
|
||||
// An older backend sends none of the node_* fields at all; the panel must
|
||||
// read that as "not on a canvas", never as an empty node.
|
||||
const item = toInventoryDevice({
|
||||
id: 'inv4',
|
||||
label: 'pdu',
|
||||
suggested_type: null,
|
||||
ip: null,
|
||||
status: 'pending',
|
||||
discovery_source: 'rack',
|
||||
node_id: null,
|
||||
node_status: null,
|
||||
racked: false,
|
||||
})
|
||||
expect(item.node).toBeNull()
|
||||
expect(item.services).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('domain → API', () => {
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
CableType,
|
||||
DeviceStatus,
|
||||
InventoryDevice,
|
||||
InventoryService,
|
||||
LinkedNodeInfo,
|
||||
MountStatus,
|
||||
Port,
|
||||
PortType,
|
||||
@@ -87,6 +89,22 @@ export interface ApiInventoryItem {
|
||||
node_id: string | null
|
||||
node_status: string | null
|
||||
racked: boolean
|
||||
// Technical detail, added after the first release of the endpoint — optional
|
||||
// so an older backend (or a fixture) still deserializes.
|
||||
mac?: string | null
|
||||
hostname?: string | null
|
||||
os?: string | null
|
||||
services?: { port?: number | null; name?: string | null }[] | null
|
||||
node_label?: string | null
|
||||
node_type?: string | null
|
||||
node_ip?: string | null
|
||||
node_mac?: string | null
|
||||
node_hostname?: string | null
|
||||
node_os?: string | null
|
||||
node_check_method?: string | null
|
||||
node_design_id?: string | null
|
||||
node_design_name?: string | null
|
||||
node_last_seen?: string | null
|
||||
}
|
||||
|
||||
export interface RackSavePayload {
|
||||
@@ -227,16 +245,50 @@ export function toCable(api: ApiRackCable): Cable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops the records with neither a port nor a name — they print as an empty chip. */
|
||||
function asServices(raw: ApiInventoryItem['services']): InventoryService[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.map((s) => ({
|
||||
port: typeof s?.port === 'number' ? s.port : null,
|
||||
name: s?.name ? String(s.name) : null,
|
||||
}))
|
||||
.filter((s) => s.port !== null || s.name !== null)
|
||||
}
|
||||
|
||||
/** The node half of an inventory row, when the backend matched one. */
|
||||
function asLinkedNode(api: ApiInventoryItem): LinkedNodeInfo | null {
|
||||
if (!api.node_id) return null
|
||||
return {
|
||||
id: api.node_id,
|
||||
label: api.node_label ?? null,
|
||||
type: api.node_type ?? null,
|
||||
ip: api.node_ip ?? null,
|
||||
mac: api.node_mac ?? null,
|
||||
hostname: api.node_hostname ?? null,
|
||||
os: api.node_os ?? null,
|
||||
checkMethod: api.node_check_method ?? null,
|
||||
designId: api.node_design_id ?? null,
|
||||
designName: api.node_design_name ?? null,
|
||||
lastSeen: api.node_last_seen ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function toInventoryDevice(api: ApiInventoryItem): InventoryDevice {
|
||||
return {
|
||||
id: api.id,
|
||||
label: api.label,
|
||||
type: api.suggested_type,
|
||||
ip: api.ip,
|
||||
mac: api.mac ?? null,
|
||||
hostname: api.hostname ?? null,
|
||||
os: api.os ?? null,
|
||||
services: asServices(api.services),
|
||||
// The inventory row itself is only pending/approved; live status comes from
|
||||
// the linked canvas node, if any.
|
||||
status: asStatus(api.node_status),
|
||||
nodeId: api.node_id,
|
||||
node: asLinkedNode(api),
|
||||
racked: api.racked,
|
||||
suggestedFaceplateId: suggestFaceplate(api.suggested_type),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user