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 New_Home_Assistant_logo 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

Homelable canvas overview Homelable node detail - Homelable sidebar and scan Homelable edit pannel + Homelable Zigbee Network +

--- 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 ( !o && onClose()}> @@ -107,7 +105,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' {/* Type + Icon on the same row */}
- { + const t = v as NodeType + setForm((f) => ({ ...f, type: t, ...(ZIGBEE_TYPES.includes(t) ? { check_method: 'none' as CheckMethod } : {}) })) + }}> {NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]} @@ -289,57 +290,36 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' comma-separated
- {/* Check method */} -
- - -
- - {/* Check target */} -
- - set('check_target', e.target.value)} - placeholder="http://..." - className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`} - /> -
- - {/* Parent container */} - {form.type !== 'groupRect' && form.type !== 'group' && filteredParentNodes.length > 0 && ( -
- - set('check_method', v as CheckMethod)}> + + {CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]} - None (standalone) - {filteredParentNodes.map((n) => ( - {n.label} + {CHECK_METHODS.map((m) => ( + {CHECK_METHOD_LABELS[m]} ))}
)} + {/* Check target — hidden for zigbee nodes */} + {!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && ( +
+ + set('check_target', e.target.value)} + placeholder="http://..." + className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`} + /> +
+ )} + {/* Container mode */} {CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
diff --git a/frontend/src/components/modals/PendingDevicesModal.tsx b/frontend/src/components/modals/PendingDevicesModal.tsx index 41a86ee..973b492 100644 --- a/frontend/src/components/modals/PendingDevicesModal.tsx +++ b/frontend/src/components/modals/PendingDevicesModal.tsx @@ -9,6 +9,7 @@ import { useCanvasStore } from '@/stores/canvasStore' import { toast } from 'sonner' import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal' import type { NodeType, ServiceInfo } from '@/types' +import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties' interface PendingDevicesModalProps { open: boolean @@ -252,13 +253,17 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus const handleApprove = async (device: PendingDevice) => { try { const fallbackLabel = deviceLabel(device) + const type = (device.suggested_type ?? 'generic') as NodeType + const zigbee = isZigbeeType(type) + const properties = zigbee ? buildZigbeeProperties(device) : [] const nodeData = { label: fallbackLabel, - type: (device.suggested_type ?? 'generic') as NodeType, + type, ip: device.ip ?? undefined, hostname: device.hostname ?? undefined, - status: 'unknown', + status: zigbee ? 'online' : 'unknown', services: (device.services ?? []) as ServiceInfo[], + properties, } const res = await scanApi.approve(device.id, nodeData) const nodeId = res.data.node_id @@ -266,7 +271,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus id: nodeId, type: nodeData.type, position: { x: 400, y: 300 }, - data: { ...nodeData, status: 'unknown' as const }, + data: { ...nodeData, status: zigbee ? ('online' as const) : ('unknown' as const) }, }) injectAutoEdges(res.data.edges) const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : '' @@ -310,17 +315,20 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus approvedDevices.forEach((d, i) => { const nodeId = deviceToNode[d.id] if (!nodeId) return + const type = (d.suggested_type ?? 'generic') as NodeType + const zigbee = isZigbeeType(type) addNode({ id: nodeId, - type: (d.suggested_type ?? 'generic') as NodeType, + type, position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 }, data: { label: deviceLabel(d), - type: (d.suggested_type ?? 'generic') as NodeType, + type, ip: d.ip ?? undefined, hostname: d.hostname ?? undefined, - status: 'unknown' as const, + status: zigbee ? ('online' as const) : ('unknown' as const), services: (d.services ?? []) as ServiceInfo[], + properties: zigbee ? buildZigbeeProperties(d) : [], }, }) }) diff --git a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx new file mode 100644 index 0000000..0ea98a7 --- /dev/null +++ b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { CustomStyleModal } from '../CustomStyleModal' +import { useThemeStore } from '@/stores/themeStore' +import { useCanvasStore } from '@/stores/canvasStore' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })) +import { toast } from 'sonner' + +describe('CustomStyleModal', () => { + beforeEach(() => { + useThemeStore.setState({ customStyle: { nodes: {}, edges: {} } }) + useCanvasStore.setState({ hasUnsavedChanges: false }) + vi.mocked(toast.success).mockReset() + }) + + it('renders nothing when closed', () => { + const { container } = render() + expect(container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('renders title and tabs', () => { + render() + expect(screen.getByText('Custom Style Editor')).toBeDefined() + expect(screen.getByRole('button', { name: 'Nodes' })).toBeDefined() + expect(screen.getByRole('button', { name: 'Edges' })).toBeDefined() + }) + + it('starts with empty selection placeholder', () => { + render() + expect(screen.getByText(/Select a node type/)).toBeDefined() + }) + + it('switches to edges tab and shows the right placeholder', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Edges' })) + expect(screen.getByText(/edge type from the list/i)).toBeDefined() + }) + + it('selecting a node type opens the node editor', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Router' })) + expect(screen.getByText(/Apply to existing/)).toBeDefined() + expect(screen.getByText('Default size')).toBeDefined() + }) + + it('selecting an edge type opens the edge editor with path style buttons', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Edges' })) + fireEvent.click(screen.getByRole('button', { name: /Ethernet/ })) + expect(screen.getByRole('button', { name: 'Bezier' })).toBeDefined() + expect(screen.getByRole('button', { name: 'Smooth' })).toBeDefined() + }) + + it('Apply-to-existing node button calls store and toasts', () => { + const applyTypeNodeStyle = vi.fn() + useCanvasStore.setState({ applyTypeNodeStyle }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Router' })) + fireEvent.click(screen.getByRole('button', { name: /Apply to existing Router/ })) + expect(applyTypeNodeStyle).toHaveBeenCalledOnce() + expect(applyTypeNodeStyle.mock.calls[0][0]).toBe('router') + expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Router')) + }) + + it('Apply-to-existing edge button calls store and toasts', () => { + const applyTypeEdgeStyle = vi.fn() + useCanvasStore.setState({ applyTypeEdgeStyle }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Edges' })) + fireEvent.click(screen.getByRole('button', { name: /Ethernet/ })) + fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ })) + expect(applyTypeEdgeStyle).toHaveBeenCalledOnce() + expect(applyTypeEdgeStyle.mock.calls[0][0]).toBe('ethernet') + }) + + it('Save Custom Style sets customStyle, marks unsaved, closes, toasts', () => { + const onClose = vi.fn() + const markUnsaved = vi.fn() + useCanvasStore.setState({ markUnsaved }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Save Custom Style' })) + expect(markUnsaved).toHaveBeenCalledOnce() + expect(onClose).toHaveBeenCalledOnce() + expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Custom style saved')) + }) + + it('Apply All to Canvas calls applyAllCustomStyles, markUnsaved, closes', () => { + const onClose = vi.fn() + const markUnsaved = vi.fn() + const applyAllCustomStyles = vi.fn() + useCanvasStore.setState({ markUnsaved, applyAllCustomStyles }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Apply All to Canvas' })) + expect(applyAllCustomStyles).toHaveBeenCalledOnce() + expect(markUnsaved).toHaveBeenCalledOnce() + expect(onClose).toHaveBeenCalledOnce() + }) + + it('Cancel button closes without saving', () => { + const onClose = vi.fn() + const markUnsaved = vi.fn() + useCanvasStore.setState({ markUnsaved }) + render() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onClose).toHaveBeenCalledOnce() + expect(markUnsaved).not.toHaveBeenCalled() + }) + + it('editing path style updates the edge draft', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Edges' })) + fireEvent.click(screen.getByRole('button', { name: /Ethernet/ })) + const smoothBtn = screen.getByRole('button', { name: 'Smooth' }) + fireEvent.click(smoothBtn) + // The clicked button should now be styled selected (cyan border) + expect(smoothBtn.getAttribute('style')).toContain('rgb(0, 212, 255)') + }) + + it('changing width input updates node draft', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Router' })) + const widthInputs = screen.getAllByRole('spinbutton') + fireEvent.change(widthInputs[0], { target: { value: '250' } }) + expect((widthInputs[0] as HTMLInputElement).value).toBe('250') + }) +}) diff --git a/frontend/src/components/modals/__tests__/NodeModal.test.tsx b/frontend/src/components/modals/__tests__/NodeModal.test.tsx index ef32c0d..ca74316 100644 --- a/frontend/src/components/modals/__tests__/NodeModal.test.tsx +++ b/frontend/src/components/modals/__tests__/NodeModal.test.tsx @@ -311,47 +311,15 @@ describe('NodeModal', () => { expect(screen.queryByText('Reset to defaults')).toBeNull() }) - // ── Parent Proxmox (vm / lxc only) ─────────────────────────────────── + // ── Parent Container selector removed ──────────────────────────────── - const parentContainerVisibleTypes = ['proxmox', 'vm', 'lxc', 'docker_host', 'isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic'] as const - const parentContainerHiddenTypes = ['groupRect', 'group'] as const - - it.each(parentContainerVisibleTypes)('shows Parent Container for %s type when options are provided', (type) => { - renderModal({ - initial: { ...BASE, type }, - parentContainerNodes: [{ id: 'c1', label: 'Container 01' }], - }) - expect(screen.getByText('Parent Container')).toBeDefined() - expect(screen.getByText('Container 01')).toBeDefined() - }) - - it.each(parentContainerHiddenTypes)('hides Parent Container for %s type even when options are provided', (type) => { - renderModal({ initial: { ...BASE, type }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] }) + it('does not render the Parent Container selector', () => { + renderModal({ initial: BASE }) expect(screen.queryByText('Parent Container')).toBeNull() }) - it.each(parentContainerVisibleTypes)('hides Parent Container for %s type when no container options are available', (type) => { - renderModal({ initial: { ...BASE, type } }) - expect(screen.queryByText('Parent Container')).toBeNull() - }) - - it('docker_container shows only docker_host parents', () => { - renderModal({ - initial: { ...BASE, type: 'docker_container' }, - parentContainerNodes: [ - { id: 'h1', label: 'My Docker Host', nodeType: 'docker_host' }, - { id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' }, - ], - }) - expect(screen.getByText('My Docker Host')).toBeDefined() - expect(screen.queryByText('My Proxmox')).toBeNull() - }) - - it('docker_container hides Parent Container when no docker_host is available', () => { - renderModal({ - initial: { ...BASE, type: 'docker_container' }, - parentContainerNodes: [{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' }], - }) + it('does not render Parent Container for docker_container either', () => { + renderModal({ initial: { ...BASE, type: 'docker_container' } }) expect(screen.queryByText('Parent Container')).toBeNull() }) @@ -433,4 +401,24 @@ describe('NodeModal', () => { const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement expect(slider.value).toBe('48') }) + + // ── Zigbee nodes ────────────────────────────────────────────────────── + + const zigbeeTypes = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] as const + + it.each(zigbeeTypes)('hides Check Method for %s type', (type) => { + renderModal({ initial: { ...BASE, type } }) + expect(screen.queryByText('Check Method')).toBeNull() + }) + + it.each(zigbeeTypes)('hides Check Target for %s type', (type) => { + renderModal({ initial: { ...BASE, type } }) + expect(screen.queryByText('Check Target')).toBeNull() + }) + + it.each(zigbeeTypes)('submits check_method=none for %s type', (type) => { + const { onSubmit } = renderModal({ initial: { ...BASE, type, label: 'Zigbee Node' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add' })) + expect((onSubmit.mock.calls[0][0] as Partial).check_method).toBe('none') + }) }) diff --git a/frontend/src/utils/zigbeeProperties.ts b/frontend/src/utils/zigbeeProperties.ts new file mode 100644 index 0000000..92dd8fc --- /dev/null +++ b/frontend/src/utils/zigbeeProperties.ts @@ -0,0 +1,23 @@ +import type { NodeProperty, NodeType } from '@/types' + +const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] + +export function isZigbeeType(type: NodeType | string | undefined | null): boolean { + return !!type && (ZIGBEE_TYPES as string[]).includes(type) +} + +/** Build the IEEE/Vendor/Model/LQI property rows shown in the right panel. + * Matches backend `build_zigbee_properties`. */ +export function buildZigbeeProperties(input: { + ieee_address?: string | null + vendor?: string | null + model?: string | null + lqi?: number | null +}): NodeProperty[] { + const props: NodeProperty[] = [] + if (input.ieee_address) props.push({ key: 'IEEE', value: input.ieee_address, icon: null, visible: false }) + if (input.vendor) props.push({ key: 'Vendor', value: input.vendor, icon: null, visible: false }) + if (input.model) props.push({ key: 'Model', value: input.model, icon: null, visible: false }) + if (input.lqi != null) props.push({ key: 'LQI', value: String(input.lqi), icon: null, visible: false }) + return props +}