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}
|
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)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user