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:
committed by
Pouzor - Rémy Jardient
parent
4393ac92c6
commit
e53197d2cb
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user