feat: Device Inventory — show all scanned devices with canvas-presence
Reworks the "Pending Devices" panel into a "Device Inventory": scanned devices already placed on a canvas are no longer suppressed — they stay listed and badged with how many canvases they appear on. - scanner: stop deleting/skipping on-canvas IPs (hidden still suppressed) - scan API: /pending returns all non-hidden devices; compute canvas_count by correlating ip/ieee_address against nodes grouped by design - frontend: rename to "Device Inventory", top-right canvas-count corner, toggle to show/hide on-canvas devices (default show) ha-relevant: maybe
This commit is contained in:
@@ -169,10 +169,60 @@ async def stop_scan(
|
||||
return {"stopping": True}
|
||||
|
||||
|
||||
async def _canvas_counts(
|
||||
db: AsyncSession, devices: list[PendingDevice]
|
||||
) -> dict[str, int]:
|
||||
"""Map each device id → number of distinct canvases (designs) it appears on.
|
||||
|
||||
Correlates a scanned device to existing nodes by ``ieee_address`` (exact) or
|
||||
``ip`` (exact). Runs a single node query and groups in Python — node counts are
|
||||
small for a homelab, so this avoids an N+1 per device.
|
||||
"""
|
||||
if not devices:
|
||||
return {}
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Node.ip, Node.ieee_address, Node.design_id).where(
|
||||
Node.design_id.isnot(None)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
# ip → set(design_id), ieee → set(design_id)
|
||||
by_ip: dict[str, set[str]] = {}
|
||||
by_ieee: dict[str, set[str]] = {}
|
||||
for ip, ieee, design_id in rows:
|
||||
if ip:
|
||||
by_ip.setdefault(ip, set()).add(design_id)
|
||||
if ieee:
|
||||
by_ieee.setdefault(ieee, set()).add(design_id)
|
||||
|
||||
counts: dict[str, int] = {}
|
||||
for d in devices:
|
||||
designs: set[str] = set()
|
||||
if d.ieee_address:
|
||||
designs |= by_ieee.get(d.ieee_address, set())
|
||||
if d.ip:
|
||||
designs |= by_ip.get(d.ip, set())
|
||||
counts[d.id] = len(designs)
|
||||
return counts
|
||||
|
||||
|
||||
async def _with_canvas_counts(
|
||||
db: AsyncSession, devices: list[PendingDevice]
|
||||
) -> list[PendingDevice]:
|
||||
"""Attach a transient ``canvas_count`` to each device for the response schema."""
|
||||
counts = await _canvas_counts(db, devices)
|
||||
for d in devices:
|
||||
d.canvas_count = counts.get(d.id, 0)
|
||||
return devices
|
||||
|
||||
|
||||
@router.get("/pending", response_model=list[PendingDeviceResponse])
|
||||
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
|
||||
return list(result.scalars().all())
|
||||
# Inventory: every scanned device except the user-hidden ones. Approved devices
|
||||
# stay listed so they keep showing with a canvas-presence badge.
|
||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status != "hidden"))
|
||||
return await _with_canvas_counts(db, list(result.scalars().all()))
|
||||
|
||||
|
||||
@router.delete("/pending", response_model=dict)
|
||||
@@ -189,7 +239,7 @@ async def clear_pending(
|
||||
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
||||
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
|
||||
return list(result.scalars().all())
|
||||
return await _with_canvas_counts(db, list(result.scalars().all()))
|
||||
|
||||
|
||||
@router.post("/pending/bulk-approve", response_model=dict)
|
||||
|
||||
@@ -21,6 +21,9 @@ class PendingDeviceResponse(BaseModel):
|
||||
vendor: str | None = None
|
||||
lqi: int | None = None
|
||||
discovered_at: datetime
|
||||
# Number of distinct canvases (designs) this device already appears on,
|
||||
# correlated by ip / ieee_address against existing nodes. Computed per-request.
|
||||
canvas_count: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import Any
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
from app.db.models import PendingDevice, ScanRun
|
||||
from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
||||
from app.services.http_probe import probe_open_ports
|
||||
|
||||
@@ -432,26 +432,14 @@ async def run_scan(
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid CIDR range: {r!r}") from None
|
||||
|
||||
# Pre-fetch canvas IPs and hidden IPs once — avoids N+1 queries per host
|
||||
canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None)))
|
||||
canvas_ips: set[str] = {row[0] for row in canvas_ips_result.fetchall()}
|
||||
|
||||
# Pre-fetch hidden IPs once — avoids N+1 queries per host.
|
||||
# Devices already on a canvas are intentionally NOT suppressed: they stay
|
||||
# in the inventory and are badged "In N canvas" via per-request correlation.
|
||||
hidden_ips_result = await db.execute(
|
||||
select(PendingDevice.ip).where(PendingDevice.status == "hidden")
|
||||
)
|
||||
hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()}
|
||||
|
||||
# Clean up stale pending devices whose IPs are already in the canvas
|
||||
if canvas_ips:
|
||||
from sqlalchemy import delete as sa_delete
|
||||
await db.execute(
|
||||
sa_delete(PendingDevice).where(
|
||||
PendingDevice.status == "pending",
|
||||
PendingDevice.ip.in_(canvas_ips),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
# Start mDNS discovery in the background while nmap scans run
|
||||
mdns_task = asyncio.create_task(_mdns_discover())
|
||||
|
||||
@@ -462,10 +450,8 @@ async def run_scan(
|
||||
nonlocal devices_found
|
||||
ip = host["ip"]
|
||||
|
||||
# Skip canvas nodes and user-hidden devices (sets pre-fetched before loop)
|
||||
if ip in canvas_ips:
|
||||
logger.debug("Skipping %s — already in canvas", ip)
|
||||
return
|
||||
# Skip only user-hidden devices. On-canvas devices are kept so they
|
||||
# surface in the inventory with a canvas-presence badge.
|
||||
if ip in hidden_ips:
|
||||
logger.debug("Skipping %s — hidden by user", ip)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user