diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 5377cb6..0ef6398 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -1,6 +1,7 @@ import ipaddress import logging import uuid +from datetime import datetime from typing import Any from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException @@ -193,51 +194,80 @@ 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. +def _agg(values: list[datetime], *, newest: bool) -> datetime | None: + """Pick the newest (max) or oldest (min) of a list of timestamps, or None.""" + present = [v for v in values if v is not None] + if not present: + return None + return max(present) if newest else min(present) - 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. + +async def _canvas_correlation( + db: AsyncSession, devices: list[PendingDevice] +) -> dict[str, dict[str, Any]]: + """Correlate each device to existing canvas nodes by ``ieee_address`` or ``ip``. + + Returns, per device id: the number of distinct canvases (designs) it appears + on, plus aggregated timestamps from every matching node — created_at (oldest), + last_scan / updated_at / last_seen (newest). One node query, grouped in Python + (node counts are small for a homelab), so no 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) - ) + select( + Node.ip, + Node.ieee_address, + Node.design_id, + Node.created_at, + Node.last_scan, + Node.updated_at, + Node.last_seen, + ).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) + # Index matching nodes by ip and by ieee so a device can look up both. + by_ip: dict[str, list[Any]] = {} + by_ieee: dict[str, list[Any]] = {} + for row in rows: + if row.ip: + by_ip.setdefault(row.ip, []).append(row) + if row.ieee_address: + by_ieee.setdefault(row.ieee_address, []).append(row) - counts: dict[str, int] = {} + info: dict[str, dict[str, Any]] = {} for d in devices: - designs: set[str] = set() + matched = [] if d.ieee_address: - designs |= by_ieee.get(d.ieee_address, set()) + matched += by_ieee.get(d.ieee_address, []) if d.ip: - designs |= by_ip.get(d.ip, set()) - counts[d.id] = len(designs) - return counts + matched += by_ip.get(d.ip, []) + # De-duplicate nodes matched by both ip and ieee. + matched = list({id(m): m for m in matched}.values()) + designs = {m.design_id for m in matched} + info[d.id] = { + "canvas_count": len(designs), + "node_created_at": _agg([m.created_at for m in matched], newest=False), + "node_last_scan": _agg([m.last_scan for m in matched], newest=True), + "node_last_modified": _agg([m.updated_at for m in matched], newest=True), + "node_last_seen": _agg([m.last_seen for m in matched], newest=True), + } + return info 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) + """Attach transient canvas count + linked-node timestamps for the response.""" + info = await _canvas_correlation(db, devices) for d in devices: - d.canvas_count = counts.get(d.id, 0) + meta = info.get(d.id, {}) + d.canvas_count = meta.get("canvas_count", 0) + d.node_created_at = meta.get("node_created_at") + d.node_last_scan = meta.get("node_last_scan") + d.node_last_modified = meta.get("node_last_modified") + d.node_last_seen = meta.get("node_last_seen") return devices @@ -278,15 +308,38 @@ async def bulk_approve_devices( first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar() default_design_id = first_design.id if first_design else None + # Accept every selected device that isn't user-hidden. We intentionally do NOT + # filter on status == "pending": a device's status is global, but canvas + # membership is per-design. A device approved onto another canvas (or whose + # node was later deleted) must still be placeable on THIS design. Duplicates + # are guarded per-design below, not by the global status flag. result = await db.execute( select(PendingDevice).where( PendingDevice.id.in_(payload.device_ids), - PendingDevice.status == "pending", + PendingDevice.status != "hidden", ) ) devices = result.scalars().all() + + # What already sits on the target canvas, so we skip devices already placed + # here (by ip or ieee_address) instead of creating duplicate nodes. + existing = ( + await db.execute( + select(Node.ip, Node.ieee_address).where(Node.design_id == default_design_id) + ) + ).all() + placed_ips = {ip for ip, _ in existing if ip} + placed_ieee = {ieee for _, ieee in existing if ieee} + created_nodes: list[Node] = [] + approved_devices: list[PendingDevice] = [] for device in devices: + already_here = ( + (device.ip is not None and device.ip in placed_ips) + or (device.ieee_address is not None and device.ieee_address in placed_ieee) + ) + if already_here: + continue device.status = "approved" node_type = device.suggested_type or "generic" is_wireless = _is_wireless(node_type) @@ -309,12 +362,20 @@ async def bulk_approve_devices( ) db.add(node) created_nodes.append(node) + approved_devices.append(device) + # Track within this batch so a duplicate selection (same ip/ieee) is not + # placed twice on the same canvas. + if device.ip: + placed_ips.add(device.ip) + if device.ieee_address: + placed_ieee.add(device.ieee_address) await db.flush() # populates node.id from Python-side default before reading + # node_ids and approved_device_ids stay index-aligned for the client's mapping. node_ids = [n.id for n in created_nodes] - approved_device_ids = [d.id for d in devices] + approved_device_ids = [d.id for d in approved_devices] all_edges: list[dict[str, str]] = [] - for device in devices: + for device in approved_devices: all_edges.extend(await _resolve_pending_links_for_ieee(db, device.ieee_address)) await db.commit() diff --git a/backend/app/db/database.py b/backend/app/db/database.py index a95258a..041aed9 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -293,6 +293,9 @@ async def init_db() -> None: "UPDATE nodes SET properties = ? WHERE id = ?", (_json.dumps(props), node_id), ) + # Inventory timestamp: last time a scan observed this node (idempotent) + with suppress(OperationalError): + await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN last_scan DATETIME") # Migrate animated column from boolean (0/1) to string ('none'/'snake') with suppress(OperationalError): await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1") diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 1e49386..f749cc7 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -61,6 +61,7 @@ class Node(Base): bottom_handles: Mapped[int] = mapped_column(Integer, default=1) ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True) last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) response_time_ms: Mapped[int | None] = mapped_column(Integer) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) @@ -99,6 +100,9 @@ class CanvasState(Base): class PendingDevice(Base): __tablename__ = "pending_devices" + # Permit the plain (non-Mapped[]) annotations on the transient request-only + # attributes below; without this SQLAlchemy 2.0 tries to map them as columns. + __allow_unmapped__ = True id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) ip: Mapped[str | None] = mapped_column(String, nullable=True) @@ -120,6 +124,12 @@ class PendingDevice(Base): # Transient (not persisted): populated per-request by the scan routes to report # how many canvases this device already appears on. Not a mapped column. canvas_count: int = 0 + # Transient (not persisted): timestamps from the linked canvas node(s), + # correlated by ip / ieee_address. None when the device is not on any canvas. + node_created_at: datetime | None = None + node_last_scan: datetime | None = None + node_last_modified: datetime | None = None + node_last_seen: datetime | None = None class PendingDeviceLink(Base): diff --git a/backend/app/schemas/nodes.py b/backend/app/schemas/nodes.py index 5c220ed..4fb3597 100644 --- a/backend/app/schemas/nodes.py +++ b/backend/app/schemas/nodes.py @@ -73,6 +73,7 @@ class NodeResponse(NodeBase): design_id: str | None = None ieee_address: str | None = None last_seen: datetime | None = None + last_scan: datetime | None = None response_time_ms: int | None = None created_at: datetime updated_at: datetime diff --git a/backend/app/schemas/scan.py b/backend/app/schemas/scan.py index b0f5b78..3b7cef7 100644 --- a/backend/app/schemas/scan.py +++ b/backend/app/schemas/scan.py @@ -24,6 +24,13 @@ class PendingDeviceResponse(BaseModel): # 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 + # Timestamps from the linked canvas node(s), correlated by ip / ieee_address. + # Null when the device is not on any canvas yet. Aggregated across matches: + # created_at = oldest; last_scan / last_modified / last_seen = newest. + node_created_at: datetime | None = None + node_last_scan: datetime | None = None + node_last_modified: datetime | None = None + node_last_seen: datetime | None = None model_config = {"from_attributes": True} diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py index c19d1a5..07401e8 100644 --- a/backend/app/services/scanner.py +++ b/backend/app/services/scanner.py @@ -11,10 +11,10 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import PendingDevice, ScanRun +from app.db.models import Node, PendingDevice, ScanRun from app.services.fingerprint import fingerprint_ports, suggest_node_type from app.services.http_probe import probe_open_ports @@ -542,6 +542,20 @@ async def run_scan( )) devices_found += 1 + # Stamp last_scan on any canvas node that matches this device by IP + # (or MAC, when known) so the inventory shows when the scanner last + # observed it. Matches across designs. + host_mac = host.get("mac") + node_match = [Node.ip == ip] + if host_mac: + node_match.append(Node.mac == host_mac) + matching_nodes = (await db.execute( + select(Node).where(or_(*node_match)) + )).scalars().all() + scanned_at = datetime.now(timezone.utc) + for node in matching_nodes: + node.last_scan = scanned_at + await db.commit() await broadcast_scan_update(run_id=run_id, devices_found=devices_found) diff --git a/backend/tests/test_canvas.py b/backend/tests/test_canvas.py index 19202b6..d26e9c6 100644 --- a/backend/tests/test_canvas.py +++ b/backend/tests/test_canvas.py @@ -51,6 +51,18 @@ async def test_save_canvas_creates_nodes_and_edges(client: AsyncClient, headers: assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5} +async def test_load_canvas_exposes_inventory_timestamps(client: AsyncClient, headers: dict): + n1 = node_payload(label="Router", type="router") + await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) + + node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0] + # created_at / updated_at always set; last_seen / last_scan null until observed. + assert node["created_at"] is not None + assert node["updated_at"] is not None + assert "last_seen" in node + assert node["last_scan"] is None + + async def test_save_canvas_updates_existing_node(client: AsyncClient, headers: dict): n1 = node_payload(label="Old Label") await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers) diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index fe225f9..d4f2d33 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -105,6 +105,28 @@ async def test_legacy_canvas_migrates_into_default_design(legacy_engine): await engine.dispose() +async def test_legacy_nodes_gain_last_scan_column(legacy_engine): + """A legacy nodes table (no last_scan) gains the column after init_db.""" + db_path, engine = legacy_engine + await _build_legacy_schema(engine) + + await database.init_db() + + check = create_async_engine(f"sqlite+aiosqlite:///{db_path}") + try: + async with check.begin() as conn: + cols = (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall() + assert "last_scan" in {c[1] for c in cols} + # Existing rows backfill to NULL (never scanned yet). + last_scan = (await conn.exec_driver_sql( + "SELECT last_scan FROM nodes WHERE id='n1'" + )).fetchone() + assert last_scan[0] is None + finally: + await check.dispose() + await engine.dispose() + + async def test_migration_is_idempotent(legacy_engine): """Running init_db twice must not duplicate the design or drop any data.""" db_path, engine = legacy_engine diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index b9213c8..3b05eb0 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -1,5 +1,6 @@ """Tests for scan routes: trigger, pending devices, approve/hide/ignore, stop.""" import uuid +from datetime import datetime, timezone from unittest.mock import AsyncMock, patch import pytest @@ -229,6 +230,56 @@ async def test_canvas_count_ignores_nodes_without_design(client, headers, db_ses assert res.json()[0]["canvas_count"] == 0 +# --- Linked-node timestamps on the inventory response --- + +@pytest.mark.asyncio +async def test_pending_device_without_node_has_null_node_timestamps(client, headers, pending_device): + # No matching canvas node → node_* timestamps are all null; the device still + # carries its own discovered_at for the "Discovered" fallback on the tile. + data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0] + assert data["discovered_at"] is not None + assert data["node_created_at"] is None + assert data["node_last_scan"] is None + assert data["node_last_modified"] is None + assert data["node_last_seen"] is None + + +@pytest.mark.asyncio +async def test_pending_device_exposes_linked_node_timestamps(client, headers, db_session, pending_device): + d1 = await _add_design(db_session, "Home") + node = _node(d1, ip="192.168.1.100") + node.last_scan = datetime(2026, 6, 1, 8, 30, tzinfo=timezone.utc) + node.last_seen = datetime(2026, 6, 25, 9, 15, tzinfo=timezone.utc) + db_session.add(node) + await db_session.commit() + + data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0] + assert data["node_created_at"] is not None # defaulted on insert + assert data["node_last_modified"] is not None # updated_at defaulted on insert + assert data["node_last_scan"].startswith("2026-06-01") + assert data["node_last_seen"].startswith("2026-06-25") + + +@pytest.mark.asyncio +async def test_node_timestamps_aggregate_across_matches(client, headers, db_session, pending_device): + # Two canvas nodes share the device IP: created_at takes the OLDEST, + # last_scan takes the NEWEST. + d1 = await _add_design(db_session, "Home") + d2 = await _add_design(db_session, "Lab") + older = _node(d1, ip="192.168.1.100") + older.created_at = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc) + older.last_scan = datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc) + newer = _node(d2, ip="192.168.1.100") + newer.created_at = datetime(2026, 5, 1, 0, 0, tzinfo=timezone.utc) + newer.last_scan = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc) + db_session.add_all([older, newer]) + await db_session.commit() + + data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0] + assert data["node_created_at"].startswith("2026-01-01") # oldest + assert data["node_last_scan"].startswith("2026-06-01") # newest + + # --- Approve device --- @pytest.mark.asyncio @@ -741,6 +792,75 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p assert all(d["status"] == "approved" for d in inventory) +@pytest.mark.asyncio +async def test_bulk_approve_places_already_approved_device_on_another_design( + client: AsyncClient, headers, db_session, two_pending_devices +): + """Regression: a device already approved (status='approved', e.g. placed on + another canvas) must still get a node on the design being approved onto. + + Previously bulk-approve filtered status=='pending', so selecting an + already-approved device created no node — the user saw fewer nodes than + they selected.""" + ids = [d.id for d in two_pending_devices] + design_a = await _add_design(db_session, "Canvas A") + design_b = await _add_design(db_session, "Canvas B") + + # Approve both onto design A. + res_a = await client.post( + "/api/v1/scan/pending/bulk-approve", + json={"device_ids": ids, "design_id": design_a}, + headers=headers, + ) + assert res_a.json()["approved"] == 2 + + # Re-approve the same (now status='approved') devices onto design B. + res_b = await client.post( + "/api/v1/scan/pending/bulk-approve", + json={"device_ids": ids, "design_id": design_b}, + headers=headers, + ) + data_b = res_b.json() + assert data_b["approved"] == 2, "already-approved devices must place onto the new canvas" + assert data_b["skipped"] == 0 + + # Two nodes now exist on each design. + from app.db.models import Node as NodeModel + nodes_b = ( + await db_session.execute(select(NodeModel).where(NodeModel.design_id == design_b)) + ).scalars().all() + assert len(nodes_b) == 2 + + +@pytest.mark.asyncio +async def test_bulk_approve_skips_device_already_on_target_design( + client: AsyncClient, headers, db_session, two_pending_devices +): + """A device already on the target canvas (same ip) is not placed twice.""" + ids = [d.id for d in two_pending_devices] + design = await _add_design(db_session, "Canvas") + # First device already sits on the canvas (matched by ip). + db_session.add(_node(design, ip="192.168.1.10")) + await db_session.commit() + + res = await client.post( + "/api/v1/scan/pending/bulk-approve", + json={"device_ids": ids, "design_id": design}, + headers=headers, + ) + data = res.json() + assert data["approved"] == 1 # only the second device (192.168.1.11) + assert data["skipped"] == 1 + + from app.db.models import Node as NodeModel + nodes = ( + await db_session.execute(select(NodeModel).where(NodeModel.design_id == design)) + ).scalars().all() + # The pre-existing node plus the one newly approved — no duplicate for .10. + assert len(nodes) == 2 + assert sorted(n.ip for n in nodes) == ["192.168.1.10", "192.168.1.11"] + + @pytest.fixture async def zigbee_pending_device(db_session): device = PendingDevice( diff --git a/backend/tests/test_scanner.py b/backend/tests/test_scanner.py index 2b02326..c493a7d 100644 --- a/backend/tests/test_scanner.py +++ b/backend/tests/test_scanner.py @@ -404,6 +404,84 @@ async def test_run_scan_adds_nmap_devices_as_pending(mem_db): assert any(d.ip == "192.168.1.5" for d in devices) +@pytest.mark.asyncio +async def test_run_scan_stamps_last_scan_on_matching_node_by_ip(mem_db): + """A scan that sees a device matching a canvas node (by IP) stamps last_scan.""" + from app.services.scanner import run_scan + + run_id = _make_run_id() + async with mem_db() as session: + session.add(_make_scan_run(run_id)) + session.add(Node(id="n1", type="server", label="NAS", ip="192.168.1.5")) + await session.commit() + + nmap_hosts = [{"ip": "192.168.1.5", "hostname": "nas.lan", "mac": None, "os": None, "open_ports": []}] + + async with mem_db() as session: + with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \ + patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \ + patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock): + await run_scan(["192.168.1.0/24"], session, run_id) + + async with mem_db() as session: + node = await session.get(Node, "n1") + + assert node is not None + assert node.last_scan is not None + + +@pytest.mark.asyncio +async def test_run_scan_stamps_last_scan_on_matching_node_by_mac(mem_db): + """A node with no IP but a matching MAC still gets last_scan stamped.""" + from app.services.scanner import run_scan + + run_id = _make_run_id() + async with mem_db() as session: + session.add(_make_scan_run(run_id)) + session.add(Node(id="n2", type="iot", label="Sensor", mac="AA:BB:CC:DD:EE:FF")) + await session.commit() + + nmap_hosts = [{"ip": "192.168.1.9", "hostname": None, "mac": "AA:BB:CC:DD:EE:FF", "os": None, "open_ports": []}] + + async with mem_db() as session: + with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \ + patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \ + patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock): + await run_scan(["192.168.1.0/24"], session, run_id) + + async with mem_db() as session: + node = await session.get(Node, "n2") + + assert node is not None + assert node.last_scan is not None + + +@pytest.mark.asyncio +async def test_run_scan_leaves_last_scan_untouched_on_unmatched_node(mem_db): + """A node whose IP/MAC is not seen by the scan keeps last_scan = None.""" + from app.services.scanner import run_scan + + run_id = _make_run_id() + async with mem_db() as session: + session.add(_make_scan_run(run_id)) + session.add(Node(id="n3", type="server", label="Other", ip="10.0.0.99")) + await session.commit() + + nmap_hosts = [{"ip": "192.168.1.5", "hostname": None, "mac": None, "os": None, "open_ports": []}] + + async with mem_db() as session: + with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \ + patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \ + patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock): + await run_scan(["192.168.1.0/24"], session, run_id) + + async with mem_db() as session: + node = await session.get(Node, "n3") + + assert node is not None + assert node.last_scan is None + + @pytest.mark.asyncio async def test_run_scan_mdns_only_device_added(mem_db): """Devices found only by mDNS (not nmap) should appear in pending_devices.""" diff --git a/frontend/src/components/modals/PendingDeviceModal.tsx b/frontend/src/components/modals/PendingDeviceModal.tsx index 542363b..d511dea 100644 --- a/frontend/src/components/modals/PendingDeviceModal.tsx +++ b/frontend/src/components/modals/PendingDeviceModal.tsx @@ -29,6 +29,12 @@ export interface PendingDevice { discovered_at: string // How many canvases (designs) this device already appears on. Computed server-side. canvas_count?: number + // Timestamps from the linked canvas node(s), correlated by ip/ieee_address. + // Null/absent when the device is not on any canvas yet. + node_created_at?: string | null + node_last_scan?: string | null + node_last_modified?: string | null + node_last_seen?: string | null } interface PendingDeviceModalProps { diff --git a/frontend/src/components/modals/PendingDevicesModal.tsx b/frontend/src/components/modals/PendingDevicesModal.tsx index b458fea..27b09f6 100644 --- a/frontend/src/components/modals/PendingDevicesModal.tsx +++ b/frontend/src/components/modals/PendingDevicesModal.tsx @@ -13,6 +13,7 @@ import type { NodeType, ServiceInfo } from '@/types' import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties' import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties' import { buildMacProperty } from '@/utils/macProperty' +import { formatRelative, formatTimestamp } from '@/utils/timeFormat' interface PendingDevicesModalProps { open: boolean @@ -562,7 +563,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
)} {!loading && filtered.length > 0 && ( -