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 +