feat(zigbee): import to pending section with edge persistence

Coordinator auto-approves to a canvas Node; routers/end devices land in
pending_devices keyed by IEEE. Discovered parent->child edges are stored
in pending_device_links so that approving a pending device later
auto-creates the Edge once both endpoints exist as canvas Nodes.

- new POST /api/v1/zigbee/import-pending (default mode in modal)
- new pending_device_links table; ieee_address on nodes + pending_devices
- pending_devices.ip migrated to nullable (table rebuild on existing DBs)
- approve / bulk-approve return auto-created edges; sidebar pushes them
  into the canvas store with bottom -> top-t handles
- ZigbeeImportModal: radio toggle pending vs canvas; reset on close
- PendingDeviceModal: zigbee badge, IEEE/LQI/vendor/model rows, services
  hidden for zigbee
- Sidebar pending row: ZIG source badge, LQI badge, friendly_name fallback
- SearchBar: null-safe IP, also searches friendly_name and ieee_address
- Tooltip trigger uses asChild to avoid nested-button hydration error
This commit is contained in:
Pouzor
2026-05-07 23:02:03 +02:00
parent 5e567d4628
commit 3ae159d8d6
15 changed files with 988 additions and 40 deletions
+101 -3
View File
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.config import settings
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, ScanRun
from app.db.models import Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.services.scanner import request_cancel, run_scan
@@ -126,23 +126,31 @@ async def bulk_approve_devices(
for device in devices:
device.status = "approved"
node = Node(
label=device.hostname or device.ip,
label=device.hostname or device.friendly_name or device.ip or "device",
type=device.suggested_type or "generic",
ip=device.ip,
hostname=device.hostname,
status="unknown",
services=device.services or [],
ieee_address=device.ieee_address,
)
db.add(node)
created_nodes.append(node)
await db.flush() # populates node.id from Python-side default before reading
node_ids = [n.id for n in created_nodes]
approved_device_ids = [d.id for d in devices]
all_edges: list[dict[str, str]] = []
for device in devices:
all_edges.extend(await _resolve_pending_links_for_ieee(db, device.ieee_address))
await db.commit()
return {
"approved": len(node_ids),
"node_ids": node_ids,
"device_ids": approved_device_ids,
"edges_created": len(all_edges),
"edges": all_edges,
"skipped": len(payload.device_ids) - len(node_ids),
}
@@ -186,12 +194,102 @@ async def approve_device(
hostname=node_data.hostname,
status=node_data.status,
services=node_data.services or [],
ieee_address=device.ieee_address,
)
db.add(node)
await db.flush()
node_id = node.id
edges = await _resolve_pending_links_for_ieee(db, device.ieee_address)
await db.commit()
return {"approved": True, "node_id": node_id}
return {
"approved": True,
"node_id": node_id,
"edges_created": len(edges),
"edges": edges,
}
async def _resolve_pending_links_for_ieee(
db: AsyncSession, ieee: str | None
) -> list[dict[str, str]]:
"""Materialize edges for any pending_device_links involving ``ieee``.
For each link where the other endpoint already exists as a canvas Node
(matched by ``Node.ieee_address``), create the Edge and drop the link
row. Links where the other endpoint is still pending are kept so they
can resolve when that endpoint is approved later.
"""
if not ieee:
return []
links_q = await db.execute(
select(PendingDeviceLink).where(
(PendingDeviceLink.source_ieee == ieee)
| (PendingDeviceLink.target_ieee == ieee)
)
)
links = list(links_q.scalars().all())
if not links:
return []
# Map every relevant ieee → Node (single query).
other_ieees = {
link.target_ieee if link.source_ieee == ieee else link.source_ieee
for link in links
}
other_ieees.add(ieee)
nodes_q = await db.execute(
select(Node).where(Node.ieee_address.in_(other_ieees))
)
by_ieee = {n.ieee_address: n for n in nodes_q.scalars().all() if n.ieee_address}
self_node = by_ieee.get(ieee)
if self_node is None:
return []
# Pre-fetch existing edges between these node ids so we don't create dups
# if the user re-approves a device or had drawn the link manually.
candidate_node_ids = [n.id for n in by_ieee.values()]
existing_q = await db.execute(
select(Edge).where(
Edge.source.in_(candidate_node_ids),
Edge.target.in_(candidate_node_ids),
)
)
existing_pairs = {(e.source, e.target) for e in existing_q.scalars().all()}
created: list[dict[str, str]] = []
for link in links:
other_ieee = (
link.target_ieee if link.source_ieee == ieee else link.source_ieee
)
other_node = by_ieee.get(other_ieee)
if other_node is None:
continue
if link.source_ieee == ieee:
src_id, tgt_id = self_node.id, other_node.id
else:
src_id, tgt_id = other_node.id, self_node.id
# Skip if either direction already exists.
if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs:
await db.delete(link)
continue
edge = Edge(
source=src_id,
target=tgt_id,
type="iot",
source_handle="bottom",
target_handle="top-t",
)
db.add(edge)
await db.flush()
existing_pairs.add((src_id, tgt_id))
created.append({"id": edge.id, "source": src_id, "target": tgt_id})
await db.delete(link)
return created
@router.post("/pending/{device_id}/hide")
+164
View File
@@ -1,12 +1,20 @@
"""FastAPI router for Zigbee2MQTT import."""
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import delete as sa_delete
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import Node, PendingDevice, PendingDeviceLink
from app.schemas.zigbee import (
ZigbeeCoordinatorOut,
ZigbeeEdgeOut,
ZigbeeImportPendingResponse,
ZigbeeImportRequest,
ZigbeeImportResponse,
ZigbeeNodeOut,
@@ -58,6 +66,162 @@ async def import_zigbee_network(
return ZigbeeImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
@router.post("/import-pending", response_model=ZigbeeImportPendingResponse)
async def import_zigbee_to_pending(
payload: ZigbeeImportRequest,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> ZigbeeImportPendingResponse:
"""Fetch the Z2M networkmap and store devices in the pending section.
Coordinator is auto-approved (creates a canvas Node directly with
``ieee_address`` set). Routers and end devices are upserted into
``pending_devices`` keyed by IEEE address. The discovered parent→child
edges are persisted as ``pending_device_links`` rows so that approving a
pending device later can auto-create the corresponding Edge when the
other endpoint already exists as a canvas Node.
Re-importing replaces all zigbee-discovered links and updates pending
rows in place; pending devices not present in the new map are kept
untouched (the user may be mid-approval).
"""
try:
nodes_raw, edges_raw = await fetch_networkmap(
mqtt_host=payload.mqtt_host,
mqtt_port=payload.mqtt_port,
base_topic=payload.base_topic,
username=payload.mqtt_username,
password=payload.mqtt_password,
tls=payload.mqtt_tls,
tls_insecure=payload.mqtt_tls_insecure,
)
except ImportError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
except ConnectionError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except TimeoutError as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Unexpected error during Zigbee pending import")
raise HTTPException(status_code=500, detail="Unexpected error during Zigbee import") from exc
return await _persist_pending_import(db, nodes_raw, edges_raw)
async def _persist_pending_import(
db: AsyncSession,
nodes_raw: list[dict[str, Any]],
edges_raw: list[dict[str, Any]],
) -> ZigbeeImportPendingResponse:
"""Upsert nodes/edges into pending_devices + pending_device_links.
Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE.
All zigbee-source links are wiped and re-inserted from the new map.
"""
coordinator_out: ZigbeeCoordinatorOut | None = None
coordinator_existed = False
pending_created = 0
pending_updated = 0
for n in nodes_raw:
ieee = n.get("ieee_address")
if not ieee:
continue
if n.get("device_type") == "Coordinator":
existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
existing_node = existing.scalar_one_or_none()
if existing_node:
coordinator_out = ZigbeeCoordinatorOut(
id=existing_node.id,
label=existing_node.label,
ieee_address=ieee,
)
coordinator_existed = True
continue
label = n.get("friendly_name") or ieee
node = Node(
label=label,
type=n.get("type") or "zigbee_coordinator",
status="unknown",
ieee_address=ieee,
services=[],
)
db.add(node)
await db.flush()
coordinator_out = ZigbeeCoordinatorOut(
id=node.id, label=label, ieee_address=ieee
)
continue
result = await db.execute(
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
)
pending = result.scalar_one_or_none()
if pending is None:
db.add(
PendingDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
suggested_type=n.get("type"),
device_subtype=n.get("device_type"),
model=n.get("model"),
vendor=n.get("vendor"),
lqi=n.get("lqi"),
status="pending",
discovery_source="zigbee",
)
)
pending_created += 1
else:
pending.friendly_name = n.get("friendly_name") or pending.friendly_name
pending.suggested_type = n.get("type") or pending.suggested_type
pending.device_subtype = n.get("device_type") or pending.device_subtype
pending.model = n.get("model") or pending.model
pending.vendor = n.get("vendor") or pending.vendor
if n.get("lqi") is not None:
pending.lqi = n.get("lqi")
if pending.status == "hidden":
# Re-imported a hidden device → leave it hidden, just refresh fields.
pass
pending_updated += 1
# Replace all zigbee-source links with the freshly discovered set.
await db.execute(
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zigbee")
)
links_recorded = 0
seen: set[tuple[str, str]] = set()
for e in edges_raw:
src = e.get("source")
tgt = e.get("target")
if not src or not tgt or (src, tgt) in seen:
continue
seen.add((src, tgt))
db.add(
PendingDeviceLink(
source_ieee=src,
target_ieee=tgt,
discovery_source="zigbee",
)
)
links_recorded += 1
await db.commit()
return ZigbeeImportPendingResponse(
pending_created=pending_created,
pending_updated=pending_updated,
coordinator=coordinator_out,
coordinator_already_existed=coordinator_existed,
links_recorded=links_recorded,
device_count=len(nodes_raw),
)
@router.post("/test-connection", response_model=ZigbeeTestConnectionResponse)
async def test_zigbee_connection(
payload: ZigbeeTestConnectionRequest,
+66
View File
@@ -80,6 +80,72 @@ async def init_db() -> None:
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ieee_address TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("CREATE INDEX IF NOT EXISTS ix_nodes_ieee_address ON nodes(ieee_address)")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN ieee_address TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
"ON pending_devices(ieee_address)"
)
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN friendly_name TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN device_subtype TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN model TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN vendor TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN lqi INTEGER")
# Drop NOT NULL on pending_devices.ip (Zigbee devices have no IP).
# SQLite can't ALTER column nullability — rebuild the table if needed.
with suppress(OperationalError):
info = await conn.exec_driver_sql("PRAGMA table_info(pending_devices)")
cols = info.fetchall()
ip_col = next((c for c in cols if c[1] == "ip"), None)
# PRAGMA table_info row layout: (cid, name, type, notnull, dflt, pk)
if ip_col and ip_col[3] == 1:
logger.info("Migrating pending_devices: dropping NOT NULL on ip column")
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
await conn.exec_driver_sql(
"CREATE TABLE pending_devices_new ("
"id VARCHAR PRIMARY KEY,"
"ip VARCHAR,"
"mac VARCHAR, hostname VARCHAR, os VARCHAR, services JSON,"
"suggested_type VARCHAR,"
"status VARCHAR,"
"discovery_source VARCHAR,"
"ieee_address VARCHAR,"
"friendly_name VARCHAR,"
"device_subtype VARCHAR,"
"model VARCHAR,"
"vendor VARCHAR,"
"lqi INTEGER,"
"discovered_at DATETIME"
")"
)
await conn.exec_driver_sql(
"INSERT INTO pending_devices_new "
"(id, ip, mac, hostname, os, services, suggested_type, status, "
"discovery_source, ieee_address, friendly_name, device_subtype, "
"model, vendor, lqi, discovered_at) "
"SELECT id, ip, mac, hostname, os, services, suggested_type, status, "
"discovery_source, ieee_address, friendly_name, device_subtype, "
"model, vendor, lqi, discovered_at FROM pending_devices"
)
await conn.exec_driver_sql("DROP TABLE pending_devices")
await conn.exec_driver_sql(
"ALTER TABLE pending_devices_new RENAME TO pending_devices"
)
await conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
"ON pending_devices(ieee_address)"
)
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
with suppress(OperationalError):
+27 -1
View File
@@ -46,6 +46,7 @@ class Node(Base):
width: Mapped[float | None] = mapped_column(Float, nullable=True)
height: Mapped[float | None] = mapped_column(Float, nullable=True)
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))
response_time_ms: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -86,7 +87,7 @@ class PendingDevice(Base):
__tablename__ = "pending_devices"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
ip: Mapped[str] = mapped_column(String, nullable=False)
ip: Mapped[str | None] = mapped_column(String, nullable=True)
mac: Mapped[str | None] = mapped_column(String)
hostname: Mapped[str | None] = mapped_column(String)
os: Mapped[str | None] = mapped_column(String)
@@ -94,6 +95,31 @@ class PendingDevice(Base):
suggested_type: Mapped[str | None] = mapped_column(String)
status: Mapped[str] = mapped_column(String, default="pending")
discovery_source: Mapped[str | None] = mapped_column(String)
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True, unique=True)
friendly_name: Mapped[str | None] = mapped_column(String, nullable=True)
device_subtype: Mapped[str | None] = mapped_column(String, nullable=True)
model: Mapped[str | None] = mapped_column(String, nullable=True)
vendor: Mapped[str | None] = mapped_column(String, nullable=True)
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class PendingDeviceLink(Base):
"""Link between two Zigbee endpoints discovered during import.
Endpoints are addressed by IEEE (stable across re-imports). Either side may
already exist as a canvas Node (resolved via Node.ieee_address) or still be
a PendingDevice. On approval, the matching Edge is auto-created when both
endpoints exist as canvas Nodes.
"""
__tablename__ = "pending_device_links"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
source_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
target_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
discovery_source: Mapped[str] = mapped_column(String, nullable=False, default="zigbee")
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+7 -1
View File
@@ -6,7 +6,7 @@ from pydantic import BaseModel
class PendingDeviceResponse(BaseModel):
id: str
ip: str
ip: str | None
mac: str | None
hostname: str | None
os: str | None
@@ -14,6 +14,12 @@ class PendingDeviceResponse(BaseModel):
suggested_type: str | None
status: str
discovery_source: str | None
ieee_address: str | None = None
friendly_name: str | None = None
device_subtype: str | None = None
model: str | None = None
vendor: str | None = None
lqi: int | None = None
discovered_at: datetime
model_config = {"from_attributes": True}
+17
View File
@@ -76,3 +76,20 @@ class ZigbeeImportResponse(BaseModel):
class ZigbeeTestConnectionResponse(BaseModel):
connected: bool
message: str
class ZigbeeCoordinatorOut(BaseModel):
id: str
label: str
ieee_address: str
class ZigbeeImportPendingResponse(BaseModel):
"""Result of importing a Z2M network into the pending section."""
pending_created: int
pending_updated: int
coordinator: ZigbeeCoordinatorOut | None = None
coordinator_already_existed: bool = False
links_recorded: int
device_count: int
+198
View File
@@ -542,3 +542,201 @@ async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices)
ids = [d.id for d in two_pending_devices]
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
assert res.status_code == 401
# ---------------------------------------------------------------------------
# Approve auto-creates Edges from pending_device_links (Zigbee flow)
# ---------------------------------------------------------------------------
async def _seed_zigbee_pending_pair(db_session):
"""Create a coordinator Node + a pending device + a link between them."""
from app.db.models import Node, PendingDevice, PendingDeviceLink
coord = Node(
label="Coordinator",
type="zigbee_coordinator",
status="unknown",
ieee_address="0xCOORD",
)
db_session.add(coord)
pending = PendingDevice(
ieee_address="0xR1",
friendly_name="router_1",
suggested_type="zigbee_router",
device_subtype="Router",
status="pending",
discovery_source="zigbee",
)
db_session.add(pending)
db_session.add(
PendingDeviceLink(
source_ieee="0xCOORD",
target_ieee="0xR1",
discovery_source="zigbee",
)
)
await db_session.commit()
return coord, pending
@pytest.mark.asyncio
async def test_approve_zigbee_creates_edge_when_other_endpoint_is_node(
client: AsyncClient, headers, db_session
):
from sqlalchemy import select
from app.db.models import Edge
coord, pending = await _seed_zigbee_pending_pair(db_session)
res = await client.post(
f"/api/v1/scan/pending/{pending.id}/approve",
json={
"label": "router_1",
"type": "zigbee_router",
"ip": None,
"status": "unknown",
"services": [],
},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["approved"] is True
assert data["edges_created"] == 1
edges = (await db_session.execute(select(Edge))).scalars().all()
assert len(edges) == 1
assert edges[0].source == coord.id
assert edges[0].target == data["node_id"]
assert edges[0].source_handle == "bottom"
assert edges[0].target_handle == "top-t"
assert edges[0].type == "iot"
@pytest.mark.asyncio
async def test_approve_zigbee_skips_duplicate_edge(
client: AsyncClient, headers, db_session
):
"""Re-running the resolution does not create a second edge for the same pair."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
coord, pending = await _seed_zigbee_pending_pair(db_session)
body = {"label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
await client.post(f"/api/v1/scan/pending/{pending.id}/approve", json=body, headers=headers)
# Simulate a second pending row + link between same coord and a new device,
# but keep an existing edge in place to verify dedupe also handles
# the swapped-direction case.
new_pending = PendingDevice(
ieee_address="0xR1B",
friendly_name="r1b",
suggested_type="zigbee_router",
status="pending",
discovery_source="zigbee",
)
db_session.add(new_pending)
db_session.add(
PendingDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
)
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{new_pending.id}/approve", json=body, headers=headers
)
assert res.json()["edges_created"] == 1 # only the new pair
edges = (await db_session.execute(select(Edge))).scalars().all()
assert len(edges) == 2 # original + new, no duplicate
@pytest.mark.asyncio
async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
client: AsyncClient, headers, db_session
):
"""Both endpoints pending → no edge yet, link row preserved for later."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
a = PendingDevice(
ieee_address="0xA",
friendly_name="a",
suggested_type="zigbee_router",
status="pending",
discovery_source="zigbee",
)
b = PendingDevice(
ieee_address="0xB",
friendly_name="b",
suggested_type="zigbee_enddevice",
status="pending",
discovery_source="zigbee",
)
db_session.add_all([a, b])
db_session.add(
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
)
await db_session.commit()
res = await client.post(
f"/api/v1/scan/pending/{a.id}/approve",
json={
"label": "a",
"type": "zigbee_router",
"ip": None,
"status": "unknown",
"services": [],
},
headers=headers,
)
assert res.status_code == 200
assert res.json()["edges_created"] == 0
edges = (await db_session.execute(select(Edge))).scalars().all()
assert edges == []
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
assert len(links) == 1 # preserved for later resolution
@pytest.mark.asyncio
async def test_approve_zigbee_resolves_link_after_second_approval(
client: AsyncClient, headers, db_session
):
"""First approval keeps link; second approval creates the edge."""
from sqlalchemy import select
from app.db.models import Edge, PendingDevice, PendingDeviceLink
a = PendingDevice(
ieee_address="0xA",
friendly_name="a",
suggested_type="zigbee_router",
status="pending",
discovery_source="zigbee",
)
b = PendingDevice(
ieee_address="0xB",
friendly_name="b",
suggested_type="zigbee_enddevice",
status="pending",
discovery_source="zigbee",
)
db_session.add_all([a, b])
db_session.add(
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
)
await db_session.commit()
body = {"label": "x", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
await client.post(f"/api/v1/scan/pending/{a.id}/approve", json=body, headers=headers)
res = await client.post(f"/api/v1/scan/pending/{b.id}/approve", json=body, headers=headers)
assert res.json()["edges_created"] == 1
edges = (await db_session.execute(select(Edge))).scalars().all()
assert len(edges) == 1
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
assert links == [] # consumed
+161
View File
@@ -264,6 +264,167 @@ async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: di
assert res.status_code == 422
# ---------------------------------------------------------------------------
# /api/v1/zigbee/import-pending
# ---------------------------------------------------------------------------
_PENDING_NODES = [
{
"id": "0xCOORD",
"label": "Coordinator",
"type": "zigbee_coordinator",
"ieee_address": "0xCOORD",
"friendly_name": "Coordinator",
"device_type": "Coordinator",
"model": None,
"vendor": None,
"lqi": None,
"parent_id": None,
},
{
"id": "0xR1",
"label": "router_1",
"type": "zigbee_router",
"ieee_address": "0xR1",
"friendly_name": "router_1",
"device_type": "Router",
"model": "CC2530",
"vendor": "TI",
"lqi": 220,
"parent_id": "0xCOORD",
},
{
"id": "0xE1",
"label": "bulb_kitchen",
"type": "zigbee_enddevice",
"ieee_address": "0xE1",
"friendly_name": "bulb_kitchen",
"device_type": "EndDevice",
"model": "TRADFRI",
"vendor": "IKEA",
"lqi": 180,
"parent_id": "0xR1",
},
]
_PENDING_EDGES = [
{"source": "0xCOORD", "target": "0xR1"},
{"source": "0xR1", "target": "0xE1"},
]
@pytest.mark.asyncio
async def test_import_pending_creates_coordinator_and_pending(
client: AsyncClient, headers: dict
) -> None:
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.return_value = (_PENDING_NODES, _PENDING_EDGES)
res = await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["device_count"] == 3
assert data["pending_created"] == 2 # router + enddevice
assert data["pending_updated"] == 0
assert data["coordinator"] is not None
assert data["coordinator"]["ieee_address"] == "0xCOORD"
assert data["coordinator_already_existed"] is False
assert data["links_recorded"] == 2
pending = await client.get("/api/v1/scan/pending", headers=headers)
assert pending.status_code == 200
rows = pending.json()
ieees = {r["ieee_address"] for r in rows}
assert ieees == {"0xR1", "0xE1"}
router = next(r for r in rows if r["ieee_address"] == "0xR1")
assert router["model"] == "CC2530"
assert router["lqi"] == 220
assert router["device_subtype"] == "Router"
assert router["discovery_source"] == "zigbee"
@pytest.mark.asyncio
async def test_import_pending_idempotent_updates_existing(
client: AsyncClient, headers: dict
) -> None:
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.return_value = (_PENDING_NODES, _PENDING_EDGES)
await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
bumped = [dict(n) for n in _PENDING_NODES]
bumped[1]["lqi"] = 99
res = await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
# second call: returns the bumped data
mock_fetch.return_value = (bumped, _PENDING_EDGES)
res = await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
assert res.status_code == 200
data = res.json()
assert data["pending_created"] == 0
assert data["pending_updated"] == 2
assert data["coordinator_already_existed"] is True
assert data["links_recorded"] == 2
pending = await client.get("/api/v1/scan/pending", headers=headers)
router = next(r for r in pending.json() if r["ieee_address"] == "0xR1")
assert router["lqi"] == 99
@pytest.mark.asyncio
async def test_import_pending_replaces_links(
client: AsyncClient, headers: dict, db_session
) -> None:
"""Re-importing wipes old zigbee links and inserts only the fresh set."""
from sqlalchemy import select
from app.db.models import PendingDeviceLink
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
mock_fetch.return_value = (_PENDING_NODES, _PENDING_EDGES)
await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
new_edges = [{"source": "0xCOORD", "target": "0xR1"}]
mock_fetch.return_value = (_PENDING_NODES[:2], new_edges)
await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
headers=headers,
)
result = await db_session.execute(select(PendingDeviceLink))
links = result.scalars().all()
assert len(links) == 1
assert (links[0].source_ieee, links[0].target_ieee) == ("0xCOORD", "0xR1")
@pytest.mark.asyncio
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
res = await client.post(
"/api/v1/zigbee/import-pending",
json={"mqtt_host": "localhost", "mqtt_port": 1883},
)
assert res.status_code == 401
@pytest.mark.asyncio
async def test_test_connection_with_tls(client: AsyncClient, headers: dict) -> None:
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
+20
View File
@@ -540,6 +540,26 @@ export default function App() {
open={zigbeeImportOpen}
onClose={() => setZigbeeImportOpen(false)}
onAddToCanvas={handleZigbeeAddToCanvas}
onPendingImported={(coordinator) => {
useCanvasStore.getState().notifyScanDeviceFound()
if (coordinator) {
const exists = useCanvasStore.getState().nodes.some((n) => n.id === coordinator.id)
if (!exists) {
addNode({
id: coordinator.id,
type: 'zigbee_coordinator',
position: { x: 600, y: 100 },
data: {
label: coordinator.label,
type: 'zigbee_coordinator' as NodeData['type'],
status: 'unknown' as const,
services: [],
},
})
markUnsaved()
}
}
}}
/>
)}
+34 -2
View File
@@ -58,10 +58,24 @@ export const scanApi = {
hidden: () => api.get('/scan/hidden'),
runs: () => api.get('/scan/runs'),
clearPending: () => api.delete('/scan/pending'),
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
approve: (id: string, nodeData: object) =>
api.post<{
approved: boolean
node_id: string
edges_created: number
edges: { id: string; source: string; target: string }[]
}>(`/scan/pending/${id}/approve`, nodeData),
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
bulkApprove: (ids: string[]) => api.post<{ approved: number; node_ids: string[]; device_ids: string[]; skipped: number }>('/scan/pending/bulk-approve', { device_ids: ids }),
bulkApprove: (ids: string[]) =>
api.post<{
approved: number
node_ids: string[]
device_ids: string[]
edges_created: number
edges: { id: string; source: string; target: string }[]
skipped: number
}>('/scan/pending/bulk-approve', { device_ids: ids }),
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
@@ -98,4 +112,22 @@ export const zigbeeApi = {
edges: import('@/components/zigbee/types').ZigbeeEdge[]
device_count: number
}>('/zigbee/import', data),
importToPending: (data: {
mqtt_host: string
mqtt_port: number
mqtt_username?: string
mqtt_password?: string
base_topic?: string
mqtt_tls?: boolean
mqtt_tls_insecure?: boolean
}) =>
api.post<{
pending_created: number
pending_updated: number
coordinator: { id: string; label: string; ieee_address: string } | null
coordinator_already_existed: boolean
links_recorded: number
device_count: number
}>('/zigbee/import-pending', data),
}
+5 -3
View File
@@ -57,8 +57,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
const pendingResults = q
? pendingDevices.filter((d) =>
d.ip.toLowerCase().includes(q) ||
d.ip?.toLowerCase().includes(q) ||
d.hostname?.toLowerCase().includes(q) ||
d.friendly_name?.toLowerCase().includes(q) ||
d.ieee_address?.toLowerCase().includes(q) ||
d.services.some((s) =>
s.service_name?.toLowerCase().includes(q) ||
s.category?.toLowerCase().includes(q)
@@ -196,10 +198,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
>
<span style={{ fontSize: 10, color: '#e3b341', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>pending</span>
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.hostname ?? d.ip}
{d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device'}
</span>
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
{serviceName ?? d.ip}
{serviceName ?? d.ip ?? d.ieee_address ?? ''}
</span>
</button>
)
@@ -12,7 +12,7 @@ interface Service {
export interface PendingDevice {
id: string
ip: string
ip: string | null
mac: string | null
hostname: string | null
os: string | null
@@ -20,6 +20,12 @@ export interface PendingDevice {
suggested_type: string | null
status: string
discovery_source: string | null
ieee_address?: string | null
friendly_name?: string | null
device_subtype?: string | null
model?: string | null
vendor?: string | null
lqi?: number | null
discovered_at: string
}
@@ -77,6 +83,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
if (!device) return null
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
const isZigbee = device.discovery_source === 'zigbee'
const titleLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'Pending device'
const handleApprove = () => { onApprove(device) }
const handleHide = () => { onHide(device); onClose() }
@@ -88,17 +96,30 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
<TypeIcon size={15} className="text-[#00d4ff] shrink-0" />
{device.hostname ?? device.ip}
{titleLabel}
{isZigbee && (
<span className="ml-1 text-[9px] font-mono uppercase px-1 py-0.5 rounded bg-[#00d4ff]/15 text-[#00d4ff] border border-[#00d4ff]/30">
Zigbee
</span>
)}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
{/* Device info */}
<div className="flex flex-col gap-1.5 p-3 rounded-md bg-[#21262d] border border-[#30363d]">
<InfoRow label="IP" value={device.ip} />
{device.ip && <InfoRow label="IP" value={device.ip} />}
{device.hostname && <InfoRow label="Hostname" value={device.hostname} />}
{device.mac && <InfoRow label="MAC" value={device.mac} />}
{device.os && <InfoRow label="OS" value={device.os} />}
{device.ieee_address && <InfoRow label="IEEE" value={device.ieee_address} />}
{device.friendly_name && device.friendly_name !== device.hostname && (
<InfoRow label="Name" value={device.friendly_name} />
)}
{device.vendor && <InfoRow label="Vendor" value={device.vendor} />}
{device.model && <InfoRow label="Model" value={device.model} />}
{device.device_subtype && <InfoRow label="Role" value={device.device_subtype} />}
{device.lqi != null && <InfoRow label="LQI" value={String(device.lqi)} />}
{device.suggested_type && (
<InfoRow label="Type" value={device.suggested_type} />
)}
@@ -108,8 +129,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
</div>
{/* Services */}
<div>
{/* Services (skipped for Zigbee devices — they don't have IP services) */}
{!isZigbee && <div>
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
Services found ({device.services.length})
</p>
@@ -138,7 +159,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
))}
</div>
)}
</div>
</div>}
{/* Actions */}
<div className="flex gap-2 pt-1">
+47 -11
View File
@@ -180,6 +180,25 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onZigbeeImport, onS
const COMMON_PORTS = new Set([22, 80, 443])
function injectAutoEdges(edges: { id: string; source: string; target: string }[] | undefined) {
if (!edges || edges.length === 0) return
useCanvasStore.setState((state) => ({
edges: [
...state.edges,
...edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: 'bottom',
targetHandle: 'top-t',
type: 'iot',
data: { type: 'iot' as const },
})),
],
hasUnsavedChanges: true,
}))
}
function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) {
const [devices, setDevices] = useState<PendingDevice[]>([])
const [loading, setLoading] = useState(false)
@@ -237,14 +256,15 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
approvedDevices.forEach((d, i) => {
const nodeId = deviceToNode[d.id]
if (!nodeId) return
const fallbackLabel = d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device'
addNode({
id: nodeId,
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
data: {
label: d.hostname ?? d.ip,
label: fallbackLabel,
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
ip: d.ip,
ip: d.ip ?? undefined,
hostname: d.hostname ?? undefined,
status: 'unknown' as const,
services: (d.services ?? []) as import('@/types').ServiceInfo[],
@@ -252,9 +272,11 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
})
onNodeApproved(nodeId)
})
injectAutoEdges(res.data.edges)
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
setCheckedIds(new Set())
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}`)
const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`)
} catch {
toast.error('Failed to bulk approve devices')
}
@@ -285,10 +307,11 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
const handleApprove = async (device: PendingDevice) => {
try {
const fallbackLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'device'
const nodeData = {
label: device.hostname ?? device.ip,
label: fallbackLabel,
type: (device.suggested_type ?? 'generic') as import('@/types').NodeType,
ip: device.ip,
ip: device.ip ?? undefined,
hostname: device.hostname ?? undefined,
status: 'unknown',
services: (device.services ?? []) as import('@/types').ServiceInfo[],
@@ -301,7 +324,9 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
position: { x: 400, y: 300 },
data: { ...nodeData, status: 'unknown' as const },
})
toast.success(`Approved ${nodeData.label}`)
injectAutoEdges(res.data.edges)
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
toast.success(`Approved ${nodeData.label}${extra}`)
setDevices((prev) => prev.filter((d) => d.id !== device.id))
setSelected(null)
onNodeApproved(nodeId)
@@ -378,20 +403,30 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
)}
{devices.map((d) => {
const isZigbee = d.discovery_source === 'zigbee'
const namedService = d.services.find((s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port))
const titleService = namedService
?? d.services.find((s) => s.port === 80)
?? d.services.find((s) => s.port === 443)
?? d.services.find((s) => s.port === 22)
const title = titleService?.service_name ?? d.hostname ?? d.ip
const showIpBelow = title !== d.ip
const title = isZigbee
? (d.friendly_name ?? d.hostname ?? d.ieee_address ?? 'zigbee device')
: (titleService?.service_name ?? d.hostname ?? d.ip ?? 'device')
const showIpBelow = !isZigbee && d.ip != null && title !== d.ip
const hasSsh = d.services.some((s) => s.port === 22)
const hasHttp = d.services.some((s) => s.port === 80)
const hasHttps = d.services.some((s) => s.port === 443)
const otherCount = d.services.filter((s) => s.port !== 22 && s.port !== 80 && s.port !== 443).length
const virtualBadge = detectVirtualBadge(d.mac)
const sourceColor = d.discovery_source === 'mdns' ? '#a855f7' : '#8b949e'
const sourceLabel = d.discovery_source === 'mdns' ? 'mDNS' : d.discovery_source === 'arp' ? 'ARP' : null
const sourceColor =
d.discovery_source === 'mdns' ? '#a855f7'
: d.discovery_source === 'zigbee' ? '#00d4ff'
: '#8b949e'
const sourceLabel =
d.discovery_source === 'mdns' ? 'mDNS'
: d.discovery_source === 'arp' ? 'ARP'
: d.discovery_source === 'zigbee' ? 'ZIG'
: null
const isHighlighted = d.id === highlightId
return (
<button
@@ -418,7 +453,7 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
{sourceLabel && <ServiceBadge label={sourceLabel} color={sourceColor} />}
{virtualBadge && (
<Tooltip>
<TooltipTrigger>
<TooltipTrigger asChild>
<span><ServiceBadge label={virtualBadge.label} color="#ff6e00" /></span>
</TooltipTrigger>
<TooltipContent side="right">{virtualBadge.title}</TooltipContent>
@@ -428,6 +463,7 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
{hasHttp && <ServiceBadge label="HTTP" color="#00d4ff" />}
{hasHttps && <ServiceBadge label="HTTPS" color="#39d353" />}
{otherCount > 0 && <ServiceBadge label={`+${otherCount}`} color="#8b949e" />}
{isZigbee && d.lqi != null && <ServiceBadge label={`LQI ${d.lqi}`} color="#8b949e" />}
</div>
)}
</button>
@@ -12,8 +12,13 @@ interface ZigbeeImportModalProps {
open: boolean
onClose: () => void
onAddToCanvas: (nodes: ZigbeeNode[], edges: ZigbeeEdge[]) => void
onPendingImported?: (
coordinator?: { id: string; label: string; ieee_address: string } | null,
) => void
}
type ImportMode = 'pending' | 'canvas'
interface ConnectionForm {
mqtt_host: string
mqtt_port: string
@@ -54,7 +59,7 @@ const DEVICE_TYPE_COLOR = {
zigbee_enddevice: '#e3b341',
} as const
export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImportModalProps) {
export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ZigbeeImportModalProps) {
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
const [connectionMsg, setConnectionMsg] = useState('')
@@ -62,6 +67,7 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
const [devices, setDevices] = useState<ZigbeeNode[]>([])
const [edges, setEdges] = useState<ZigbeeEdge[]>([])
const [checked, setChecked] = useState<Set<string>>(new Set())
const [importMode, setImportMode] = useState<ImportMode>('pending')
const updateField = (field: keyof ConnectionForm, value: string) =>
setForm((f) => ({
@@ -120,24 +126,45 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
}
}
const extractError = (err: unknown): string | undefined => {
if (err && typeof err === 'object' && 'response' in err) {
return (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
}
return undefined
}
const handleFetchDevices = async () => {
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
setLoading(true)
try {
const res = await zigbeeApi.importNetwork(buildPayload())
setDevices(res.data.nodes)
setEdges(res.data.edges)
setChecked(new Set(res.data.nodes.map((n) => n.id)))
if (res.data.device_count === 0) {
toast.info('No Zigbee devices found in the network map')
if (importMode === 'pending') {
const res = await zigbeeApi.importToPending(buildPayload())
const { pending_created, pending_updated, coordinator, coordinator_already_existed, device_count } = res.data
if (device_count === 0) {
toast.info('No Zigbee devices found in the network map')
} else {
const coordMsg = coordinator_already_existed
? 'coordinator already on canvas'
: 'coordinator added to canvas'
toast.success(
`Imported ${pending_created} new, updated ${pending_updated} (${coordMsg})`,
)
}
onPendingImported?.(coordinator)
handleClose()
} else {
toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`)
const res = await zigbeeApi.importNetwork(buildPayload())
setDevices(res.data.nodes)
setEdges(res.data.edges)
setChecked(new Set(res.data.nodes.map((n) => n.id)))
if (res.data.device_count === 0) {
toast.info('No Zigbee devices found in the network map')
} else {
toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`)
}
}
} catch (err: unknown) {
const msg = err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
: undefined
toast.error(msg ?? 'Failed to fetch Zigbee devices')
toast.error(extractError(err) ?? 'Failed to fetch Zigbee devices')
} finally {
setLoading(false)
}
@@ -169,6 +196,7 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
setChecked(new Set())
setConnectionStatus('idle')
setConnectionMsg('')
setImportMode('pending')
onClose()
}
@@ -285,6 +313,29 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
</div>
)}
<div className="flex items-center gap-3 text-xs">
<span className="text-muted-foreground">Send devices to:</span>
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
<input
type="radio"
name="zigbee-import-mode"
checked={importMode === 'pending'}
onChange={() => setImportMode('pending')}
className="accent-[#00d4ff] cursor-pointer"
/>
Pending section
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
<input
type="radio"
name="zigbee-import-mode"
checked={importMode === 'canvas'}
onChange={() => setImportMode('canvas')}
className="accent-[#00d4ff] cursor-pointer"
/>
Canvas directly
</label>
</div>
<div className="flex gap-2">
<Button
size="sm"
@@ -306,7 +357,7 @@ export function ZigbeeImportModal({ open, onClose, onAddToCanvas }: ZigbeeImport
disabled={loading || connectionStatus === 'testing'}
>
{loading ? <Loader2 size={13} className="animate-spin" /> : <Network size={13} />}
Fetch Devices
{importMode === 'pending' ? 'Import to Pending' : 'Fetch Devices'}
</Button>
</div>
<p className="text-[11px] text-muted-foreground italic">
@@ -6,6 +6,7 @@ vi.mock('@/api/client', () => ({
zigbeeApi: {
testConnection: vi.fn(),
importNetwork: vi.fn(),
importToPending: vi.fn(),
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
@@ -50,6 +51,7 @@ describe('ZigbeeImportModal', () => {
beforeEach(() => {
vi.mocked(zigbeeApi.testConnection).mockReset()
vi.mocked(zigbeeApi.importNetwork).mockReset()
vi.mocked(zigbeeApi.importToPending).mockReset()
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
vi.mocked(toast.info).mockReset()
@@ -109,12 +111,17 @@ describe('ZigbeeImportModal', () => {
})
})
const selectCanvasMode = () => {
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
}
it('fetches devices and renders them grouped by type', async () => {
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
data: { nodes: sampleNodes, edges: [], device_count: 2 },
} as never)
render(<ZigbeeImportModal {...defaultProps} />)
selectCanvasMode()
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
@@ -132,6 +139,7 @@ describe('ZigbeeImportModal', () => {
} as never)
render(<ZigbeeImportModal {...defaultProps} />)
selectCanvasMode()
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
@@ -147,6 +155,7 @@ describe('ZigbeeImportModal', () => {
} as never)
render(<ZigbeeImportModal {...defaultProps} />)
selectCanvasMode()
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
@@ -168,4 +177,45 @@ describe('ZigbeeImportModal', () => {
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(defaultProps.onClose).toHaveBeenCalledOnce()
})
it('imports to pending by default and notifies parent', async () => {
vi.mocked(zigbeeApi.importToPending).mockResolvedValue({
data: {
pending_created: 2,
pending_updated: 0,
coordinator: { id: 'coord-uuid', label: 'Coordinator', ieee_address: '0x0000' },
coordinator_already_existed: false,
links_recorded: 1,
device_count: 3,
},
} as never)
const onPendingImported = vi.fn()
render(<ZigbeeImportModal {...defaultProps} onPendingImported={onPendingImported} />)
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
fireEvent.click(screen.getByRole('button', { name: /import to pending/i }))
await waitFor(() => {
expect(zigbeeApi.importToPending).toHaveBeenCalled()
expect(onPendingImported).toHaveBeenCalled()
expect(defaultProps.onClose).toHaveBeenCalled()
})
expect(zigbeeApi.importNetwork).not.toHaveBeenCalled()
})
it('switching to canvas mode calls importNetwork and not importToPending', async () => {
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
data: { nodes: sampleNodes, edges: [], device_count: 2 },
} as never)
render(<ZigbeeImportModal {...defaultProps} />)
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
await waitFor(() => expect(zigbeeApi.importNetwork).toHaveBeenCalled())
expect(zigbeeApi.importToPending).not.toHaveBeenCalled()
})
})