diff --git a/README.md b/README.md
index 7de65b9..0229938 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,15 @@
# Homelable
-Homelable is a self-hosted infrastructure visualization solution. It provides a network scanning feature to accelerate the identification of machines and services deployed on your local infrastructure.
+Homelable is a self-hosted infrastructure visualization solution. It provides a network/zigbee scanning feature to accelerate the identification of machines, devices and services deployed on your local infrastructure.
-Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
+Homelable also offers a healthcheck system through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
You can also select some pre-built design styles, or personalize each device in your diagram.
If you just like the design, you can only run the frontend and export your design as PNG.
+If you are running Homeassistant, check the [Homelable HA version](https://github.com/Pouzor/homelable-hacs) (via HACS)
+
---
@@ -16,8 +18,9 @@ If you just like the design, you can only run the frontend and export your desig
-
+
+
---
diff --git a/VERSION b/VERSION
index f93ea0c..6acdb44 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.0.2
\ No newline at end of file
+2.0.3
\ No newline at end of file
diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py
index 67156ea..33c03df 100644
--- a/backend/app/api/routes/scan.py
+++ b/backend/app/api/routes/scan.py
@@ -15,6 +15,9 @@ 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
+from app.services.zigbee_service import build_zigbee_properties
+
+_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
class BulkActionRequest(BaseModel):
@@ -125,17 +128,22 @@ async def bulk_approve_devices(
created_nodes: list[Node] = []
for device in devices:
device.status = "approved"
+ node_type = device.suggested_type or "generic"
+ is_zigbee = node_type in _ZIGBEE_TYPES
node = Node(
label=device.hostname or device.friendly_name or device.ip or "device",
- type=device.suggested_type or "generic",
+ type=node_type,
ip=device.ip,
hostname=device.hostname,
- status="unknown",
+ status="online" if is_zigbee else "unknown",
services=device.services or [],
ieee_address=device.ieee_address,
+ properties=build_zigbee_properties(
+ device.ieee_address, device.vendor, device.model, device.lqi
+ ) if is_zigbee else [],
# Default to ping so the status checker actually polls the new node.
# Without this the scheduler skips it (check_method NULL → no check).
- check_method="ping" if device.ip else None,
+ check_method="none" if is_zigbee else ("ping" if device.ip else None),
)
db.add(node)
created_nodes.append(node)
@@ -225,18 +233,20 @@ async def approve_device(
if device.status != "pending":
raise HTTPException(status_code=409, detail="Device already processed")
device.status = "approved"
+ _is_zigbee = node_data.type in _ZIGBEE_TYPES
node = Node(
label=node_data.label,
type=node_data.type,
ip=node_data.ip,
hostname=node_data.hostname,
- status=node_data.status,
+ status="online" if _is_zigbee else node_data.status,
services=node_data.services or [],
ieee_address=device.ieee_address,
- # Honour caller-supplied check_method, else default to ping when an IP exists
- # so the scheduler doesn't silently skip the new node.
- check_method=node_data.check_method or ("ping" if node_data.ip else None),
- check_target=node_data.check_target,
+ properties=build_zigbee_properties(
+ device.ieee_address, device.vendor, device.model, device.lqi
+ ) if _is_zigbee else (node_data.properties or []),
+ check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
+ check_target=None if _is_zigbee else node_data.check_target,
)
db.add(node)
await db.flush()
diff --git a/backend/app/api/routes/zigbee.py b/backend/app/api/routes/zigbee.py
index af8c9c9..1379d23 100644
--- a/backend/app/api/routes/zigbee.py
+++ b/backend/app/api/routes/zigbee.py
@@ -23,7 +23,12 @@ from app.schemas.zigbee import (
ZigbeeTestConnectionRequest,
ZigbeeTestConnectionResponse,
)
-from app.services.zigbee_service import fetch_networkmap, test_mqtt_connection
+from app.services.zigbee_service import (
+ build_zigbee_properties,
+ fetch_networkmap,
+ merge_zigbee_properties,
+ test_mqtt_connection,
+)
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -142,10 +147,17 @@ async def _persist_pending_import(
ieee = n.get("ieee_address")
if not ieee:
continue
+ props = build_zigbee_properties(
+ ieee, n.get("vendor"), n.get("model"), n.get("lqi")
+ )
+
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:
+ existing_node.properties = merge_zigbee_properties(
+ existing_node.properties, props
+ )
coordinator_out = ZigbeeCoordinatorOut(
id=existing_node.id,
label=existing_node.label,
@@ -157,9 +169,11 @@ async def _persist_pending_import(
node = Node(
label=label,
type=n.get("type") or "zigbee_coordinator",
- status="unknown",
+ status="online",
+ check_method="none",
ieee_address=ieee,
services=[],
+ properties=props,
)
db.add(node)
await db.flush()
@@ -168,6 +182,19 @@ async def _persist_pending_import(
)
continue
+ # If the device has already been approved as a canvas Node, refresh
+ # its properties and skip creating a pending row (keeps approved
+ # devices out of pending/hidden modals on re-import).
+ existing_node_q = await db.execute(
+ select(Node).where(Node.ieee_address == ieee)
+ )
+ existing_node = existing_node_q.scalar_one_or_none()
+ if existing_node:
+ existing_node.properties = merge_zigbee_properties(
+ existing_node.properties, props
+ )
+ continue
+
result = await db.execute(
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
)
diff --git a/backend/app/core/security.py b/backend/app/core/security.py
index ee9257a..957b1aa 100644
--- a/backend/app/core/security.py
+++ b/backend/app/core/security.py
@@ -1,22 +1,22 @@
from datetime import datetime, timedelta, timezone
+import bcrypt
from jose import JWTError, jwt
-from passlib.context import CryptContext
from app.core.config import settings
-pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
-
def verify_password(plain: str, hashed: str) -> bool:
+ if not plain or not hashed:
+ return False
try:
- return bool(pwd_context.verify(plain, hashed))
- except ValueError:
+ return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
+ except (ValueError, TypeError):
return False
def hash_password(password: str) -> str:
- return str(pwd_context.hash(password))
+ return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def create_access_token(subject: str) -> str:
diff --git a/backend/app/services/status_checker.py b/backend/app/services/status_checker.py
index 4f6ba75..8a75126 100644
--- a/backend/app/services/status_checker.py
+++ b/backend/app/services/status_checker.py
@@ -24,6 +24,11 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
host = target or raw_ip
if not host:
return {"status": "unknown", "response_time_ms": None}
+ # Reject hostnames that look like CLI flags — defends ping/tcp invocations
+ # against arg-injection if a malicious admin sets target like "-O".
+ if host.startswith("-"):
+ logger.warning("Rejecting check target that starts with '-': %r", host)
+ return {"status": "unknown", "response_time_ms": None}
start = time.monotonic()
try:
diff --git a/backend/app/services/zigbee_service.py b/backend/app/services/zigbee_service.py
index 4b6222a..d09e255 100644
--- a/backend/app/services/zigbee_service.py
+++ b/backend/app/services/zigbee_service.py
@@ -58,6 +58,54 @@ def _build_tls_context(insecure: bool) -> ssl.SSLContext:
return ctx
+def build_zigbee_properties(
+ ieee: str | None,
+ vendor: str | None,
+ model: str | None,
+ lqi: int | None,
+) -> list[dict[str, Any]]:
+ """Build a NodeProperty list for a Zigbee device (IEEE, Vendor, Model, LQI).
+
+ Only includes a row when the value is non-empty. Shape matches the
+ frontend ``NodeProperty`` type: ``{key, value, icon, visible}``.
+
+ New props default to ``visible=False`` — users opt in to showing them on
+ the canvas card from the right panel.
+ """
+ props: list[dict[str, Any]] = []
+ if ieee:
+ props.append({"key": "IEEE", "value": ieee, "icon": None, "visible": False})
+ if vendor:
+ props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": False})
+ if model:
+ props.append({"key": "Model", "value": model, "icon": None, "visible": False})
+ if lqi is not None:
+ props.append({"key": "LQI", "value": str(lqi), "icon": None, "visible": False})
+ return props
+
+
+def merge_zigbee_properties(
+ existing: list[dict[str, Any]] | None,
+ new_props: list[dict[str, Any]],
+) -> list[dict[str, Any]]:
+ """Merge fresh zigbee props into an existing property list.
+
+ For keys already present: update ``value`` but preserve the user's
+ ``visible`` choice. New keys are appended with whatever visibility the
+ caller gave them (hidden by default per ``build_zigbee_properties``).
+ Non-zigbee custom properties are preserved untouched.
+ """
+ out = [dict(p) for p in (existing or [])]
+ by_key = {p.get("key"): p for p in out}
+ for np in new_props:
+ key = np.get("key")
+ if key in by_key:
+ by_key[key]["value"] = np.get("value")
+ else:
+ out.append(dict(np))
+ return out
+
+
def _z2m_type_to_homelable(device_type: str) -> str:
"""Map a Z2M device type string to a homelable node type."""
mapping = {
diff --git a/backend/requirements.txt b/backend/requirements.txt
index e504051..53d5bb7 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -7,8 +7,7 @@ alembic==1.13.3
pydantic==2.9.2
pydantic-settings==2.5.2
python-jose[cryptography]==3.5.0
-passlib[bcrypt]==1.7.4
-bcrypt==4.0.1
+bcrypt==4.2.1
python-multipart==0.0.27
apscheduler==3.10.4
python-nmap==0.7.1
diff --git a/backend/scripts/hash_password.py b/backend/scripts/hash_password.py
index 9ee5c5b..6c1587d 100644
--- a/backend/scripts/hash_password.py
+++ b/backend/scripts/hash_password.py
@@ -1,13 +1,11 @@
-"""Generate a bcrypt password hash for config.yml."""
+"""Generate a bcrypt password hash for the AUTH_PASSWORD_HASH env var."""
import sys
-from passlib.context import CryptContext
-
-pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+import bcrypt
if len(sys.argv) < 2:
print("Usage: python scripts/hash_password.py ")
sys.exit(1)
password = sys.argv[1]
-print(pwd_context.hash(password))
+print(bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8"))
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index de3d6dd..7dc8c21 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -5,23 +5,21 @@ os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
import pytest
from httpx import ASGITransport, AsyncClient
-from passlib.context import CryptContext
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+from app.core.security import hash_password
from app.db.database import Base, get_db
from app.main import app
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
-_pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
-
@pytest.fixture(autouse=True, scope="session")
def test_credentials():
"""Configure test auth credentials directly on settings."""
from app.core.config import settings
settings.auth_username = "admin"
- settings.auth_password_hash = _pwd_ctx.hash("admin")
+ settings.auth_password_hash = hash_password("admin")
@pytest.fixture
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
index f65f578..09b569d 100644
--- a/backend/tests/test_auth.py
+++ b/backend/tests/test_auth.py
@@ -68,3 +68,71 @@ async def test_login_with_malformed_hash_returns_401_not_500(client: AsyncClient
assert res.status_code == 401
finally:
settings.auth_password_hash = original
+
+
+# --- JWT-level cases ---
+
+async def test_expired_token_rejected(client: AsyncClient):
+ """A JWT whose `exp` is in the past must be refused."""
+ from datetime import datetime, timedelta, timezone
+
+ from jose import jwt
+
+ from app.core.config import settings
+ payload = {
+ "sub": "admin",
+ "exp": datetime.now(timezone.utc) - timedelta(minutes=1),
+ }
+ token = jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
+ res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {token}"})
+ assert res.status_code == 401
+
+
+async def test_malformed_token_rejected(client: AsyncClient):
+ res = await client.get("/api/v1/nodes", headers={"Authorization": "Bearer not-a-jwt"})
+ assert res.status_code == 401
+
+
+async def test_token_signed_with_wrong_secret_rejected(client: AsyncClient):
+ """A token signed with a different key must not be accepted."""
+ from datetime import datetime, timedelta, timezone
+
+ from jose import jwt
+
+ from app.core.config import settings
+ payload = {
+ "sub": "admin",
+ "exp": datetime.now(timezone.utc) + timedelta(minutes=5),
+ }
+ forged = jwt.encode(payload, "different-secret", algorithm=settings.algorithm)
+ res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {forged}"})
+ assert res.status_code == 401
+
+
+async def test_missing_authorization_header_rejected(client: AsyncClient):
+ res = await client.get("/api/v1/nodes")
+ assert res.status_code == 401
+
+
+async def test_empty_password_does_not_pass_when_hash_empty(client: AsyncClient):
+ """No credentials configured server-side must not authenticate an empty password."""
+ from app.core.config import settings
+ original_hash = settings.auth_password_hash
+ settings.auth_password_hash = ""
+ try:
+ res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": ""})
+ assert res.status_code == 401
+ finally:
+ settings.auth_password_hash = original_hash
+
+
+# --- Password helper ---
+
+def test_verify_password_handles_empty_inputs():
+ """verify_password must be safe against empty plain / empty hash without raising."""
+ from app.core.security import hash_password, verify_password
+ h = hash_password("hunter2")
+ assert verify_password("hunter2", h) is True
+ assert verify_password("", h) is False
+ assert verify_password("hunter2", "") is False
+ assert verify_password("", "") is False
diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py
index 46abcdc..7199ea9 100644
--- a/backend/tests/test_scan.py
+++ b/backend/tests/test_scan.py
@@ -37,6 +37,95 @@ async def pending_device(db_session):
return device
+# --- _background_scan error handling ---
+
+@pytest.fixture
+async def mem_db():
+ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+ from app.db.database import Base
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+ async with engine.begin() as conn:
+ await conn.run_sync(Base.metadata.create_all)
+ factory = async_sessionmaker(engine, expire_on_commit=False)
+ yield factory
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_background_scan_marks_run_failed_on_exception(mem_db):
+ """If run_scan() raises, the ScanRun must transition running → failed and the
+ session rollback path must execute without a follow-on exception."""
+ from app.api.routes.scan import _background_scan
+
+ async with mem_db() as session:
+ run = ScanRun(status="running", ranges=["10.0.0.0/24"])
+ session.add(run)
+ await session.commit()
+ run_id = run.id
+
+ with (
+ patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
+ patch(
+ "app.api.routes.scan.run_scan",
+ new_callable=AsyncMock,
+ side_effect=RuntimeError("boom"),
+ ),
+ ):
+ await _background_scan(run_id, ["10.0.0.0/24"])
+
+ async with mem_db() as session:
+ refreshed = await session.get(ScanRun, run_id)
+ assert refreshed is not None
+ assert refreshed.status == "failed"
+
+
+@pytest.mark.asyncio
+async def test_background_scan_leaves_non_running_status_alone(mem_db):
+ """If the run was already stopped/cancelled before run_scan failed, _background_scan
+ must NOT overwrite that terminal status with 'failed'."""
+ from app.api.routes.scan import _background_scan
+
+ async with mem_db() as session:
+ run = ScanRun(status="cancelled", ranges=["10.0.0.0/24"])
+ session.add(run)
+ await session.commit()
+ run_id = run.id
+
+ with (
+ patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
+ patch(
+ "app.api.routes.scan.run_scan",
+ new_callable=AsyncMock,
+ side_effect=RuntimeError("boom"),
+ ),
+ ):
+ await _background_scan(run_id, ["10.0.0.0/24"])
+
+ async with mem_db() as session:
+ refreshed = await session.get(ScanRun, run_id)
+ assert refreshed is not None
+ assert refreshed.status == "cancelled"
+
+
+@pytest.mark.asyncio
+async def test_background_scan_success_path_invokes_run_scan(mem_db):
+ from app.api.routes.scan import _background_scan
+
+ async with mem_db() as session:
+ run = ScanRun(status="running", ranges=["10.0.0.0/24"])
+ session.add(run)
+ await session.commit()
+ run_id = run.id
+
+ with (
+ patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
+ patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan,
+ ):
+ await _background_scan(run_id, ["10.0.0.0/24"])
+ mock_run_scan.assert_awaited_once()
+
+
# --- Trigger scan ---
@pytest.mark.asyncio
@@ -528,6 +617,87 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p
assert pending_res.json() == []
+@pytest.fixture
+async def zigbee_pending_device(db_session):
+ device = PendingDevice(
+ id=str(uuid.uuid4()),
+ ip=None,
+ mac=None,
+ hostname=None,
+ friendly_name="bulb_1",
+ services=[],
+ suggested_type="zigbee_enddevice",
+ device_subtype="EndDevice",
+ ieee_address="0xABCDEF",
+ vendor="IKEA",
+ model="TRADFRI",
+ lqi=180,
+ status="pending",
+ discovery_source="zigbee",
+ )
+ db_session.add(device)
+ await db_session.commit()
+ await db_session.refresh(device)
+ return device
+
+
+@pytest.mark.asyncio
+async def test_approve_zigbee_device_populates_properties(
+ client: AsyncClient, headers, zigbee_pending_device, db_session
+):
+ """Approving a zigbee device must populate IEEE/Vendor/Model/LQI in properties."""
+ from sqlalchemy import select
+
+ from app.db.models import Node as NodeModel
+ payload = {
+ "label": "bulb_1",
+ "type": "zigbee_enddevice",
+ "status": "online",
+ "services": [],
+ "check_method": "none",
+ }
+ res = await client.post(
+ f"/api/v1/scan/pending/{zigbee_pending_device.id}/approve",
+ json=payload,
+ headers=headers,
+ )
+ assert res.status_code == 200
+ node = (
+ await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
+ ).scalar_one()
+ keys = {p["key"]: p["value"] for p in node.properties}
+ assert keys == {
+ "IEEE": "0xABCDEF",
+ "Vendor": "IKEA",
+ "Model": "TRADFRI",
+ "LQI": "180",
+ }
+
+
+@pytest.mark.asyncio
+async def test_bulk_approve_zigbee_populates_properties(
+ client: AsyncClient, headers, zigbee_pending_device, db_session
+):
+ from sqlalchemy import select
+
+ from app.db.models import Node as NodeModel
+ res = await client.post(
+ "/api/v1/scan/pending/bulk-approve",
+ json={"device_ids": [zigbee_pending_device.id]},
+ headers=headers,
+ )
+ assert res.status_code == 200
+ node = (
+ await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
+ ).scalar_one()
+ keys = {p["key"]: p["value"] for p in node.properties}
+ assert keys["IEEE"] == "0xABCDEF"
+ assert keys["Vendor"] == "IKEA"
+ assert keys["Model"] == "TRADFRI"
+ assert keys["LQI"] == "180"
+ assert node.check_method == "none"
+
+
@pytest.mark.asyncio
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
"""Approved devices with an IP must default to ping; otherwise scheduler skips them."""
diff --git a/backend/tests/test_status_checker.py b/backend/tests/test_status_checker.py
index 3d23cd8..205e62d 100644
--- a/backend/tests/test_status_checker.py
+++ b/backend/tests/test_status_checker.py
@@ -216,6 +216,31 @@ async def test_ping_uses_windows_args_on_win32():
assert "-c" not in captured["args"]
+# --- check_node target validation ---
+
+@pytest.mark.asyncio
+async def test_check_node_rejects_flag_like_target():
+ """A target starting with '-' must never reach subprocess invocation."""
+ from app.services.status_checker import check_node
+
+ with patch("asyncio.create_subprocess_exec") as mock_exec:
+ result = await check_node("ping", "-O", None)
+
+ mock_exec.assert_not_called()
+ assert result["status"] == "unknown"
+
+
+@pytest.mark.asyncio
+async def test_check_node_rejects_flag_like_ip():
+ from app.services.status_checker import check_node
+
+ with patch("asyncio.create_subprocess_exec") as mock_exec:
+ result = await check_node("ping", None, "-O")
+
+ mock_exec.assert_not_called()
+ assert result["status"] == "unknown"
+
+
# --- _tcp_connect ---
@pytest.mark.asyncio
diff --git a/backend/tests/test_zigbee_router.py b/backend/tests/test_zigbee_router.py
index 2fc463d..b24e0b3 100644
--- a/backend/tests/test_zigbee_router.py
+++ b/backend/tests/test_zigbee_router.py
@@ -388,6 +388,150 @@ async def test_persist_pending_import_replaces_links(db_session) -> None:
assert (rows[0].source_ieee, rows[0].target_ieee) == ("0xCOORD", "0xR1")
+@pytest.mark.asyncio
+async def test_persist_pending_import_sets_coordinator_properties(db_session) -> None:
+ """Coordinator Node is created with IEEE/Vendor/Model/LQI in properties."""
+ from sqlalchemy import select
+
+ from app.api.routes.zigbee import _persist_pending_import
+ from app.db.models import Node
+
+ nodes_with_meta = [dict(n) for n in _PENDING_NODES]
+ nodes_with_meta[0]["vendor"] = "TI"
+ nodes_with_meta[0]["model"] = "CC2652"
+
+ await _persist_pending_import(db_session, nodes_with_meta, _PENDING_EDGES)
+
+ coord = (
+ await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
+ ).scalar_one()
+ keys = {p["key"]: p["value"] for p in coord.properties}
+ assert keys == {"IEEE": "0xCOORD", "Vendor": "TI", "Model": "CC2652"}
+ # New zigbee props default to hidden — user opts in from the right panel.
+ assert all(p["visible"] is False for p in coord.properties)
+
+
+@pytest.mark.asyncio
+async def test_persist_pending_import_skips_pending_for_approved_node(
+ db_session,
+) -> None:
+ """A device already approved as a canvas Node must not reappear in pending.
+
+ Its properties must still be refreshed with the latest Vendor/Model/LQI.
+ """
+ from sqlalchemy import select
+
+ from app.api.routes.zigbee import _persist_pending_import
+ from app.db.models import Node, PendingDevice
+
+ # Simulate: router was approved earlier → exists as a canvas Node.
+ approved = Node(
+ label="router_1",
+ type="zigbee_router",
+ status="online",
+ check_method="none",
+ ieee_address="0xR1",
+ services=[],
+ properties=[],
+ )
+ db_session.add(approved)
+ await db_session.commit()
+
+ bumped = [dict(n) for n in _PENDING_NODES]
+ bumped[1]["lqi"] = 250 # new LQI from re-import
+ await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
+
+ # No PendingDevice row was created for the approved router.
+ pendings = (
+ await db_session.execute(
+ select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
+ )
+ ).scalars().all()
+ assert pendings == []
+
+ # Node properties got refreshed.
+ refreshed = (
+ await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
+ ).scalar_one()
+ keys = {p["key"]: p["value"] for p in refreshed.properties}
+ assert keys == {"IEEE": "0xR1", "Vendor": "TI", "Model": "CC2530", "LQI": "250"}
+ # Brand-new props on an existing Node start hidden.
+ assert all(p["visible"] is False for p in refreshed.properties)
+
+
+@pytest.mark.asyncio
+async def test_persist_pending_import_preserves_user_visibility(db_session) -> None:
+ """If user has already made props visible, re-import must not flip them back."""
+ from sqlalchemy import select
+
+ from app.api.routes.zigbee import _persist_pending_import
+ from app.db.models import Node
+
+ approved = Node(
+ label="router_1",
+ type="zigbee_router",
+ status="online",
+ check_method="none",
+ ieee_address="0xR1",
+ services=[],
+ properties=[
+ {"key": "IEEE", "value": "0xR1", "icon": None, "visible": True},
+ {"key": "Vendor", "value": "TI", "icon": None, "visible": True},
+ {"key": "Custom", "value": "kept", "icon": None, "visible": True},
+ ],
+ )
+ db_session.add(approved)
+ await db_session.commit()
+
+ bumped = [dict(n) for n in _PENDING_NODES]
+ bumped[1]["lqi"] = 99
+ bumped[1]["model"] = "CC2530"
+ await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
+
+ refreshed = (
+ await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
+ ).scalar_one()
+ by_key = {p["key"]: p for p in refreshed.properties}
+ # Existing keys keep their visibility (True).
+ assert by_key["IEEE"]["visible"] is True
+ assert by_key["Vendor"]["visible"] is True
+ # New key arrives hidden.
+ assert by_key["Model"]["visible"] is False
+ assert by_key["LQI"]["visible"] is False
+ assert by_key["LQI"]["value"] == "99"
+ # Non-zigbee user-added prop is preserved untouched.
+ assert by_key["Custom"]["value"] == "kept"
+ assert by_key["Custom"]["visible"] is True
+
+
+@pytest.mark.asyncio
+async def test_persist_pending_import_refreshes_existing_coordinator_properties(
+ db_session,
+) -> None:
+ from sqlalchemy import select
+
+ from app.api.routes.zigbee import _persist_pending_import
+ from app.db.models import Node
+
+ await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
+
+ bumped = [dict(n) for n in _PENDING_NODES]
+ bumped[0]["vendor"] = "TI"
+ bumped[0]["model"] = "CC2652"
+ await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
+
+ coord = (
+ await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
+ ).scalar_one()
+ keys = {p["key"]: p["value"] for p in coord.properties}
+ assert keys["Vendor"] == "TI"
+ assert keys["Model"] == "CC2652"
+ # Newly added keys on re-import default to hidden.
+ by_key = {p["key"]: p for p in coord.properties}
+ assert by_key["Vendor"]["visible"] is False
+ assert by_key["Model"]["visible"] is False
+
+
@pytest.mark.asyncio
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
res = await client.post(
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 93ec52e..5b0e902 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "frontend",
- "version": "2.0.2",
+ "version": "2.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
- "version": "1.13.0",
+ "version": "2.0.3",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
diff --git a/frontend/package.json b/frontend/package.json
index acfd3ac..8335154 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "2.0.2",
+ "version": "2.0.3",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 4c82fe8..fa561f6 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -552,9 +552,6 @@ export default function App() {
onClose={() => setAddNodeOpen(false)}
onSubmit={handleAddNode}
title="Add Node"
- parentContainerNodes={nodes
- .filter((n) => CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
- .map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
/>
{/* key forces re-mount when editing a different node, resetting form state */}
@@ -565,9 +562,6 @@ export default function App() {
onSubmit={handleUpdateNode}
initial={editNode?.data}
title="Edit Node"
- parentContainerNodes={nodes
- .filter((n) => n.id !== editNodeId && CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
- .map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
/>
= {
+ fulfilled?: (v: T) => T | Promise
+ rejected?: (e: unknown) => unknown
+}
+
+interface MockInstance {
+ defaults: { baseURL?: string }
+ interceptors: {
+ request: { use: (f: Interceptor['fulfilled'], r?: Interceptor['rejected']) => void }
+ response: { use: (f: Interceptor['fulfilled'], r?: Interceptor['rejected']) => void }
+ }
+ get: ReturnType
+ post: ReturnType
+ patch: ReturnType
+ delete: ReturnType
+ __req: Interceptor<{ headers: Record }>
+ __res: Interceptor
+}
+
+const hoisted = vi.hoisted(() => ({ instances: [] as unknown[] }))
+const instances = hoisted.instances as MockInstance[]
+
+vi.mock('axios', () => {
+ return {
+ default: {
+ create: (cfg: { baseURL?: string }) => {
+ const inst: MockInstance = {
+ defaults: { baseURL: cfg?.baseURL },
+ interceptors: {
+ request: { use: (f: unknown, r?: unknown) => { inst.__req = { fulfilled: f as never, rejected: r as never } } },
+ response: { use: (f: unknown, r?: unknown) => { inst.__res = { fulfilled: f as never, rejected: r as never } } },
+ },
+ get: vi.fn(() => Promise.resolve({ data: {} })),
+ post: vi.fn(() => Promise.resolve({ data: {} })),
+ patch: vi.fn(() => Promise.resolve({ data: {} })),
+ delete: vi.fn(() => Promise.resolve({ data: {} })),
+ __req: {},
+ __res: {},
+ }
+ hoisted.instances.push(inst)
+ return inst
+ },
+ },
+ }
+})
+
+import { useAuthStore } from '@/stores/authStore'
+import * as clientModule from '../client'
+
+describe('api/client', () => {
+ const mod = clientModule
+ const [api, publicApi] = instances
+
+ beforeEach(() => {
+ useAuthStore.setState({ token: null, isAuthenticated: false })
+ api.get.mockClear()
+ api.post.mockClear()
+ api.patch.mockClear()
+ api.delete.mockClear()
+ publicApi.get.mockClear()
+ publicApi.post.mockClear()
+ })
+
+ it('creates two axios instances with /api/v1 baseURL', () => {
+ expect(instances).toHaveLength(2)
+ expect(api.defaults.baseURL).toBe('/api/v1')
+ expect(publicApi.defaults.baseURL).toBe('/api/v1')
+ })
+
+ it('exports `api` matching the first created instance', () => {
+ expect(mod.api).toBe(api)
+ })
+
+ it('request interceptor adds Authorization header when token present', () => {
+ useAuthStore.setState({ token: 'tok-123', isAuthenticated: true })
+ const cfg = { headers: {} as Record }
+ const out = api.__req.fulfilled!(cfg)
+ expect((out as typeof cfg).headers.Authorization).toBe('Bearer tok-123')
+ })
+
+ it('request interceptor leaves headers untouched when no token', () => {
+ const cfg = { headers: {} as Record }
+ const out = api.__req.fulfilled!(cfg)
+ expect((out as typeof cfg).headers.Authorization).toBeUndefined()
+ })
+
+ it('response interceptor passes through 2xx responses', () => {
+ const r = { status: 200, data: { ok: true } }
+ expect(api.__res.fulfilled!(r)).toBe(r)
+ })
+
+ it('response interceptor calls logout on 401', async () => {
+ const logout = vi.spyOn(useAuthStore.getState(), 'logout')
+ useAuthStore.setState({ token: 't', isAuthenticated: true, logout })
+ const err = { response: { status: 401 } }
+ await expect(api.__res.rejected!(err)).rejects.toBe(err)
+ expect(logout).toHaveBeenCalled()
+ })
+
+ it('response interceptor does not call logout on non-401', async () => {
+ const logout = vi.fn()
+ useAuthStore.setState({ token: 't', isAuthenticated: true, logout })
+ const err = { response: { status: 500 } }
+ await expect(api.__res.rejected!(err)).rejects.toBe(err)
+ expect(logout).not.toHaveBeenCalled()
+ })
+
+ it('response interceptor handles error with no response object', async () => {
+ const logout = vi.fn()
+ useAuthStore.setState({ logout })
+ const err = { message: 'network down' }
+ await expect(api.__res.rejected!(err)).rejects.toBe(err)
+ expect(logout).not.toHaveBeenCalled()
+ })
+
+ it('publicApi has no request/response interceptors registered', () => {
+ expect(publicApi.__req.fulfilled).toBeUndefined()
+ expect(publicApi.__res.fulfilled).toBeUndefined()
+ })
+
+ it('authApi.login posts to /auth/login', () => {
+ mod.authApi.login('u', 'p')
+ expect(api.post).toHaveBeenCalledWith('/auth/login', { username: 'u', password: 'p' })
+ })
+
+ it('canvasApi.load GETs /canvas', () => {
+ mod.canvasApi.load()
+ expect(api.get).toHaveBeenCalledWith('/canvas')
+ })
+
+ it('canvasApi.save POSTs to /canvas/save with payload', () => {
+ const payload = { nodes: [], edges: [], viewport: {} }
+ mod.canvasApi.save(payload)
+ expect(api.post).toHaveBeenCalledWith('/canvas/save', payload)
+ })
+
+ it('nodesApi CRUD calls correct endpoints', () => {
+ mod.nodesApi.create({ a: 1 })
+ expect(api.post).toHaveBeenCalledWith('/nodes', { a: 1 })
+ mod.nodesApi.update('n1', { b: 2 })
+ expect(api.patch).toHaveBeenCalledWith('/nodes/n1', { b: 2 })
+ mod.nodesApi.delete('n1')
+ expect(api.delete).toHaveBeenCalledWith('/nodes/n1')
+ })
+
+ it('edgesApi CRUD calls correct endpoints', () => {
+ mod.edgesApi.create({ s: 'a', t: 'b' })
+ expect(api.post).toHaveBeenCalledWith('/edges', { s: 'a', t: 'b' })
+ mod.edgesApi.delete('e1')
+ expect(api.delete).toHaveBeenCalledWith('/edges/e1')
+ })
+
+ it('liveviewApi.load uses publicApi with key param', () => {
+ mod.liveviewApi.load('k-1')
+ expect(publicApi.get).toHaveBeenCalledWith('/liveview', { params: { key: 'k-1' } })
+ expect(api.get).not.toHaveBeenCalled()
+ })
+
+ it('scanApi endpoints route correctly', () => {
+ mod.scanApi.trigger()
+ expect(api.post).toHaveBeenCalledWith('/scan/trigger')
+ mod.scanApi.pending()
+ expect(api.get).toHaveBeenCalledWith('/scan/pending')
+ mod.scanApi.hidden()
+ expect(api.get).toHaveBeenCalledWith('/scan/hidden')
+ mod.scanApi.runs()
+ expect(api.get).toHaveBeenCalledWith('/scan/runs')
+ mod.scanApi.clearPending()
+ expect(api.delete).toHaveBeenCalledWith('/scan/pending')
+ mod.scanApi.approve('d1', { foo: 'bar' })
+ expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/approve', { foo: 'bar' })
+ mod.scanApi.hide('d1')
+ expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/hide')
+ mod.scanApi.ignore('d1')
+ expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/ignore')
+ mod.scanApi.bulkApprove(['a', 'b'])
+ expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'] })
+ mod.scanApi.bulkHide(['a'])
+ expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-hide', { device_ids: ['a'] })
+ mod.scanApi.restore('d1')
+ expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/restore')
+ mod.scanApi.bulkRestore(['a'])
+ expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-restore', { device_ids: ['a'] })
+ mod.scanApi.stop('run-1')
+ expect(api.post).toHaveBeenCalledWith('/scan/run-1/stop')
+ mod.scanApi.getConfig()
+ expect(api.get).toHaveBeenCalledWith('/scan/config')
+ mod.scanApi.saveConfig({ ranges: ['1.0/24'] })
+ expect(api.post).toHaveBeenCalledWith('/scan/config', { ranges: ['1.0/24'] })
+ })
+
+ it('settingsApi get/save', () => {
+ mod.settingsApi.get()
+ expect(api.get).toHaveBeenCalledWith('/settings')
+ mod.settingsApi.save({ interval_seconds: 30 })
+ expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30 })
+ })
+
+ it('zigbeeApi.testConnection/importNetwork/importToPending', () => {
+ const cfg = { mqtt_host: 'h', mqtt_port: 1883 }
+ mod.zigbeeApi.testConnection(cfg)
+ expect(api.post).toHaveBeenCalledWith('/zigbee/test-connection', cfg)
+ mod.zigbeeApi.importNetwork(cfg)
+ expect(api.post).toHaveBeenCalledWith('/zigbee/import', cfg)
+ mod.zigbeeApi.importToPending(cfg)
+ expect(api.post).toHaveBeenCalledWith('/zigbee/import-pending', cfg)
+ })
+})
diff --git a/frontend/src/components/canvas/nodes/__tests__/ProxmoxGroupNode.test.tsx b/frontend/src/components/canvas/nodes/__tests__/ProxmoxGroupNode.test.tsx
new file mode 100644
index 0000000..6afcb3f
--- /dev/null
+++ b/frontend/src/components/canvas/nodes/__tests__/ProxmoxGroupNode.test.tsx
@@ -0,0 +1,111 @@
+import { describe, it, expect, beforeEach } from 'vitest'
+import { render } from '@testing-library/react'
+import { ReactFlowProvider } from '@xyflow/react'
+import { ProxmoxGroupNode } from '../ProxmoxGroupNode'
+import { useCanvasStore } from '@/stores/canvasStore'
+import { useThemeStore } from '@/stores/themeStore'
+import type { NodeData, NodeProperty } from '@/types'
+import type { NodeProps, Node } from '@xyflow/react'
+
+function renderNode(data: Partial = {}, selected = false) {
+ const fullData: NodeData = {
+ label: 'pve-01',
+ type: 'proxmox',
+ status: 'online',
+ services: [],
+ ...data,
+ }
+ const props = {
+ id: 'p1',
+ data: fullData,
+ selected,
+ type: 'proxmox',
+ zIndex: 0,
+ isConnectable: true,
+ xPos: 0,
+ yPos: 0,
+ dragging: false,
+ deletable: true,
+ draggable: true,
+ selectable: true,
+ positionAbsoluteX: 0,
+ positionAbsoluteY: 0,
+ width: 300,
+ height: 200,
+ dragHandle: undefined,
+ parentId: undefined,
+ sourcePosition: undefined,
+ targetPosition: undefined,
+ } as unknown as NodeProps>
+ return render(
+
+
+
+ )
+}
+
+describe('ProxmoxGroupNode', () => {
+ beforeEach(() => {
+ useCanvasStore.setState({ hideIp: false })
+ useThemeStore.setState({ activeTheme: 'default' })
+ })
+
+ it('renders the node label', () => {
+ const { getByText } = renderNode({ label: 'My Proxmox' })
+ expect(getByText('My Proxmox')).toBeDefined()
+ })
+
+ it('renders ip when provided', () => {
+ const { getByText } = renderNode({ ip: '192.168.1.10' })
+ expect(getByText('192.168.1.10')).toBeDefined()
+ })
+
+ it('renders multiple ips when comma separated', () => {
+ const { getByText } = renderNode({ ip: '10.0.0.1, 10.0.0.2' })
+ expect(getByText('10.0.0.1')).toBeDefined()
+ expect(getByText('10.0.0.2')).toBeDefined()
+ })
+
+ it('masks ip when hideIp is enabled in store', () => {
+ useCanvasStore.setState({ hideIp: true })
+ const { queryByText } = renderNode({ ip: '192.168.1.10' })
+ expect(queryByText('192.168.1.10')).toBeNull()
+ })
+
+ it('renders visible properties only', () => {
+ const properties: NodeProperty[] = [
+ { key: 'CPU', value: '16 cores', icon: null, visible: true },
+ { key: 'Hidden', value: 'should-not-show', icon: null, visible: false },
+ ]
+ const { getByText, queryByText } = renderNode({ properties })
+ expect(getByText('CPU')).toBeDefined()
+ expect(getByText(/16 cores/)).toBeDefined()
+ expect(queryByText('Hidden')).toBeNull()
+ expect(queryByText(/should-not-show/)).toBeNull()
+ })
+
+ it('renders status dot with title matching status', () => {
+ const { container } = renderNode({ status: 'offline' })
+ const dot = container.querySelector('[title="offline"]')
+ expect(dot).not.toBeNull()
+ })
+
+ it('container_mode === false renders as BaseNode (no resizer group border)', () => {
+ const { container } = renderNode({ container_mode: false })
+ // NodeResizer should not be present when not group-rendered
+ expect(container.querySelector('.react-flow__resize-control')).toBeNull()
+ })
+
+ it('container_mode default renders the group border container', () => {
+ const { container } = renderNode({})
+ // Group border div has rounded-xl border-2 classes
+ expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull()
+ })
+
+ it('renders cluster handles in both modes', () => {
+ const { container: groupC } = renderNode({})
+ expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
+ const { container: nodeC } = renderNode({ container_mode: false })
+ expect(nodeC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
+ })
+})
diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx
index da28e01..193310f 100644
--- a/frontend/src/components/modals/NodeModal.tsx
+++ b/frontend/src/components/modals/NodeModal.tsx
@@ -22,6 +22,7 @@ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
+const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
const CHECK_METHOD_LABELS: Record = {
none: 'None',
@@ -53,13 +54,14 @@ interface NodeModalProps {
onSubmit: (data: Partial) => void
initial?: Partial
title?: string
- parentContainerNodes?: { id: string; label: string; nodeType?: NodeType }[]
}
// NodeModal is always mounted with a key that changes on open/edit, so useState
// initial value is enough - no need for a reset effect.
-export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) {
- const [form, setForm] = useState>({ ...DEFAULT_DATA, ...initial })
+export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' }: NodeModalProps) {
+ const merged = { ...DEFAULT_DATA, ...initial }
+ if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
+ const [form, setForm] = useState>(merged)
const [iconSearch, setIconSearch] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [iconTab, setIconTab] = useState<'generic' | 'brand'>(isBrandIconKey(initial?.custom_icon) ? 'brand' : 'generic')
@@ -91,10 +93,6 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
onClose()
}
- const filteredParentNodes = form.type === 'docker_container'
- ? parentContainerNodes.filter((n) => n.nodeType === 'docker_host')
- : parentContainerNodes
-
return (