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:
Pouzor
2026-06-25 18:05:13 +02:00
parent 96107cc657
commit d7ab4ba49a
8 changed files with 236 additions and 42 deletions
+53 -3
View File
@@ -169,10 +169,60 @@ async def stop_scan(
return {"stopping": True} 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]) @router.get("/pending", response_model=list[PendingDeviceResponse])
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]: 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")) # Inventory: every scanned device except the user-hidden ones. Approved devices
return list(result.scalars().all()) # 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) @router.delete("/pending", response_model=dict)
@@ -189,7 +239,7 @@ async def clear_pending(
@router.get("/hidden", response_model=list[PendingDeviceResponse]) @router.get("/hidden", response_model=list[PendingDeviceResponse])
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]: 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")) 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) @router.post("/pending/bulk-approve", response_model=dict)
+3
View File
@@ -21,6 +21,9 @@ class PendingDeviceResponse(BaseModel):
vendor: str | None = None vendor: str | None = None
lqi: int | None = None lqi: int | None = None
discovered_at: datetime 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} model_config = {"from_attributes": True}
+6 -20
View File
@@ -14,7 +14,7 @@ from typing import Any
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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.fingerprint import fingerprint_ports, suggest_node_type
from app.services.http_probe import probe_open_ports from app.services.http_probe import probe_open_ports
@@ -432,26 +432,14 @@ async def run_scan(
except ValueError: except ValueError:
raise ValueError(f"Invalid CIDR range: {r!r}") from None raise ValueError(f"Invalid CIDR range: {r!r}") from None
# Pre-fetch canvas IPs and hidden IPs once — avoids N+1 queries per host # Pre-fetch hidden IPs once — avoids N+1 queries per host.
canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None))) # Devices already on a canvas are intentionally NOT suppressed: they stay
canvas_ips: set[str] = {row[0] for row in canvas_ips_result.fetchall()} # in the inventory and are badged "In N canvas" via per-request correlation.
hidden_ips_result = await db.execute( hidden_ips_result = await db.execute(
select(PendingDevice.ip).where(PendingDevice.status == "hidden") select(PendingDevice.ip).where(PendingDevice.status == "hidden")
) )
hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()} 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 # Start mDNS discovery in the background while nmap scans run
mdns_task = asyncio.create_task(_mdns_discover()) mdns_task = asyncio.create_task(_mdns_discover())
@@ -462,10 +450,8 @@ async def run_scan(
nonlocal devices_found nonlocal devices_found
ip = host["ip"] ip = host["ip"]
# Skip canvas nodes and user-hidden devices (sets pre-fetched before loop) # Skip only user-hidden devices. On-canvas devices are kept so they
if ip in canvas_ips: # surface in the inventory with a canvas-presence badge.
logger.debug("Skipping %s — already in canvas", ip)
return
if ip in hidden_ips: if ip in hidden_ips:
logger.debug("Skipping %s — hidden by user", ip) logger.debug("Skipping %s — hidden by user", ip)
return return
+81 -11
View File
@@ -7,7 +7,7 @@ from httpx import AsyncClient
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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 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 len(data) == 1
assert data[0]["ip"] == "192.168.1.100" assert data[0]["ip"] == "192.168.1.100"
assert data[0]["hostname"] == "my-server" 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 --- # --- Approve device ---
@@ -191,9 +251,13 @@ async def test_approve_device(client: AsyncClient, headers, pending_device):
assert data["approved"] is True assert data["approved"] is True
assert "node_id" in data 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) 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 @pytest.mark.asyncio
@@ -332,8 +396,9 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_scan_purges_stale_pending_for_canvas_nodes(db_session: AsyncSession): async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSession):
"""Pending devices that were already in canvas before scan starts must be removed.""" """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( node = Node(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
label="Existing Server", 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( result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50") 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 @pytest.mark.asyncio
async def test_run_scan_skips_ip_already_in_canvas(db_session: AsyncSession): async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
"""Devices whose IP already exists as a canvas Node must not appear in pending.""" """A scanned IP that already exists as a canvas Node still produces a pending
device (no longer suppressed)."""
node = Node( node = Node(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
label="Existing Server", 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( result = await db_session.execute(
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50") 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 @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 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 len(data["device_ids"]) == 2
assert data["skipped"] == 0 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) 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 @pytest.fixture
+6 -3
View File
@@ -457,8 +457,9 @@ async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_scan_skips_canvas_nodes(mem_db): async def test_run_scan_keeps_canvas_nodes(mem_db):
"""Hosts already approved onto the canvas must be skipped.""" """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 from app.services.scanner import run_scan
run_id = _make_run_id() 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: async with mem_db() as session:
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.100")) 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 @pytest.mark.asyncio
@@ -27,6 +27,8 @@ export interface PendingDevice {
vendor?: string | null vendor?: string | null
lqi?: number | null lqi?: number | null
discovered_at: string discovered_at: string
// How many canvases (designs) this device already appears on. Computed server-side.
canvas_count?: number
} }
interface PendingDeviceModalProps { interface PendingDeviceModalProps {
@@ -119,6 +119,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all') const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
const [typeFilter, setTypeFilter] = useState<string>('all') const [typeFilter, setTypeFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<StatusFilter>(initialStatus) const [statusFilter, setStatusFilter] = useState<StatusFilter>(initialStatus)
// Inventory shows on-canvas devices by default; toggle off to hide them.
const [showOnCanvas, setShowOnCanvas] = useState(true)
const { addNode, scanEventTs } = useCanvasStore() const { addNode, scanEventTs } = useCanvasStore()
const highlightRef = useRef<HTMLButtonElement>(null) const highlightRef = useRef<HTMLButtonElement>(null)
@@ -159,6 +161,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
return devices.filter((d) => { return devices.filter((d) => {
if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) 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) { if (q) {
const hay = [ const hay = [
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model, 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 return true
}) })
}, [devices, search, sourceFilter, typeFilter]) }, [devices, search, sourceFilter, typeFilter, statusFilter, showOnCanvas])
useEffect(() => { useEffect(() => {
if (!highlightId || loading || !open) return 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`) if (failed > 0) toast.error(`Removed ${removedIds.size}, ${failed} failed`)
else toast.success(`Removed ${removedIds.size} device${removedIds.size !== 1 ? 's' : ''}`) else toast.success(`Removed ${removedIds.size} device${removedIds.size !== 1 ? 's' : ''}`)
} else { } else {
// Clears only pending rows server-side; approved/on-canvas devices stay,
// so reload rather than blanking the whole inventory.
await scanApi.clearPending() await scanApi.clearPending()
setDevices([])
setSelectedIds(new Set()) setSelectedIds(new Set())
await load()
toast.success('Pending devices cleared') toast.success('Pending devices cleared')
} }
} catch { } catch {
@@ -398,7 +404,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
<DialogHeader className="px-4 py-3 border-b border-border shrink-0"> <DialogHeader className="px-4 py-3 border-b border-border shrink-0">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<DialogTitle className="text-base font-semibold flex items-center gap-2"> <DialogTitle className="text-base font-semibold flex items-center gap-2">
{statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'} {statusFilter === 'pending' ? 'Device Inventory' : 'Hidden Devices'}
<span className="text-muted-foreground font-normal text-xs"> <span className="text-muted-foreground font-normal text-xs">
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`}) ({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
</span> </span>
@@ -479,7 +485,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
onClick={() => setStatusFilter('pending')} 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'}`} 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
</button> </button>
<button <button
onClick={() => setStatusFilter('hidden')} onClick={() => setStatusFilter('hidden')}
@@ -488,6 +494,17 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
Hidden Hidden
</button> </button>
</div> </div>
{statusFilter === 'pending' && (
<button
onClick={() => setShowOnCanvas((v) => !v)}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded border transition-colors ${showOnCanvas ? 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground' : 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50'}`}
title="Show or hide devices already on a canvas"
aria-pressed={!showOnCanvas}
>
<Layers size={12} />
{showOnCanvas ? 'Hide on-canvas' : 'Show on-canvas'}
</button>
)}
<button <button
onClick={() => selectMode ? exitSelectMode() : enterSelectMode()} onClick={() => selectMode ? exitSelectMode() : enterSelectMode()}
className={`text-xs px-2.5 py-1.5 rounded border transition-colors ${selectMode ? 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`} className={`text-xs px-2.5 py-1.5 rounded border transition-colors ${selectMode ? 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
@@ -624,12 +641,24 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
{selectMode && selected && ( {selectMode && selected && (
<CheckCircle2 <CheckCircle2
size={18} size={18}
className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117]" className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117] z-10"
/> />
)} )}
{!selectMode && device.status === 'hidden' && ( {!selectMode && device.status === 'hidden' && (
<EyeOff size={14} className="absolute top-2 right-2 text-muted-foreground" /> <EyeOff size={14} className="absolute top-2 right-2 text-muted-foreground" />
)} )}
{/* Canvas-presence corner: how many canvases this device already sits on.
Hidden while a select-mode checkmark occupies the same corner. */}
{device.status !== 'hidden' && (device.canvas_count ?? 0) > 0 && !(selectMode && selected) && (
<div
className="absolute top-0 right-0 flex items-center gap-1 rounded-bl-lg rounded-tr-lg bg-[#00d4ff] text-[#0d1117] text-xs font-bold px-2 py-1 shadow-md"
title={`On ${device.canvas_count} canvas${device.canvas_count !== 1 ? 'es' : ''}`}
aria-label={`On ${device.canvas_count} canvas${device.canvas_count !== 1 ? 'es' : ''}`}
>
<Layers size={12} strokeWidth={2.5} />
{device.canvas_count}
</div>
)}
{/* Header */} {/* Header */}
<div className="flex items-start gap-2 mb-2"> <div className="flex items-start gap-2 mb-2">
@@ -271,4 +271,55 @@ describe('PendingDevicesModal', () => {
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a'])) await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
expect(mockBulkApprove).not.toHaveBeenCalled() expect(mockBulkApprove).not.toHaveBeenCalled()
}) })
// --- Device Inventory: rename, canvas badge, on-canvas filter ---
it('titles the pending view "Device Inventory"', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('Device Inventory')).toBeInTheDocument()
})
it('shows "Hidden Devices" title in hidden mode', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByText('Hidden Devices')).toBeInTheDocument()
})
it('renders a corner canvas-count when canvas_count > 0', async () => {
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 2 }] })
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
const corner = screen.getByLabelText('On 2 canvases')
expect(corner).toHaveTextContent('2')
})
it('uses singular "canvas" for a single canvas', async () => {
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 1 }] })
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.getByLabelText('On 1 canvas')).toHaveTextContent('1')
})
it('does not render the canvas-count corner when canvas_count is 0', async () => {
mockPending.mockResolvedValue({ data: [{ ...DEVICE_IP, canvas_count: 0 }] })
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
expect(screen.queryByLabelText(/On \d+ canvas/)).not.toBeInTheDocument()
})
it('shows on-canvas devices by default and hides them when toggled off', async () => {
mockPending.mockResolvedValue({
data: [DEVICE_IP, { ...DEVICE_ZIGBEE, canvas_count: 1 }],
})
render(<PendingDevicesModal {...baseProps} />)
// Default: both visible (on-canvas shown).
await waitFor(() => expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument())
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
// Toggle off → the on-canvas device (dev-b) disappears.
fireEvent.click(screen.getByRole('button', { name: /Hide on-canvas/ }))
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
})
}) })