feat(inventory): make the inventory row the source of a node's facts

A device drawn on three canvases was three independent copies of the same
facts. Point every node at the Device Inventory row it draws, and let that
row own what the device *is* — the node keeps only how it is drawn.

- nodes.device_id -> device_inventory.id, ON DELETE SET NULL. NULL for
  canvas furniture (group / groupRect / text), which describes nothing
  physical. Deleting a node never deletes the row.
- services/inventory_sync holds the shared rules: matching by ieee > ip >
  mac (per token, so 10.0.0.4 never matches 10.0.0.40), a property union on
  key, a service union on (port, protocol, name), and the backfill that
  links every pre-existing node.
- The backfill is non-destructive by construction: it writes device_id and
  fills the row, and deletes nothing. Nodes are visited oldest-edit-first,
  so where two canvases disagree on a scalar the most recently edited wins,
  while properties and services stay unioned — nothing any canvas recorded
  is lost. A second boot finds nothing to do.
- The wire shape does not change: GET /canvas hydrates the device fields
  from the row, and a save routes them back to it. Editing a node's IP on
  one canvas now shows on every other canvas holding that device.
- approve / bulk-approve set device_id instead of owning a copy, and a new
  canvas node joins (or mints) its row. Rows minted this way are tagged
  with a new `canvas` discovery source and get their own inventory filter.
- DetailPanel offers "Open in inventory"; standalone, which has no
  inventory, is not offered it.

test_racks' "reports what the canvas node knows" seeded a second inventory
row for a host that already had one — a state a node create can no longer
produce. Its seeding order is swapped so the node links to the row; every
assertion is unchanged.

ha-relevant: yes
This commit is contained in:
Pouzor
2026-08-14 18:02:02 +02:00
committed by Pouzor - Rémy Jardient
parent 4393ac92c6
commit e53197d2cb
18 changed files with 1016 additions and 32 deletions
+17 -4
View File
@@ -12,6 +12,7 @@ from app.db.models import CanvasState, Design, Edge, Node
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
from app.services.inventory_sync import hydrated_node, link_node, load_devices_for
router = APIRouter()
@@ -28,12 +29,15 @@ async def load_canvas(
if design_id is None:
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
nodes = list((await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all())
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
state = await db.get(CanvasState, design_id)
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
# The device facts live on the inventory row now; hydrate them back into the
# node so the wire shape the canvas consumes is unchanged.
devices = await load_devices_for(db, nodes)
return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes],
nodes=[NodeResponse.model_validate(hydrated_node(n, devices.get(n.device_id or ""))) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
custom_style=state.custom_style if state else None,
@@ -74,7 +78,10 @@ async def save_canvas(
await db.flush()
# Upsert nodes
# Upsert nodes, then route their device facts to the inventory row that owns
# them — an edit made on this canvas is an edit to the device itself, and
# every other canvas showing it follows.
saved_nodes: list[Node] = []
for node_data in body.nodes:
db_node = await db.get(Node, node_data.id)
payload = node_data.model_dump()
@@ -83,7 +90,13 @@ async def save_canvas(
for field, value in payload.items():
setattr(db_node, field, value)
else:
db.add(Node(**payload))
db_node = Node(**payload)
db.add(db_node)
saved_nodes.append(db_node)
await db.flush()
for node in saved_nodes:
await link_node(db, node, overwrite_scalars=True, replace_lists=True)
# Upsert edges
for edge_data in body.edges:
+26 -9
View File
@@ -1,11 +1,14 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import Design, Node
from app.db.models import Design, InventoryDevice, Node
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
from app.services.inventory_sync import hydrated_node, link_node, load_devices_for
from app.services.node_dedupe import find_duplicate_node
router = APIRouter()
@@ -59,15 +62,17 @@ async def list_nodes(
label: str | None = Query(None, description="Case-insensitive substring filter on node label"),
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> list[Node]:
) -> list[Any]:
query = select(Node)
if label:
query = query.where(Node.label.ilike(f"%{label}%"))
return list((await db.execute(query)).scalars().all())
nodes = list((await db.execute(query)).scalars().all())
devices = await load_devices_for(db, nodes)
return [hydrated_node(n, devices.get(n.device_id or "")) for n in nodes]
@router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Any:
data = body.model_dump()
# `force` bypasses the duplicate guard below; it is not a Node column.
force = data.pop("force", False)
@@ -105,31 +110,43 @@ async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: s
node = Node(**data)
db.add(node)
await db.flush()
# A new device node gets (or joins) its Device Inventory row — the canvas is
# one more way to document hardware, not a parallel store.
await link_node(db, node)
await db.commit()
await db.refresh(node)
return node
return await _hydrate(db, node)
async def _hydrate(db: AsyncSession, node: Node) -> dict[str, Any]:
"""Node as the API reports it, with the device facts read off its row."""
device = await db.get(InventoryDevice, node.device_id) if node.device_id else None
return hydrated_node(node, device)
@router.get("/{node_id}", response_model=NodeResponse)
async def get_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
async def get_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Any:
node = await db.get(Node, node_id)
if not node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
return node
return await _hydrate(db, node)
@router.patch("/{node_id}", response_model=NodeResponse)
async def update_node(
node_id: str, body: NodeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> Node:
) -> Any:
node = await db.get(Node, node_id)
if not node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
for field, value in body.model_dump(exclude_unset=True).items():
setattr(node, field, value)
# The user edited the device, not just its drawing: push the facts down.
await link_node(db, node, overwrite_scalars=True, replace_lists=True)
await db.commit()
await db.refresh(node)
return node
return await _hydrate(db, node)
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT)
+8
View File
@@ -28,6 +28,7 @@ from app.schemas.scan import (
InventoryDeviceUpdate,
ScanRunResponse,
)
from app.services.inventory_sync import merge_node_into_device
from app.services.mac_utils import normalize_mac
from app.services.node_dedupe import dedupe_nodes_by_ieee, find_duplicate_node
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
@@ -567,6 +568,8 @@ async def bulk_approve_devices(
left_handles=1 if cluster_host else 0,
right_handles=1 if cluster_host else 0,
design_id=default_design_id,
# The node draws this inventory row; the row keeps owning the facts.
device_id=device.id,
)
db.add(node)
created_nodes.append(node)
@@ -738,10 +741,15 @@ async def approve_device(
left_handles=1 if cluster_host else 0,
right_handles=1 if cluster_host else 0,
design_id=node_design_id,
# The node draws this inventory row; the row keeps owning the facts.
device_id=device.id,
)
db.add(node)
await db.flush()
node_id = node.id
# The approve payload may carry user edits (a renamed label, a typed IP) the
# inventory row has never seen — fold them back so it stays the source.
merge_node_into_device(device, node, overwrite_scalars=False, replace_lists=False)
edges = await _resolve_pending_links_for_ieee(db, device.ieee_address, node_design_id)
+35
View File
@@ -460,6 +460,15 @@ async def init_db() -> None:
await _try_migrate(conn, sql, label=label)
# Backfill the columns that carry a non-NULL default in the model, so a
# legacy row round-trips through Pydantic without tripping the validators.
# The link itself: a node points at the inventory row it draws.
for label, sql in (
("nodes.device_id", "ALTER TABLE nodes ADD COLUMN device_id TEXT"),
(
"nodes.device_id.index",
"CREATE INDEX IF NOT EXISTS ix_nodes_device_id ON nodes(device_id)",
),
):
await _try_migrate(conn, sql, label=label)
for label, sql in (
(
"device_inventory.status_live.backfill",
@@ -476,6 +485,32 @@ async def init_db() -> None:
):
await _try_migrate(conn, sql, label=label)
await _backfill_node_devices()
async def _backfill_node_devices() -> None:
"""Link existing canvas nodes to their Device Inventory rows (3.3.0).
Runs outside the DDL connection because the merge is Python-side, not SQL.
Self-limiting: it only looks at nodes with no ``device_id``, so a second boot
finds nothing to do. Never fatal — a failure here leaves the canvas working
off its own columns, which still carry the data at this point.
"""
# Imported here: app.services imports app.db.models, which imports this module.
from app.services.inventory_sync import backfill_node_devices
try:
async with AsyncSessionLocal() as session:
stats = await backfill_node_devices(session)
if stats["linked"]:
await session.commit()
logger.info(
"Inventory backfill: linked %d node(s) — %d device(s) created, %d merged",
stats["linked"], stats["created"], stats["merged"],
)
except Exception as exc: # pragma: no cover - defensive, boot must not die
logger.warning("Inventory backfill failed: %s", exc)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
+6
View File
@@ -34,6 +34,12 @@ class Node(Base):
type: Mapped[str] = mapped_column(String, nullable=False)
label: Mapped[str] = mapped_column(String, nullable=False)
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
# The Device Inventory row this node draws. NULL for canvas furniture
# (group / groupRect / text), which represents nothing physical. Deleting a
# node never deletes the device — the inventory outlives every canvas.
device_id: Mapped[str | None] = mapped_column(
String, ForeignKey("device_inventory.id", ondelete="SET NULL"), index=True, nullable=True
)
hostname: Mapped[str | None] = mapped_column(String)
ip: Mapped[str | None] = mapped_column(String)
mac: Mapped[str | None] = mapped_column(String)
+4
View File
@@ -11,6 +11,10 @@ class NodeSave(BaseModel):
id: str
type: str
label: str
# Round-tripped so a save keeps the node pointed at its inventory row. The
# device fields below are still accepted at their canvas values and routed
# to that row server-side.
device_id: str | None = None
hostname: str | None = None
ip: str | None = None
mac: str | None = None
+4
View File
@@ -85,6 +85,10 @@ class NodeUpdate(BaseModel):
class NodeResponse(NodeBase):
id: str
design_id: str | None = None
# The Device Inventory row this node draws; None for canvas furniture. The
# device fields above are hydrated from that row on read, so the wire shape
# is unchanged even though the row, not the node, owns them.
device_id: str | None = None
ieee_address: str | None = None
last_seen: datetime | None = None
last_scan: datetime | None = None
+391
View File
@@ -0,0 +1,391 @@
"""Keep a canvas :class:`Node` and its Device Inventory row in step.
The inventory row owns what a device *is* — addresses, services, properties,
notes, hardware, check method. A node owns only how that device is drawn on one
canvas. This module holds the matching and merging rules shared by:
* the one-off backfill that links pre-3.3.0 nodes to inventory rows,
* the approve / node-create paths, which link instead of copying,
* the canvas save write-through, which pushes a node edit back to the row.
Nothing here commits — the caller owns the transaction.
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import InventoryDevice, Node
from app.services.discovery_sources import add_source
logger = logging.getLogger(__name__)
# Canvas furniture: annotations, not hardware. These never get an inventory row.
FURNITURE_TYPES = frozenset({"group", "groupRect", "text"})
# Source tag for a device that only ever existed as a canvas node — the backfill
# mints its inventory row.
CANVAS_SOURCE = "canvas"
# Scalar facts the inventory row owns. Order matters only for readability.
DEVICE_SCALARS = (
"hostname",
"ip",
"mac",
"os",
"notes",
"cpu_count",
"cpu_model",
"ram_gb",
"disk_gb",
"check_method",
"check_target",
)
def is_furniture(node_type: str | None) -> bool:
return (node_type or "") in FURNITURE_TYPES
def _ip_tokens(ip: str | None) -> list[str]:
"""Split an ``ip`` field into individual addresses.
A node or device may carry several comma-separated addresses, so identity
matching compares per token — same rule as ``node_dedupe._ip_tokens``.
"""
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
def _blank(value: Any) -> bool:
return value is None or value == ""
async def find_device_for(
db: AsyncSession,
*,
ip: str | None,
mac: str | None,
ieee: str | None,
) -> InventoryDevice | None:
"""Find the inventory row describing this host, or ``None``.
Precedence is ieee > ip > mac, matching ``find_duplicate_node`` and the
bulk-approve skip order so a device is identified the same way everywhere.
Hidden rows are eligible: a hidden device is still that device, and silently
minting a second row for it would resurrect the duplicate the user hid.
"""
ip_toks = _ip_tokens(ip)
conds = []
if ieee:
conds.append(InventoryDevice.ieee_address == ieee)
for tok in ip_toks:
# Narrow with a substring match, then confirm per token below — an exact
# comparison misses rows holding several addresses.
conds.append(InventoryDevice.ip.contains(tok))
if mac:
conds.append(InventoryDevice.mac == mac)
if not conds:
return None
candidates = (
await db.execute(select(InventoryDevice).where(or_(*conds)).order_by(InventoryDevice.discovered_at))
).scalars().all()
for device in candidates:
if ieee and device.ieee_address == ieee:
return device
for device in candidates:
# "1.2.3.4" must not match "1.2.3.40" — confirm the token, don't trust
# the SQL `contains`.
if ip_toks and set(_ip_tokens(device.ip)) & set(ip_toks):
return device
for device in candidates:
if mac and device.mac == mac:
return device
return None
def merge_properties(base: list[Any] | None, incoming: list[Any] | None) -> list[Any]:
"""Union two property lists on ``key`` (case-insensitive); incoming wins.
Order-stable: existing keys keep their position, new ones are appended, so a
user's arrangement survives a merge.
"""
out: list[Any] = [dict(p) if isinstance(p, dict) else p for p in (base or [])]
index: dict[str, int] = {}
for i, prop in enumerate(out):
if isinstance(prop, dict) and prop.get("key") is not None:
index[str(prop["key"]).lower()] = i
for prop in incoming or []:
if not isinstance(prop, dict) or prop.get("key") is None:
if prop not in out:
out.append(prop)
continue
key = str(prop["key"]).lower()
pos = index.get(key)
if pos is None:
out.append(dict(prop))
index[key] = len(out) - 1
continue
current = out[pos]
if not isinstance(current, dict):
out[pos] = dict(prop)
continue
merged = {**current, **{k: v for k, v in prop.items() if not _blank(v)}}
# Keys match case-insensitively but the display spelling is the user's —
# "rack" arriving must not rewrite their "Rack".
merged["key"] = current.get("key", prop["key"])
# `visible` is a real False, not an empty value — carry it explicitly.
if "visible" in prop:
merged["visible"] = prop["visible"]
out[pos] = merged
return out
def _service_key(svc: Any) -> Any:
if not isinstance(svc, dict):
return repr(svc)
return (svc.get("port"), svc.get("protocol"), (svc.get("service_name") or "").lower())
def merge_services(base: list[Any] | None, incoming: list[Any] | None) -> list[Any]:
"""Union two service lists on (port, protocol, name); incoming wins."""
out: list[Any] = [dict(s) if isinstance(s, dict) else s for s in (base or [])]
index = {_service_key(s): i for i, s in enumerate(out)}
for svc in incoming or []:
key = _service_key(svc)
pos = index.get(key)
if pos is None:
out.append(dict(svc) if isinstance(svc, dict) else svc)
index[key] = len(out) - 1
elif isinstance(svc, dict) and isinstance(out[pos], dict):
out[pos] = {**out[pos], **svc}
else:
out[pos] = svc
return out
def merge_node_into_device(
device: InventoryDevice,
node: Node,
*,
overwrite_scalars: bool,
replace_lists: bool,
) -> None:
"""Fold a node's device facts into its inventory row, in place.
Two independent knobs, because the three callers need three combinations:
* ``overwrite_scalars`` — a non-blank node value replaces the row's. True
for the backfill (nodes are visited oldest-edit-first, so the most
recently edited canvas is the last writer and wins) and for a user's save.
False on approve, where the row was just discovered and the node is only a
placement. A blank *never* clears an established value in either mode.
* ``replace_lists`` — properties/services are taken wholesale rather than
unioned. True only for a user's save: otherwise a property they deleted
would come straight back on the next one. The backfill unions, so nothing
any canvas recorded is lost.
"""
for field in DEVICE_SCALARS:
incoming = getattr(node, field, None)
if _blank(incoming):
continue
if overwrite_scalars or _blank(getattr(device, field, None)):
setattr(device, field, incoming)
if not _blank(node.label) and (overwrite_scalars or _blank(device.label)):
device.label = node.label
if not _blank(node.type) and (overwrite_scalars or _blank(device.type)):
device.type = node.type
if not _blank(node.ieee_address) and _blank(device.ieee_address):
# Identity, never overwritten — two IEEEs mean two devices.
device.ieee_address = node.ieee_address
if node.show_hardware and not device.show_hardware:
device.show_hardware = True
if replace_lists:
device.properties = list(node.properties or [])
device.services = list(node.services or [])
else:
device.properties = merge_properties(device.properties, node.properties)
device.services = merge_services(device.services, node.services)
# Live status: keep the freshest observation rather than the last writer.
if node.last_seen and (device.last_seen is None or node.last_seen > device.last_seen):
device.last_seen = node.last_seen
device.status_live = node.status or device.status_live
device.response_time_ms = node.response_time_ms
elif device.status_live in (None, "", "unknown") and node.status:
device.status_live = node.status
if node.last_scan and (device.last_scan is None or node.last_scan > device.last_scan):
device.last_scan = node.last_scan
def device_from_node(node: Node) -> InventoryDevice:
"""Mint the inventory row for a node that has none.
Tagged ``canvas`` so the inventory filters can tell hand-drawn gear apart
from anything a scan or import found.
"""
device = InventoryDevice(
label=node.label,
type=node.type,
hostname=node.hostname,
ip=node.ip,
mac=node.mac,
os=node.os,
ieee_address=node.ieee_address,
services=list(node.services or []),
properties=list(node.properties or []),
notes=node.notes,
cpu_count=node.cpu_count,
cpu_model=node.cpu_model,
ram_gb=node.ram_gb,
disk_gb=node.disk_gb,
show_hardware=bool(node.show_hardware),
check_method=node.check_method,
check_target=node.check_target,
suggested_type=node.type,
friendly_name=node.label,
# On a canvas already, so it is past the pending queue.
status="approved",
status_live=node.status or "unknown",
last_seen=node.last_seen,
last_scan=node.last_scan,
response_time_ms=node.response_time_ms,
discovery_source=CANVAS_SOURCE,
discovery_sources=[CANVAS_SOURCE],
)
return device
async def link_node(
db: AsyncSession,
node: Node,
*,
overwrite_scalars: bool = False,
replace_lists: bool = False,
) -> InventoryDevice | None:
"""Point one node at its inventory row, creating or merging as needed.
Returns the row, or ``None`` for canvas furniture. The two flags are handed
to :func:`merge_node_into_device`; see it for when each applies. Flushes so
a freshly minted row has an id to link to, but does not commit.
"""
if is_furniture(node.type):
node.device_id = None
return None
device = None
if node.device_id:
device = await db.get(InventoryDevice, node.device_id)
if device is None:
device = await find_device_for(db, ip=node.ip, mac=node.mac, ieee=node.ieee_address)
if device is None:
device = device_from_node(node)
db.add(device)
await db.flush()
else:
merge_node_into_device(
device, node, overwrite_scalars=overwrite_scalars, replace_lists=replace_lists
)
device.discovery_sources = add_source(device.discovery_sources, CANVAS_SOURCE)
node.device_id = device.id
return device
async def load_devices_for(db: AsyncSession, nodes: list[Node]) -> dict[str, InventoryDevice]:
"""Fetch the inventory rows a batch of nodes points at, keyed by device id."""
ids = {n.device_id for n in nodes if n.device_id}
if not ids:
return {}
rows = (
await db.execute(select(InventoryDevice).where(InventoryDevice.id.in_(ids)))
).scalars().all()
return {d.id: d for d in rows}
def hydrated_node(node: Node, device: InventoryDevice | None) -> dict[str, Any]:
"""A node as the API reports it: presentation from the node, facts from the row.
The device fields stay on the wire exactly where they have always been, so
every reader — the canvas, the live view, the MCP server — is unaffected by
the split. Falls back to the node's own columns when it has no row (canvas
furniture, or a device row deleted out from under it).
"""
payload: dict[str, Any] = {
c.name: getattr(node, c.name) for c in node.__table__.columns
}
if device is None:
return payload
for field in DEVICE_SCALARS:
payload[field] = getattr(device, field, None)
payload["label"] = device.label or node.label
payload["type"] = device.type or node.type
payload["services"] = device.services or []
payload["properties"] = device.properties or []
payload["show_hardware"] = bool(device.show_hardware)
payload["ieee_address"] = device.ieee_address or node.ieee_address
# Live status still reaches the node while the status checker is node-scoped
# (it moves to the row in the next step), so prefer whichever side has an
# actual observation rather than blanking the canvas in between.
if device.status_live and device.status_live != "unknown":
payload["status"] = device.status_live
payload["last_seen"] = device.last_seen or node.last_seen
payload["last_scan"] = device.last_scan or node.last_scan
payload["response_time_ms"] = (
device.response_time_ms if device.response_time_ms is not None else node.response_time_ms
)
return payload
async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
"""Link every pre-3.3.0 canvas node to a Device Inventory row.
Non-destructive: it writes ``nodes.device_id`` and fills blank inventory
fields, and never deletes a node or a row. Two nodes on two canvases
describing the same host converge on one row — that convergence is the point.
Nodes are processed oldest-edit-first so that, where two canvases disagree
on a scalar, the most recently edited node is the last writer and wins.
Returns counts for the boot log. Does not commit.
"""
nodes = (
await db.execute(
select(Node)
.where(Node.device_id.is_(None), Node.type.not_in(tuple(FURNITURE_TYPES)))
.order_by(Node.updated_at, Node.created_at, Node.id)
)
).scalars().all()
if not nodes:
return {"linked": 0, "created": 0, "merged": 0}
created = merged = 0
for node in nodes:
existing = await find_device_for(db, ip=node.ip, mac=node.mac, ieee=node.ieee_address)
# Nodes arrive oldest-edit-first, so overwriting scalars leaves the most
# recently edited canvas as the last writer. Lists stay unioned — nothing
# any canvas recorded is dropped.
device = await link_node(db, node, overwrite_scalars=True)
if device is None:
continue
if existing is None:
created += 1
logger.info("Inventory backfill: node %s (%s) created device %s", node.id, node.label, device.id)
else:
merged += 1
logger.info(
"Inventory backfill: node %s (%s, design %s) merged into device %s",
node.id, node.label, node.design_id, device.id,
)
await db.flush()
return {"linked": len(nodes), "created": created, "merged": merged}
+343
View File
@@ -0,0 +1,343 @@
"""The node ↔ Device Inventory link: matching, merging, and the 3.3.0 backfill.
The inventory row owns the device facts; a node owns how the device is drawn.
These tests pin the rules that make one device end up as one row even when it
was drawn on several canvases.
"""
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.db.models import Design, InventoryDevice, Node
from app.services.inventory_sync import (
backfill_node_devices,
find_device_for,
merge_properties,
merge_services,
)
def _now(offset_minutes: int = 0) -> datetime:
return datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc) + timedelta(minutes=offset_minutes)
async def _design(db_session, name="Net") -> str:
design = Design(id=str(uuid.uuid4()), name=name)
db_session.add(design)
await db_session.commit()
return design.id
def _node(design_id: str, **kwargs) -> Node:
payload = {
"id": str(uuid.uuid4()),
"label": "n",
"type": "server",
"status": "unknown",
"services": [],
"properties": [],
"pos_x": 0.0,
"pos_y": 0.0,
"design_id": design_id,
}
payload.update(kwargs)
return Node(**payload)
# --- pure merge rules -------------------------------------------------------
class TestMergeRules:
def test_properties_union_on_key_with_incoming_winning(self):
base = [{"key": "Rack", "value": "A1", "icon": None, "visible": True}]
incoming = [
{"key": "rack", "value": "B2", "icon": None, "visible": False},
{"key": "Owner", "value": "me", "icon": None, "visible": True},
]
out = merge_properties(base, incoming)
assert [p["value"] for p in out] == ["B2", "me"]
# Existing keys keep their position — a user's ordering survives.
assert out[0]["key"] == "Rack"
assert out[0]["visible"] is False
def test_properties_keep_established_value_when_incoming_is_blank(self):
out = merge_properties(
[{"key": "Rack", "value": "A1", "icon": "Server", "visible": True}],
[{"key": "Rack", "value": "", "icon": None, "visible": True}],
)
assert out[0]["value"] == "A1"
assert out[0]["icon"] == "Server"
def test_services_union_on_port_protocol_and_name(self):
base = [{"port": 22, "protocol": "tcp", "service_name": "ssh"}]
incoming = [
{"port": 22, "protocol": "tcp", "service_name": "SSH", "icon": "Terminal"},
{"port": 80, "protocol": "tcp", "service_name": "http"},
]
out = merge_services(base, incoming)
assert len(out) == 2
assert out[0]["icon"] == "Terminal"
assert out[1]["port"] == 80
# --- matching ---------------------------------------------------------------
class TestFindDeviceFor:
@pytest.mark.asyncio
async def test_prefers_ieee_over_ip_and_mac(self, db_session):
by_ip = InventoryDevice(id="d-ip", ip="10.0.0.5")
by_ieee = InventoryDevice(id="d-ieee", ieee_address="0xAAA")
db_session.add_all([by_ip, by_ieee])
await db_session.commit()
found = await find_device_for(db_session, ip="10.0.0.5", mac=None, ieee="0xAAA")
assert found is not None and found.id == "d-ieee"
@pytest.mark.asyncio
async def test_matches_one_address_out_of_a_comma_list(self, db_session):
device = InventoryDevice(id="d-1", ip="10.0.0.5, fd00::1")
db_session.add(device)
await db_session.commit()
found = await find_device_for(db_session, ip="fd00::1", mac=None, ieee=None)
assert found is not None and found.id == "d-1"
@pytest.mark.asyncio
async def test_does_not_match_a_longer_address_with_the_same_prefix(self, db_session):
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.40"))
await db_session.commit()
assert await find_device_for(db_session, ip="10.0.0.4", mac=None, ieee=None) is None
@pytest.mark.asyncio
async def test_matches_a_hidden_row_rather_than_minting_a_second(self, db_session):
db_session.add(InventoryDevice(id="d-hidden", ip="10.0.0.5", status="hidden"))
await db_session.commit()
found = await find_device_for(db_session, ip="10.0.0.5", mac=None, ieee=None)
assert found is not None and found.id == "d-hidden"
# --- the backfill -----------------------------------------------------------
class TestBackfill:
@pytest.mark.asyncio
async def test_links_a_node_to_its_existing_row(self, db_session):
design = await _design(db_session)
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", hostname="nas"))
node = _node(design, ip="10.0.0.5", label="NAS")
db_session.add(node)
await db_session.commit()
stats = await backfill_node_devices(db_session)
await db_session.commit()
assert stats == {"linked": 1, "created": 0, "merged": 1}
assert node.device_id == "d-1"
@pytest.mark.asyncio
async def test_creates_a_row_for_a_node_no_scan_ever_saw(self, db_session):
design = await _design(db_session)
node = _node(design, label="Dumb switch", type="switch", notes="under the desk")
db_session.add(node)
await db_session.commit()
stats = await backfill_node_devices(db_session)
await db_session.commit()
assert stats == {"linked": 1, "created": 1, "merged": 0}
device = await db_session.get(InventoryDevice, node.device_id)
assert device is not None
assert device.label == "Dumb switch"
assert device.type == "switch"
assert device.notes == "under the desk"
assert device.status == "approved"
assert device.discovery_sources == ["canvas"]
@pytest.mark.asyncio
async def test_two_canvases_one_device_merge_into_one_row(self, db_session):
"""The whole point: the same host drawn twice becomes one inventory row."""
design_a = await _design(db_session, "A")
design_b = await _design(db_session, "B")
older = _node(
design_a,
ip="10.0.0.5",
label="nas-old",
hostname="nas.lan",
notes="older note",
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
updated_at=_now(0),
)
newer = _node(
design_b,
ip="10.0.0.5",
label="nas-new",
os="TrueNAS",
properties=[{"key": "Owner", "value": "me", "icon": None, "visible": True}],
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
updated_at=_now(30),
)
db_session.add_all([older, newer])
await db_session.commit()
stats = await backfill_node_devices(db_session)
await db_session.commit()
assert stats["linked"] == 2
assert older.device_id == newer.device_id
device = await db_session.get(InventoryDevice, older.device_id)
assert device is not None
# Scalars: most recent edit wins, blanks never wipe an established value.
assert device.label == "nas-new"
assert device.hostname == "nas.lan"
assert device.os == "TrueNAS"
assert device.notes == "older note"
# Cumulative fields: union of both canvases.
assert {p["key"] for p in device.properties} == {"Rack", "Owner"}
assert {s["port"] for s in device.services} == {22, 80}
@pytest.mark.asyncio
async def test_leaves_canvas_furniture_alone(self, db_session):
design = await _design(db_session)
for kind in ("group", "groupRect", "text"):
db_session.add(_node(design, type=kind, label=kind))
await db_session.commit()
stats = await backfill_node_devices(db_session)
await db_session.commit()
assert stats["linked"] == 0
assert (await db_session.execute(select(InventoryDevice))).scalars().all() == []
@pytest.mark.asyncio
async def test_is_a_no_op_on_a_second_run(self, db_session):
design = await _design(db_session)
db_session.add(_node(design, ip="10.0.0.5"))
await db_session.commit()
first = await backfill_node_devices(db_session)
await db_session.commit()
second = await backfill_node_devices(db_session)
await db_session.commit()
assert first["linked"] == 1
assert second == {"linked": 0, "created": 0, "merged": 0}
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
assert len(rows) == 1
# --- routes -----------------------------------------------------------------
class TestRoutesKeepTheLinkInStep:
@pytest.mark.asyncio
async def test_creating_a_node_joins_the_matching_row(self, client: AsyncClient, headers, db_session):
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", hostname="nas", os="TrueNAS"))
await db_session.commit()
res = await client.post(
"/api/v1/nodes",
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
headers=headers,
)
assert res.status_code == 201
body = res.json()
assert body["device_id"] == "d-1"
# Hydrated from the row: the node never carried an OS.
assert body["os"] == "TrueNAS"
@pytest.mark.asyncio
async def test_creating_furniture_creates_no_row(self, client: AsyncClient, headers, db_session):
res = await client.post(
"/api/v1/nodes", json={"type": "groupRect", "label": "Zone"}, headers=headers
)
assert res.status_code == 201
assert res.json()["device_id"] is None
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
assert rows == []
@pytest.mark.asyncio
async def test_editing_a_node_writes_through_to_the_row(self, client: AsyncClient, headers, db_session):
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", hostname="nas"))
await db_session.commit()
node_id = (
await client.post(
"/api/v1/nodes",
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
headers=headers,
)
).json()["id"]
res = await client.patch(
f"/api/v1/nodes/{node_id}",
json={"hostname": "nas2.lan", "notes": "moved to the garage"},
headers=headers,
)
assert res.status_code == 200
device = await db_session.get(InventoryDevice, "d-1")
await db_session.refresh(device)
assert device.hostname == "nas2.lan"
assert device.notes == "moved to the garage"
@pytest.mark.asyncio
async def test_the_second_canvas_sees_the_first_canvas_edit(self, client: AsyncClient, headers, db_session):
"""No per-node overrides: one device, one set of facts, every canvas."""
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5"))
await db_session.commit()
design_a = (await client.post("/api/v1/designs", json={"name": "A"}, headers=headers)).json()["id"]
design_b = (await client.post("/api/v1/designs", json={"name": "B"}, headers=headers)).json()["id"]
node_a = (
await client.post(
"/api/v1/nodes",
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "design_id": design_a, "force": True},
headers=headers,
)
).json()["id"]
await client.post(
"/api/v1/nodes",
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "design_id": design_b, "force": True},
headers=headers,
)
await client.patch(f"/api/v1/nodes/{node_a}", json={"ip": "10.0.0.9"}, headers=headers)
canvas_b = (await client.get(f"/api/v1/canvas?design_id={design_b}", headers=headers)).json()
assert canvas_b["nodes"][0]["ip"] == "10.0.0.9"
@pytest.mark.asyncio
async def test_deleting_a_node_keeps_the_device(self, client: AsyncClient, headers, db_session):
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5"))
await db_session.commit()
node_id = (
await client.post(
"/api/v1/nodes",
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
headers=headers,
)
).json()["id"]
assert (await client.delete(f"/api/v1/nodes/{node_id}", headers=headers)).status_code == 204
assert await db_session.get(InventoryDevice, "d-1") is not None
@pytest.mark.asyncio
async def test_approving_a_device_links_instead_of_copying(self, client: AsyncClient, headers, db_session):
db_session.add(
InventoryDevice(id="d-1", ip="10.0.0.5", hostname="nas", suggested_type="nas", status="pending")
)
await db_session.commit()
res = await client.post(
"/api/v1/scan/pending/d-1/approve",
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "status": "unknown"},
headers=headers,
)
assert res.status_code == 200
node = await db_session.get(Node, res.json()["node_id"])
assert node is not None and node.device_id == "d-1"
+15 -14
View File
@@ -468,6 +468,21 @@ class TestInventory:
"""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")
# Straight to the table: the manual-create schema carries no os/services,
# and a scan find does. Seeded before the node so the node links to this
# row rather than minting a second one for the same host.
db_session.add(
InventoryDevice(
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()
await client.post(
"/api/v1/nodes",
json={
@@ -483,20 +498,6 @@ class TestInventory:
},
headers=headers,
)
# Straight to the table: the manual-create schema carries no os/services,
# and a scan find does.
db_session.add(
InventoryDevice(
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)
+6 -1
View File
@@ -1060,7 +1060,12 @@ export default function App() {
{isRackDesign
? <RackCablePanel />
: (selectedNodeId || selectedNodeIds.length > 1) && (
<DetailPanel onEdit={handleEditNode} />
<DetailPanel
onEdit={handleEditNode}
// Standalone has no Device Inventory to open (ADR-001 style
// gate: the whole scan surface is backend-only).
onOpenInventory={STANDALONE ? undefined : (deviceId) => openInventoryModal(deviceId)}
/>
)}
</div>
</div>
@@ -594,6 +594,13 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
>
Rack devices
</button>
<button
onClick={() => setSourceFilter('canvas')}
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'canvas' ? 'bg-[#8b949e]/20 text-foreground' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
title="Documented directly on a canvas — no scan ever saw it"
>
Canvas
</button>
</div>
<select
value={typeFilter}
+15 -2
View File
@@ -1,5 +1,5 @@
import { createElement, useRef, useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff, GripVertical } from 'lucide-react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff, GripVertical, Boxes } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -16,12 +16,15 @@ import type { Node } from '@xyflow/react'
interface DetailPanelProps {
onEdit: (id: string) => void
/** Opens the Device Inventory on this node's row. Absent in contexts with no
* inventory (standalone, the tour), where the link is simply not offered. */
onOpenInventory?: (deviceId: string) => void
}
/** Open service editor: `index === null` means "add a new service". */
type SvcModalState = { nodeId: string; index: number | null; form?: ServiceFormData }
export function DetailPanel({ onEdit }: DetailPanelProps) {
export function DetailPanel({ onEdit, onOpenInventory }: DetailPanelProps) {
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize } = useCanvasStore()
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
@@ -209,6 +212,16 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{data.last_scan && <DetailRow label="Last Scan" value={formatTimestamp(data.last_scan)} />}
{data.created_at && <DetailRow label="Created" value={formatTimestamp(data.created_at)} />}
{data.updated_at && <DetailRow label="Last Modified" value={formatTimestamp(data.updated_at)} />}
{/* The row behind this node — where the same facts live for every other
canvas showing it. */}
{data.device_id && onOpenInventory && (
<button
onClick={() => onOpenInventory(data.device_id as string)}
className="mt-1 flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
>
<Boxes size={10} /> Open in inventory
</button>
)}
</div>
{/* Size section — manual width/height entry for pixel-exact sizing */}
@@ -0,0 +1,69 @@
/**
* The panel offers a way through to the Device Inventory row behind the node —
* the place the same facts live for every other canvas showing this device.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { DetailPanel } from '../DetailPanel'
import * as canvasStore from '@/stores/canvasStore'
import type { NodeData } from '@/types'
import type { Node } from '@xyflow/react'
vi.mock('@/stores/canvasStore', async (importActual) => ({
...(await importActual<typeof canvasStore>()),
useCanvasStore: vi.fn(),
}))
function makeNode(data: Partial<NodeData>): Node<NodeData> {
return {
id: 'n1',
type: 'server',
position: { x: 0, y: 0 },
data: { label: 'NAS', type: 'server', status: 'online', services: [], ...data },
}
}
function setupStore(nodeData: Partial<NodeData> = {}) {
const state = {
nodes: [makeNode(nodeData)],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
removeFromGroup: vi.fn(),
setNodeSize: vi.fn(),
serviceStatuses: {},
}
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
((sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state)) as unknown as typeof canvasStore.useCanvasStore,
)
}
beforeEach(() => vi.clearAllMocks())
describe('DetailPanel — inventory link', () => {
it('opens the inventory on the linked device', () => {
setupStore({ device_id: 'dev-1' })
const onOpenInventory = vi.fn()
render(<DetailPanel onEdit={vi.fn()} onOpenInventory={onOpenInventory} />)
fireEvent.click(screen.getByRole('button', { name: /Open in inventory/i }))
expect(onOpenInventory).toHaveBeenCalledWith('dev-1')
})
it('offers nothing for a node with no row yet', () => {
setupStore({})
render(<DetailPanel onEdit={vi.fn()} onOpenInventory={vi.fn()} />)
expect(screen.queryByRole('button', { name: /Open in inventory/i })).toBeNull()
})
it('offers nothing where there is no inventory to open (standalone)', () => {
setupStore({ device_id: 'dev-1' })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByRole('button', { name: /Open in inventory/i })).toBeNull()
})
})
+7
View File
@@ -174,6 +174,13 @@ export interface InventoryEntry {
export interface NodeData extends Record<string, unknown> {
label: string
type: NodeType
/**
* The Device Inventory row this node draws. The row owns the device facts
* below (ip, services, properties, notes…) — they arrive hydrated from it and
* a save routes them back to it. Null/absent for canvas furniture
* (group / groupRect / text), which describes nothing physical.
*/
device_id?: string | null
hostname?: string
ip?: string
mac?: string
@@ -0,0 +1,50 @@
/**
* A node carries the id of the Device Inventory row it draws. The link has to
* survive a save, or the next one would mint a second row for the same device.
*/
import { describe, it, expect } from 'vitest'
import { serializeNode, deserializeApiNode } from '@/utils/canvasSerializer'
import type { NodeData } from '@/types'
import type { Node } from '@xyflow/react'
function node(data: Partial<NodeData>): Node<NodeData> {
return {
id: 'n1',
type: data.type ?? 'server',
position: { x: 10, y: 20 },
data: { label: 'NAS', type: 'server', status: 'online', services: [], ...data },
}
}
describe('serializeNode — device link', () => {
it('sends the device_id it was loaded with', () => {
expect(serializeNode(node({ device_id: 'dev-1' })).device_id).toBe('dev-1')
})
it('sends null when the node has no row yet', () => {
expect(serializeNode(node({})).device_id).toBeNull()
})
it('sends null for a zone — furniture describes nothing physical', () => {
expect(serializeNode(node({ type: 'groupRect' })).device_id).toBeNull()
})
})
describe('deserializeApiNode — device link', () => {
it('hoists device_id onto node data', () => {
const rf = deserializeApiNode(
{
id: 'n1',
type: 'server',
label: 'NAS',
pos_x: 0,
pos_y: 0,
status: 'online',
services: [],
device_id: 'dev-1',
},
new Map(),
)
expect(rf.data.device_id).toBe('dev-1')
})
})
+7
View File
@@ -9,6 +9,8 @@ export interface ApiNode extends Record<string, unknown> {
id: string
type: string
label: string
/** Device Inventory row this node draws; null for canvas furniture. */
device_id?: string | null
pos_x: number
pos_y: number
status: string
@@ -67,6 +69,8 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
id: n.id,
type: 'groupRect',
label: n.data.label,
// A zone is canvas furniture — it never points at an inventory row.
device_id: null,
hostname: null,
ip: null,
mac: null,
@@ -95,6 +99,9 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
id: n.id,
type: n.data.type,
label: n.data.label,
// The Device Inventory row this node draws. Round-tripped so a save keeps
// the link; the device fields below are routed to that row server-side.
device_id: n.data.device_id ?? null,
hostname: n.data.hostname ?? null,
ip: n.data.ip ?? null,
mac: n.data.mac ?? null,
+6 -2
View File
@@ -7,7 +7,7 @@
*/
import type { InventoryEntry } from '@/components/modals/InventoryDeviceModal'
export type SourceBucket = 'ip' | 'zigbee' | 'zwave' | 'proxmox' | 'rack'
export type SourceBucket = 'ip' | 'zigbee' | 'zwave' | 'proxmox' | 'rack' | 'canvas'
export const SOURCE_META: Record<SourceBucket, { color: string; label: string }> = {
zigbee: { color: '#00d4ff', label: 'ZIGBEE' },
@@ -15,10 +15,12 @@ export const SOURCE_META: Record<SourceBucket, { color: string; label: string }>
proxmox: { color: '#e57000', label: 'PROXMOX' },
ip: { color: '#a855f7', label: 'IP' },
rack: { color: '#39d353', label: 'RACK' },
// Documented straight on a canvas — no scan ever saw it.
canvas: { color: '#8b949e', label: 'CANVAS' },
}
// Stable badge order (IP first — it's the primary discovery path).
const SOURCE_ORDER: SourceBucket[] = ['ip', 'proxmox', 'zigbee', 'zwave', 'rack']
const SOURCE_ORDER: SourceBucket[] = ['ip', 'proxmox', 'zigbee', 'zwave', 'rack', 'canvas']
/** Every source bucket that has observed this device. A device found by both an
* IP scan and a Proxmox import returns {ip, proxmox}. */
@@ -34,6 +36,8 @@ export function sourceBuckets(d: InventoryEntry): Set<SourceBucket> {
// Created from a rack canvas: inventory gear that never lands on a
// logical canvas.
else if (s === 'rack') buckets.add('rack')
// Drawn on a canvas rather than discovered.
else if (s === 'canvas') buckets.add('canvas')
else buckets.add('ip') // arp / mdns / anything else → IP scan
}
if (buckets.size === 0) {