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 `