From d7ab4ba49aeba28a5be52e836cea082a2890e9f9 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 25 Jun 2026 18:05:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Device=20Inventory=20=E2=80=94=20show?= =?UTF-8?q?=20all=20scanned=20devices=20with=20canvas-presence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/routes/scan.py | 56 ++++++++++- backend/app/schemas/scan.py | 3 + backend/app/services/scanner.py | 26 ++---- backend/tests/test_scan.py | 92 ++++++++++++++++--- backend/tests/test_scanner.py | 9 +- .../components/modals/PendingDeviceModal.tsx | 2 + .../components/modals/PendingDevicesModal.tsx | 39 +++++++- .../__tests__/PendingDevicesModal.test.tsx | 51 ++++++++++ 8 files changed, 236 insertions(+), 42 deletions(-) diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 4e1214a..7f0da05 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -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) diff --git a/backend/app/schemas/scan.py b/backend/app/schemas/scan.py index 92151bf..b0f5b78 100644 --- a/backend/app/schemas/scan.py +++ b/backend/app/schemas/scan.py @@ -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} diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py index fccb315..909f268 100644 --- a/backend/app/services/scanner.py +++ b/backend/app/services/scanner.py @@ -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 diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index 7032772..5879390 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -7,7 +7,7 @@ from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import Node, PendingDevice, ScanRun +from app.db.models import Design, Node, PendingDevice, ScanRun from app.services.scanner import _cancelled_runs, request_cancel, run_scan @@ -167,6 +167,66 @@ async def test_list_pending_returns_device(client: AsyncClient, headers, pending assert len(data) == 1 assert data[0]["ip"] == "192.168.1.100" assert data[0]["hostname"] == "my-server" + # No matching node → not on any canvas. + assert data[0]["canvas_count"] == 0 + + +# --- Canvas-presence correlation (canvas_count) --- + +async def _add_design(db_session, name: str) -> 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, *, ip=None, ieee=None) -> Node: + return Node( + id=str(uuid.uuid4()), label="n", type="server", status="online", + ip=ip, ieee_address=ieee, services=[], pos_x=0.0, pos_y=0.0, + design_id=design_id, + ) + + +@pytest.mark.asyncio +async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_session, pending_device): + # Same IP placed on two different canvases → canvas_count == 2. + d1 = await _add_design(db_session, "Home") + d2 = await _add_design(db_session, "Lab") + db_session.add(_node(d1, ip="192.168.1.100")) + db_session.add(_node(d2, ip="192.168.1.100")) + await db_session.commit() + + res = await client.get("/api/v1/scan/pending", headers=headers) + data = res.json() + assert len(data) == 1 + assert data[0]["canvas_count"] == 2 + + +@pytest.mark.asyncio +async def test_canvas_count_correlates_by_ieee(client, headers, db_session): + device = PendingDevice( + id=str(uuid.uuid4()), ieee_address="0x00124b001", discovery_source="zigbee", + suggested_type="zigbee_enddevice", services=[], status="pending", + ) + db_session.add(device) + d1 = await _add_design(db_session, "Zigbee") + db_session.add(_node(d1, ieee="0x00124b001")) + await db_session.commit() + + res = await client.get("/api/v1/scan/pending", headers=headers) + by_id = {d["id"]: d for d in res.json()} + assert by_id[device.id]["canvas_count"] == 1 + + +@pytest.mark.asyncio +async def test_canvas_count_ignores_nodes_without_design(client, headers, db_session, pending_device): + # A node with no design_id is not "on a canvas". + db_session.add(_node(None, ip="192.168.1.100")) + await db_session.commit() + + res = await client.get("/api/v1/scan/pending", headers=headers) + assert res.json()[0]["canvas_count"] == 0 # --- Approve device --- @@ -191,9 +251,13 @@ async def test_approve_device(client: AsyncClient, headers, pending_device): assert data["approved"] is True assert "node_id" in data - # Device should no longer appear in pending list + # Approved devices stay in the inventory (status != "hidden") so they keep + # showing with an "In N canvas" badge — they are no longer dropped. pending_res = await client.get("/api/v1/scan/pending", headers=headers) - assert pending_res.json() == [] + inventory = pending_res.json() + assert len(inventory) == 1 + assert inventory[0]["id"] == pending_device.id + assert inventory[0]["status"] == "approved" @pytest.mark.asyncio @@ -332,8 +396,9 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession): @pytest.mark.asyncio -async def test_run_scan_purges_stale_pending_for_canvas_nodes(db_session: AsyncSession): - """Pending devices that were already in canvas before scan starts must be removed.""" +async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSession): + """Pending devices whose IP is already on a canvas are NOT purged — they stay + in the inventory and are surfaced with an "In N canvas" badge.""" node = Node( id=str(uuid.uuid4()), label="Existing Server", @@ -372,12 +437,13 @@ async def test_run_scan_purges_stale_pending_for_canvas_nodes(db_session: AsyncS result = await db_session.execute( select(PendingDevice).where(PendingDevice.ip == "192.168.1.50") ) - assert result.scalar_one_or_none() is None + assert result.scalar_one_or_none() is not None @pytest.mark.asyncio -async def test_run_scan_skips_ip_already_in_canvas(db_session: AsyncSession): - """Devices whose IP already exists as a canvas Node must not appear in pending.""" +async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession): + """A scanned IP that already exists as a canvas Node still produces a pending + device (no longer suppressed).""" node = Node( id=str(uuid.uuid4()), label="Existing Server", @@ -405,7 +471,9 @@ async def test_run_scan_skips_ip_already_in_canvas(db_session: AsyncSession): result = await db_session.execute( select(PendingDevice).where(PendingDevice.ip == "192.168.1.50") ) - assert result.scalar_one_or_none() is None + device = result.scalar_one_or_none() + assert device is not None + assert device.status == "pending" @pytest.mark.asyncio @@ -613,9 +681,11 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs" assert len(data["device_ids"]) == 2 assert data["skipped"] == 0 - # Pending list should now be empty + # Approved devices stay in the inventory, now marked "approved". pending_res = await client.get("/api/v1/scan/pending", headers=headers) - assert pending_res.json() == [] + inventory = pending_res.json() + assert len(inventory) == 2 + assert all(d["status"] == "approved" for d in inventory) @pytest.fixture diff --git a/backend/tests/test_scanner.py b/backend/tests/test_scanner.py index b7d66fa..2b02326 100644 --- a/backend/tests/test_scanner.py +++ b/backend/tests/test_scanner.py @@ -457,8 +457,9 @@ async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db): @pytest.mark.asyncio -async def test_run_scan_skips_canvas_nodes(mem_db): - """Hosts already approved onto the canvas must be skipped.""" +async def test_run_scan_keeps_canvas_nodes(mem_db): + """Hosts already on a canvas are NOT suppressed — they stay in the inventory + (badged "In N canvas" via correlation), so a re-scan still records them.""" from app.services.scanner import run_scan run_id = _make_run_id() @@ -481,7 +482,9 @@ async def test_run_scan_skips_canvas_nodes(mem_db): async with mem_db() as session: result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.100")) - assert result.scalar_one_or_none() is None + device = result.scalar_one_or_none() + assert device is not None + assert device.status == "pending" @pytest.mark.asyncio diff --git a/frontend/src/components/modals/PendingDeviceModal.tsx b/frontend/src/components/modals/PendingDeviceModal.tsx index aa76069..542363b 100644 --- a/frontend/src/components/modals/PendingDeviceModal.tsx +++ b/frontend/src/components/modals/PendingDeviceModal.tsx @@ -27,6 +27,8 @@ export interface PendingDevice { vendor?: string | null lqi?: number | null discovered_at: string + // How many canvases (designs) this device already appears on. Computed server-side. + canvas_count?: number } interface PendingDeviceModalProps { diff --git a/frontend/src/components/modals/PendingDevicesModal.tsx b/frontend/src/components/modals/PendingDevicesModal.tsx index de35096..02657cb 100644 --- a/frontend/src/components/modals/PendingDevicesModal.tsx +++ b/frontend/src/components/modals/PendingDevicesModal.tsx @@ -119,6 +119,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus const [sourceFilter, setSourceFilter] = useState('all') const [typeFilter, setTypeFilter] = useState('all') const [statusFilter, setStatusFilter] = useState(initialStatus) + // Inventory shows on-canvas devices by default; toggle off to hide them. + const [showOnCanvas, setShowOnCanvas] = useState(true) const { addNode, scanEventTs } = useCanvasStore() const highlightRef = useRef(null) @@ -159,6 +161,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus return devices.filter((d) => { if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false + // Inventory-only: optionally hide devices already placed on a canvas. + if (statusFilter === 'pending' && !showOnCanvas && (d.canvas_count ?? 0) > 0) return false if (q) { const hay = [ d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model, @@ -168,7 +172,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus } return true }) - }, [devices, search, sourceFilter, typeFilter]) + }, [devices, search, sourceFilter, typeFilter, statusFilter, showOnCanvas]) useEffect(() => { if (!highlightId || loading || !open) return @@ -241,9 +245,11 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus if (failed > 0) toast.error(`Removed ${removedIds.size}, ${failed} failed`) else toast.success(`Removed ${removedIds.size} device${removedIds.size !== 1 ? 's' : ''}`) } else { + // Clears only pending rows server-side; approved/on-canvas devices stay, + // so reload rather than blanking the whole inventory. await scanApi.clearPending() - setDevices([]) setSelectedIds(new Set()) + await load() toast.success('Pending devices cleared') } } catch { @@ -398,7 +404,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
- {statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'} + {statusFilter === 'pending' ? 'Device Inventory' : 'Hidden Devices'} ({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`}) @@ -479,7 +485,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus onClick={() => setStatusFilter('pending')} className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'pending' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`} > - Pending + Inventory
+ {statusFilter === 'pending' && ( + + )}