diff --git a/backend/app/api/routes/canvas.py b/backend/app/api/routes/canvas.py
index c78e3b2..c1cea55 100644
--- a/backend/app/api/routes/canvas.py
+++ b/backend/app/api/routes/canvas.py
@@ -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:
diff --git a/backend/app/api/routes/nodes.py b/backend/app/api/routes/nodes.py
index 8d4d463..80187b0 100644
--- a/backend/app/api/routes/nodes.py
+++ b/backend/app/api/routes/nodes.py
@@ -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)
diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py
index 378b624..c93cdee 100644
--- a/backend/app/api/routes/scan.py
+++ b/backend/app/api/routes/scan.py
@@ -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)
diff --git a/backend/app/db/database.py b/backend/app/db/database.py
index 591449e..b220f32 100644
--- a/backend/app/db/database.py
+++ b/backend/app/db/database.py
@@ -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:
diff --git a/backend/app/db/models.py b/backend/app/db/models.py
index 0e7f4cd..cc59447 100644
--- a/backend/app/db/models.py
+++ b/backend/app/db/models.py
@@ -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)
diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py
index f2b689c..a7edc89 100644
--- a/backend/app/schemas/canvas.py
+++ b/backend/app/schemas/canvas.py
@@ -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
diff --git a/backend/app/schemas/nodes.py b/backend/app/schemas/nodes.py
index 193e495..bead9c4 100644
--- a/backend/app/schemas/nodes.py
+++ b/backend/app/schemas/nodes.py
@@ -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
diff --git a/backend/app/services/inventory_sync.py b/backend/app/services/inventory_sync.py
new file mode 100644
index 0000000..35259f7
--- /dev/null
+++ b/backend/app/services/inventory_sync.py
@@ -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}
diff --git a/backend/tests/test_inventory_sync.py b/backend/tests/test_inventory_sync.py
new file mode 100644
index 0000000..9515f6b
--- /dev/null
+++ b/backend/tests/test_inventory_sync.py
@@ -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"
diff --git a/backend/tests/test_racks.py b/backend/tests/test_racks.py
index f3ee776..2d8084d 100644
--- a/backend/tests/test_racks.py
+++ b/backend/tests/test_racks.py
@@ -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)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8b0f9dc..c26560f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1060,7 +1060,12 @@ export default function App() {
{isRackDesign
?
: (selectedNodeId || selectedNodeIds.length > 1) && (
-
+ openInventoryModal(deviceId)}
+ />
)}
diff --git a/frontend/src/components/modals/DeviceInventoryModal.tsx b/frontend/src/components/modals/DeviceInventoryModal.tsx
index cbce41c..5b3c4f9 100644
--- a/frontend/src/components/modals/DeviceInventoryModal.tsx
+++ b/frontend/src/components/modals/DeviceInventoryModal.tsx
@@ -594,6 +594,13 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
>
Rack devices
+