From e22152d2d356e8054fb77549c2abb76cd5ca5444 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Sun, 9 Aug 2026 21:49:26 +0200 Subject: [PATCH] feat(rack): show the logical view of a mounted device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/routes/racks.py | 54 ++++++++ backend/app/schemas/racks.py | 31 ++++- backend/tests/test_racks.py | 94 +++++++++++++ docs/rack-canvas.md | 11 ++ frontend/src/rack/README.md | 3 +- .../rack/__tests__/LinkedDevicePanel.test.tsx | 106 ++++++++++++++ .../rack/__tests__/RackDeviceModal.test.tsx | 20 +++ .../src/rack/components/LinkedDevicePanel.tsx | 130 ++++++++++++++++++ .../src/rack/components/RackDeviceModal.tsx | 8 ++ frontend/src/rack/demoData.ts | 68 +++++++-- frontend/src/types/rack.ts | 38 +++++ .../utils/__tests__/rackSerializer.test.ts | 62 +++++++++ frontend/src/utils/rackSerializer.ts | 52 +++++++ 13 files changed, 662 insertions(+), 15 deletions(-) create mode 100644 frontend/src/rack/__tests__/LinkedDevicePanel.test.tsx create mode 100644 frontend/src/rack/components/LinkedDevicePanel.tsx diff --git a/backend/app/api/routes/racks.py b/backend/app/api/routes/racks.py index 4b87204..2d0ebbf 100644 --- a/backend/app/api/routes/racks.py +++ b/backend/app/api/routes/racks.py @@ -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, ) ) diff --git a/backend/app/schemas/racks.py b/backend/app/schemas/racks.py index ca6bb40..2c03244 100644 --- a/backend/app/schemas/racks.py +++ b/backend/app/schemas/racks.py @@ -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 diff --git a/backend/tests/test_racks.py b/backend/tests/test_racks.py index 1b0e1e0..055a569 100644 --- a/backend/tests/test_racks.py +++ b/backend/tests/test_racks.py @@ -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( diff --git a/docs/rack-canvas.md b/docs/rack-canvas.md index 4079906..8fc8786 100644 --- a/docs/rack-canvas.md +++ b/docs/rack-canvas.md @@ -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 diff --git a/frontend/src/rack/README.md b/frontend/src/rack/README.md index 773db17..f46d7e0 100644 --- a/frontend/src/rack/README.md +++ b/frontend/src/rack/README.md @@ -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 `` — 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 `