Completes the split: `nodes` now holds only how a device is drawn on one canvas, and every device fact reaches the API from the inventory row. - The device columns are removed from `nodes` (SQLite table rebuild, the same shape as the existing device_inventory and canvas_state rebuilds). The drop is skipped, and logged, while any non-furniture node is still unlinked: those columns are the last copy of that node's facts. The backfill therefore reads them with raw SQL — by the time it runs, the model no longer declares them. - The status checker iterates devices, not nodes: one check per device however many canvases draw it, writing `status_live` / `last_seen` / `response_time_ms` on the row. `/ws/status` messages carry `device_id` and the node ids they light up. Hidden devices are not probed. - Readers repointed: scanner (last_scan lands on the row), proxmox (its node tier collapses into the inventory tier, keeping only the cluster handles), zigbee/zwave (one property refresh serves every canvas), rack inventory, liveview, stats, node dedupe. - `POST /scan/pending` merges into the row that already describes the host instead of minting a second one — one device is one row, whichever way it was documented. - Standalone keeps parity: the canvas blob gains `devices`, split on save and hydrated on load. A blob written before the split still reads. The rack inventory had a related bug: a mount that names a node explicitly printed the mount's device rather than the pinned node's. It now reads the node's own row. Tests that built a node with device columns are ported to the link; where a behaviour genuinely moved (properties refresh once on the row, last_scan is the device's) the assertion moved with it rather than being dropped. ha-relevant: yes
155 lines
5.4 KiB
Python
155 lines
5.4 KiB
Python
"""Duplicate canvas nodes: the same device drawn twice on the *same* canvas.
|
|
|
|
A device legitimately appears on several canvases — one :class:`Node` per
|
|
design, all pointing at one ``device_inventory`` row. Two nodes drawing that row
|
|
on the *same* design are the corrupt case, and the only one collapsed here.
|
|
|
|
Identity is the device link, not the addresses: a node names the row it draws,
|
|
so "same device" is an id comparison rather than a guess across ieee/ip/mac.
|
|
The repair is idempotent — the oldest node stays, edges and ``parent_id``
|
|
references are re-pointed onto it, then the extras go. No device data is lost in
|
|
the process because none of it lives on the node any more.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.models import Edge, Node
|
|
from app.services.inventory_sync import find_device_for
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def find_duplicate_node(
|
|
db: AsyncSession,
|
|
design_id: str | None,
|
|
ip: str | None,
|
|
mac: str | None,
|
|
ieee: str | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""Return conflict details if this device is already drawn on ``design_id``.
|
|
|
|
Identity is resolved once, by the inventory row the addresses point at
|
|
(``find_device_for``: ieee > ip > mac), and a duplicate is simply a second
|
|
node on the same design drawing that row. Scoped to one design on purpose:
|
|
the same device may legitimately appear on several canvases.
|
|
|
|
The create/approve endpoints turn this into a 409 so the UI can offer "go to
|
|
existing" vs "add duplicate anyway".
|
|
"""
|
|
device = await find_device_for(db, ip=ip, mac=mac, ieee=ieee)
|
|
if device is None:
|
|
return None
|
|
existing = (
|
|
await db.execute(
|
|
select(Node)
|
|
.where(Node.design_id == design_id, Node.device_id == device.id)
|
|
.order_by(Node.created_at, Node.id)
|
|
)
|
|
).scalars().first()
|
|
if existing is None:
|
|
return None
|
|
|
|
# Report which address identified the device, as the UI prints it, in the
|
|
# same precedence find_device_for used: ieee > ip > mac.
|
|
match: str
|
|
value: str | None
|
|
device_ips = {t.strip() for t in (device.ip or "").split(",") if t.strip()}
|
|
shared_ip = next((t.strip() for t in (ip or "").split(",") if t.strip() in device_ips), None)
|
|
if ieee and device.ieee_address == ieee:
|
|
match, value = "ieee", ieee
|
|
elif shared_ip:
|
|
match, value = "ip", shared_ip
|
|
elif mac and device.mac == mac:
|
|
match, value = "mac", mac
|
|
else:
|
|
match, value = "ip", ip
|
|
return {
|
|
"duplicate": True,
|
|
"existing_node_id": existing.id,
|
|
"existing_label": device.label or existing.label,
|
|
"match": match,
|
|
"value": value,
|
|
}
|
|
|
|
|
|
async def dedupe_nodes_by_device(db: AsyncSession) -> int:
|
|
"""Merge duplicate nodes drawing the same device on the same canvas.
|
|
|
|
Returns the number of nodes removed. Idempotent. Nodes drawing one device on
|
|
*different* designs are left alone — that is valid cross-canvas placement.
|
|
The device facts live on the inventory row, so nothing has to be merged out
|
|
of the extras: only edges and parent links are re-pointed before they go.
|
|
Does not commit — the caller owns the transaction.
|
|
"""
|
|
rows = (
|
|
await db.execute(
|
|
select(Node)
|
|
.where(Node.device_id.is_not(None))
|
|
.order_by(Node.device_id, Node.created_at, Node.id)
|
|
)
|
|
).scalars().all()
|
|
|
|
groups: dict[tuple[str, str | None], list[Node]] = {}
|
|
for node in rows:
|
|
groups.setdefault((node.device_id, node.design_id), []).append(node) # type: ignore[arg-type]
|
|
|
|
removed = 0
|
|
for (device_id, _design), nodes in groups.items():
|
|
if len(nodes) < 2:
|
|
continue
|
|
canonical, *dups = nodes # oldest first (ordered above)
|
|
dup_ids = {d.id for d in dups}
|
|
|
|
# Re-point edges + parents, then drop self-loops / duplicates.
|
|
edges = (
|
|
await db.execute(
|
|
select(Edge).where(Edge.source.in_(dup_ids) | Edge.target.in_(dup_ids))
|
|
)
|
|
).scalars().all()
|
|
for edge in edges:
|
|
if edge.source in dup_ids:
|
|
edge.source = canonical.id
|
|
if edge.target in dup_ids:
|
|
edge.target = canonical.id
|
|
|
|
children = (
|
|
await db.execute(select(Node).where(Node.parent_id.in_(dup_ids)))
|
|
).scalars().all()
|
|
for child in children:
|
|
child.parent_id = canonical.id
|
|
|
|
all_edges = (
|
|
await db.execute(
|
|
select(Edge).where((Edge.source == canonical.id) | (Edge.target == canonical.id))
|
|
)
|
|
).scalars().all()
|
|
seen_pairs: set[tuple[str, str, str]] = set()
|
|
for edge in all_edges:
|
|
if edge.source == edge.target:
|
|
await db.delete(edge)
|
|
continue
|
|
key = (edge.source, edge.target, edge.type)
|
|
if key in seen_pairs:
|
|
await db.delete(edge)
|
|
continue
|
|
seen_pairs.add(key)
|
|
|
|
await db.flush()
|
|
for dup in dups:
|
|
await db.delete(dup)
|
|
removed += 1
|
|
|
|
logger.info(
|
|
"Deduped device %s: merged %d duplicate node(s) into %s",
|
|
device_id, len(dups), canonical.id,
|
|
)
|
|
|
|
if removed:
|
|
await db.flush()
|
|
return removed
|