diff --git a/.env.example b/.env.example
index de4d90c..52fa748 100644
--- a/.env.example
+++ b/.env.example
@@ -28,3 +28,9 @@ MCP_SERVICE_KEY=svc_changeme
# Off by default. Set to a random secret to enable.
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# LIVEVIEW_KEY=
+
+# Gethomepage widget — read-only stats at /api/v1/stats/summary
+# Off by default. Set to a random secret to enable; clients must send
+# the same value in the `X-API-Key` header.
+# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
+# HOMEPAGE_API_KEY=
diff --git a/README.md b/README.md
index 7de65b9..8b5ada9 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
-
+
+
---
@@ -131,6 +134,60 @@ The page shows your canvas in pan/zoom-only mode — no editing, no credentials
---
+## Gethomepage Widget (read-only stats)
+
+Homelable can expose a small JSON stats endpoint that [gethomepage](https://gethomepage.dev) consumes through its built-in `customapi` widget. Disabled by default.
+
+### Activation
+
+Add `HOMEPAGE_API_KEY` to your `.env`:
+
+`HOMEPAGE_API_KEY=your-secret-key`
+
+Restart the backend (`docker compose restart backend`).
+
+### Endpoint
+
+`GET /api/v1/stats/summary` — requires header `X-API-Key: your-secret-key`. Returns:
+
+```json
+{
+ "nodes": 12,
+ "online": 9,
+ "offline": 2,
+ "unknown": 1,
+ "pending_devices": 3,
+ "zigbee_devices": 5,
+ "last_scan_at": "2026-05-14T10:00:00+00:00"
+}
+```
+
+### gethomepage `services.yaml` snippet
+
+```yaml
+- Homelab:
+ - Homelable:
+ icon: mdi-lan
+ href: http://homelable.local:3000
+ widget:
+ type: customapi
+ url: http://homelable.local:8000/api/v1/stats/summary
+ method: GET
+ headers:
+ X-API-Key: your-secret-key
+ mappings:
+ - field: nodes ; label: Nodes
+ - field: online ; label: Online
+ - field: offline ; label: Offline
+ - field: pending_devices ; label: Pending
+ - field: zigbee_devices ; label: Zigbee
+ - field: last_scan_at ; label: Last scan
+```
+
+The backend port (`8000`) must be reachable from your gethomepage container.
+
+---
+
## MCP Server (AI Integration) (optional)
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
diff --git a/VERSION b/VERSION
index f88cf52..7c32728 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.13.0
\ No newline at end of file
+2.1.1
\ No newline at end of file
diff --git a/backend/app/api/routes/liveview.py b/backend/app/api/routes/liveview.py
index 6b9bad2..c6cd0f1 100644
--- a/backend/app/api/routes/liveview.py
+++ b/backend/app/api/routes/liveview.py
@@ -34,8 +34,10 @@ async def liveview_canvas(
edges = (await db.execute(select(Edge))).scalars().all()
state = await db.get(CanvasState, 1)
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
+ custom_style: dict[str, Any] | None = state.custom_style if state else None
return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
+ custom_style=custom_style,
)
diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py
index 476ec23..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,14 +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="none" if is_zigbee else ("ping" if device.ip else None),
)
db.add(node)
created_nodes.append(node)
@@ -222,14 +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,
+ 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/stats.py b/backend/app/api/routes/stats.py
new file mode 100644
index 0000000..e3979f6
--- /dev/null
+++ b/backend/app/api/routes/stats.py
@@ -0,0 +1,64 @@
+import hmac
+
+from fastapi import APIRouter, Depends, Header, HTTPException
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.config import settings
+from app.db.database import get_db
+from app.db.models import Node, PendingDevice, ScanRun
+
+router = APIRouter()
+
+
+def _check_key(x_api_key: str | None) -> None:
+ if not settings.homepage_api_key:
+ raise HTTPException(status_code=403, detail="Stats endpoint is disabled")
+ if not x_api_key or not hmac.compare_digest(x_api_key, settings.homepage_api_key):
+ raise HTTPException(status_code=403, detail="Invalid API key")
+
+
+@router.get("/summary")
+async def summary(
+ x_api_key: str | None = Header(default=None, alias="X-API-Key"),
+ db: AsyncSession = Depends(get_db),
+) -> dict[str, object]:
+ """Read-only stats payload for the gethomepage `customapi` widget.
+
+ Disabled unless HOMEPAGE_API_KEY is set. Caller must send the same
+ value in the `X-API-Key` header.
+ """
+ _check_key(x_api_key)
+
+ status_rows = (
+ await db.execute(select(Node.status, func.count()).group_by(Node.status))
+ ).all()
+ counts = {row[0]: row[1] for row in status_rows}
+
+ pending = (
+ await db.execute(
+ select(func.count())
+ .select_from(PendingDevice)
+ .where(PendingDevice.status == "pending")
+ )
+ ).scalar_one()
+
+ zigbee = (
+ await db.execute(
+ select(func.count()).select_from(Node).where(Node.ieee_address.isnot(None))
+ )
+ ).scalar_one()
+
+ last_scan_at = (
+ await db.execute(select(func.max(ScanRun.finished_at)))
+ ).scalar_one()
+
+ return {
+ "nodes": sum(counts.values()),
+ "online": counts.get("online", 0),
+ "offline": counts.get("offline", 0),
+ "unknown": counts.get("unknown", 0),
+ "pending_devices": pending,
+ "zigbee_devices": zigbee,
+ "last_scan_at": last_scan_at.isoformat() if last_scan_at else None,
+ }
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/config.py b/backend/app/core/config.py
index 481b7bd..3eb3882 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -61,6 +61,11 @@ class Settings(BaseSettings):
# Leave unset (or empty) to keep the feature disabled (default).
liveview_key: str | None = None
+ # Homepage widget — optional read-only stats endpoint for gethomepage.
+ # Set to a random secret to enable /api/v1/stats/summary (X-API-Key header).
+ # Leave empty to keep the feature disabled (default).
+ homepage_api_key: str = ""
+
def _override_path(self) -> Path:
return Path(self.sqlite_path).parent / "scan_config.json"
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/main.py b/backend/app/main.py
index b954546..8f0952f 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -7,7 +7,7 @@ from typing import Any
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
-from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status, zigbee
+from app.api.routes import auth, canvas, edges, liveview, nodes, scan, stats, status, zigbee
from app.api.routes import settings as settings_routes
from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler
@@ -56,6 +56,7 @@ app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
+app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"])
@app.get("/api/v1/health")
diff --git a/backend/app/services/status_checker.py b/backend/app/services/status_checker.py
index 7f150e8..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:
@@ -60,8 +65,14 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
async def _ping(host: str) -> bool:
+ # ping(8) -W flag units differ by OS:
+ # Linux: seconds (-W 1 = 1s)
+ # macOS: milliseconds (-W 1 = 1ms — fails for any RTT >1ms)
+ # Windows: -w in ms (-w 1000 = 1s)
if sys.platform == "win32":
args = ["ping", "-n", "1", "-w", "1000", host]
+ elif sys.platform == "darwin":
+ args = ["ping", "-c", "1", "-W", "1000", host]
else:
args = ["ping", "-c", "1", "-W", "1", host]
proc = await asyncio.create_subprocess_exec(
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_liveview.py b/backend/tests/test_liveview.py
index bf27b85..835fbe6 100644
--- a/backend/tests/test_liveview.py
+++ b/backend/tests/test_liveview.py
@@ -112,6 +112,28 @@ async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
assert nodes[0]["label"] == "Live Node"
+# ── custom_style + theme propagation ─────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, auth_headers):
+ """custom_style and viewport.theme_id from a saved canvas surface in liveview."""
+ settings.liveview_key = "test-key"
+ headers = await auth_headers()
+ payload = {
+ "nodes": [],
+ "edges": [],
+ "viewport": {"x": 0, "y": 0, "zoom": 1, "theme_id": "matrix"},
+ "custom_style": {"fontFamily": "Inter", "nodeRadius": 12},
+ }
+ await client.post("/api/v1/canvas/save", json=payload, headers=headers)
+
+ res = await client.get("/api/v1/liveview?key=test-key")
+ assert res.status_code == 200
+ body = res.json()
+ assert body["viewport"].get("theme_id") == "matrix"
+ assert body["custom_style"] == {"fontFamily": "Inter", "nodeRadius": 12}
+
+
# ── Re-disable after enabling ─────────────────────────────────────────────────
@pytest.mark.asyncio
diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py
index 3fdf851..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,118 @@ 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."""
+ from sqlalchemy import select
+
+ from app.db.models import Node as NodeModel
+ ids = [d.id for d in two_pending_devices]
+ res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
+ assert res.status_code == 200
+ nodes = (await db_session.execute(select(NodeModel))).scalars().all()
+ for n in nodes:
+ if n.ip:
+ assert n.check_method == "ping", f"node {n.id} created without check_method"
+
+
+@pytest.mark.asyncio
+async def test_approve_device_sets_default_check_method(client: AsyncClient, headers, pending_device, db_session):
+ from sqlalchemy import select
+
+ from app.db.models import Node as NodeModel
+ res = await client.post(
+ f"/api/v1/scan/pending/{pending_device.id}/approve",
+ json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
+ headers=headers,
+ )
+ assert res.status_code == 200
+ node = (await db_session.execute(select(NodeModel))).scalars().first()
+ assert node is not None
+ assert node.check_method == "ping"
+
+
@pytest.mark.asyncio
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
ids = [d.id for d in two_pending_devices]
diff --git a/backend/tests/test_stats.py b/backend/tests/test_stats.py
new file mode 100644
index 0000000..77e033a
--- /dev/null
+++ b/backend/tests/test_stats.py
@@ -0,0 +1,100 @@
+"""API tests for /api/v1/stats/* (gethomepage widget)."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.config import settings
+from app.db.models import Node, PendingDevice, ScanRun
+
+
+@pytest.fixture(autouse=True)
+def _reset_homepage_key():
+ original = settings.homepage_api_key
+ settings.homepage_api_key = ""
+ yield
+ settings.homepage_api_key = original
+
+
+@pytest.mark.asyncio
+async def test_summary_disabled_when_key_unset(client: AsyncClient) -> None:
+ res = await client.get("/api/v1/stats/summary")
+ assert res.status_code == 403
+ assert "disabled" in res.json()["detail"].lower()
+
+
+@pytest.mark.asyncio
+async def test_summary_rejects_missing_header(client: AsyncClient) -> None:
+ settings.homepage_api_key = "topsecret"
+ res = await client.get("/api/v1/stats/summary")
+ assert res.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_summary_rejects_wrong_key(client: AsyncClient) -> None:
+ settings.homepage_api_key = "topsecret"
+ res = await client.get(
+ "/api/v1/stats/summary", headers={"X-API-Key": "wrong"}
+ )
+ assert res.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_summary_empty_db(client: AsyncClient) -> None:
+ settings.homepage_api_key = "topsecret"
+ res = await client.get(
+ "/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
+ )
+ assert res.status_code == 200
+ body = res.json()
+ assert body == {
+ "nodes": 0,
+ "online": 0,
+ "offline": 0,
+ "unknown": 0,
+ "pending_devices": 0,
+ "zigbee_devices": 0,
+ "last_scan_at": None,
+ }
+
+
+@pytest.mark.asyncio
+async def test_summary_aggregates_counts(
+ client: AsyncClient, db_session: AsyncSession
+) -> None:
+ settings.homepage_api_key = "topsecret"
+ finished = datetime(2026, 5, 14, 10, 0, tzinfo=timezone.utc)
+ db_session.add_all([
+ Node(type="server", label="A", status="online"),
+ Node(type="server", label="B", status="online"),
+ Node(type="server", label="C", status="offline"),
+ Node(type="server", label="D", status="unknown"),
+ Node(type="iot", label="Z1", status="online", ieee_address="0x1"),
+ Node(type="iot", label="Z2", status="online", ieee_address="0x2"),
+ PendingDevice(ip="10.0.0.1", status="pending"),
+ PendingDevice(ip="10.0.0.2", status="pending"),
+ PendingDevice(ip="10.0.0.3", status="hidden"), # excluded
+ ScanRun(status="success", finished_at=finished),
+ ScanRun(status="success",
+ finished_at=datetime(2026, 5, 13, 10, 0, tzinfo=timezone.utc)),
+ ])
+ await db_session.commit()
+
+ res = await client.get(
+ "/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
+ )
+ assert res.status_code == 200
+ body = res.json()
+ assert body["nodes"] == 6
+ assert body["online"] == 4
+ assert body["offline"] == 1
+ assert body["unknown"] == 1
+ assert body["pending_devices"] == 2
+ assert body["zigbee_devices"] == 2
+ # SQLite returns naive datetimes; compare prefix only.
+ assert body["last_scan_at"] is not None
+ assert body["last_scan_at"].startswith("2026-05-14T10:00:00")
diff --git a/backend/tests/test_status_checker.py b/backend/tests/test_status_checker.py
index 3033127..205e62d 100644
--- a/backend/tests/test_status_checker.py
+++ b/backend/tests/test_status_checker.py
@@ -169,6 +169,31 @@ async def test_ping_uses_unix_args_on_non_windows():
assert "-c" in captured["args"]
assert "-W" in captured["args"]
assert "-n" not in captured["args"]
+ # Linux: -W is in seconds; 1s is the intended timeout
+ w_idx = captured["args"].index("-W")
+ assert captured["args"][w_idx + 1] == "1"
+
+
+@pytest.mark.asyncio
+async def test_ping_uses_macos_millisecond_timeout():
+ """macOS ping(8) -W is milliseconds, not seconds. 1ms would fail any RTT >1ms."""
+ captured = {}
+
+ async def fake_exec(*args, **kwargs):
+ captured["args"] = args
+ proc = MagicMock()
+ proc.returncode = 0
+ proc.wait = AsyncMock()
+ return proc
+
+ with patch("app.services.status_checker.sys.platform", "darwin"), \
+ patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
+ await _ping("192.168.1.1")
+
+ assert "-c" in captured["args"]
+ assert "-W" in captured["args"]
+ w_idx = captured["args"].index("-W")
+ assert captured["args"][w_idx + 1] == "1000"
@pytest.mark.asyncio
@@ -191,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 bafecf0..3c89582 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "frontend",
- "version": "1.13.0",
+ "version": "2.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
- "version": "1.13.0",
+ "version": "2.1.1",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
@@ -2520,6 +2520,9 @@
"arm"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2534,6 +2537,9 @@
"arm"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2548,6 +2554,9 @@
"arm64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2562,6 +2571,9 @@
"arm64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2576,6 +2588,9 @@
"loong64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2590,6 +2605,9 @@
"loong64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2604,6 +2622,9 @@
"ppc64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2618,6 +2639,9 @@
"ppc64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2632,6 +2656,9 @@
"riscv64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2646,6 +2673,9 @@
"riscv64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2660,6 +2690,9 @@
"s390x"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2674,6 +2707,9 @@
"x64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2688,6 +2724,9 @@
"x64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2936,6 +2975,9 @@
"arm64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2953,6 +2995,9 @@
"arm64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2970,6 +3015,9 @@
"x64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -2987,6 +3035,9 @@
"x64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MIT",
"optional": true,
"os": [
@@ -6884,6 +6935,9 @@
"arm64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6905,6 +6959,9 @@
"arm64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6926,6 +6983,9 @@
"x64"
],
"dev": true,
+ "libc": [
+ "glibc"
+ ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -6947,6 +7007,9 @@
"x64"
],
"dev": true,
+ "libc": [
+ "musl"
+ ],
"license": "MPL-2.0",
"optional": true,
"os": [
diff --git a/frontend/package.json b/frontend/package.json
index b45d482..727a7b6 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "1.13.0",
+ "version": "2.1.1",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/scripts/fetch-dashboard-icons.mjs b/frontend/scripts/fetch-dashboard-icons.mjs
new file mode 100644
index 0000000..7718f86
--- /dev/null
+++ b/frontend/scripts/fetch-dashboard-icons.mjs
@@ -0,0 +1,27 @@
+#!/usr/bin/env node
+// Regenerate frontend/src/data/dashboardIcons.json from the upstream
+// homarr-labs/dashboard-icons repo. Run manually to refresh the manifest.
+//
+// node scripts/fetch-dashboard-icons.mjs
+
+import { writeFileSync, mkdirSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const TREE_URL = 'https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/tree.json'
+const OUT = resolve(dirname(fileURLToPath(import.meta.url)), '../src/data/dashboardIcons.json')
+
+const res = await fetch(TREE_URL)
+if (!res.ok) {
+ console.error(`fetch failed: ${res.status} ${res.statusText}`)
+ process.exit(1)
+}
+const tree = await res.json()
+const slugs = (tree.svg ?? [])
+ .filter((f) => f.endsWith('.svg'))
+ .map((f) => f.slice(0, -4))
+ .sort()
+
+mkdirSync(dirname(OUT), { recursive: true })
+writeFileSync(OUT, JSON.stringify(slugs))
+console.log(`wrote ${slugs.length} slugs → ${OUT}`)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index c74c62b..073b7b9 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -4,6 +4,7 @@ import { type Node } from '@xyflow/react'
import { applyDagreLayout } from '@/utils/layout'
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { generateUUID } from '@/utils/uuid'
+import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { ExportModal } from '@/components/modals/ExportModal'
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
@@ -21,6 +22,7 @@ import { EdgeModal } from '@/components/modals/EdgeModal'
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
+import { TextModal, type TextFormData } from '@/components/modals/TextModal'
import { ThemeModal } from '@/components/modals/ThemeModal'
import { SearchModal } from '@/components/modals/SearchModal'
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
@@ -36,10 +38,9 @@ import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
-const CONTAINER_MODE_TYPES = new Set(['proxmox', 'vm', 'lxc', 'docker_host'])
export default function App() {
- const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
+ const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
const canvasRef = useRef(null)
const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
@@ -61,6 +62,7 @@ export default function App() {
const [shortcutsOpen, setShortcutsOpen] = useState(false)
const [addNodeOpen, setAddNodeOpen] = useState(false)
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
+ const [addTextOpen, setAddTextOpen] = useState(false)
const [editNodeId, setEditNodeId] = useState(null)
const [pendingConnection, setPendingConnection] = useState(null)
const [editEdgeId, setEditEdgeId] = useState(null)
@@ -241,6 +243,67 @@ export default function App() {
setEditingGroupRectId(null)
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory])
+ const handleAddText = useCallback((data: TextFormData) => {
+ snapshotHistory()
+ const id = generateUUID()
+ const newNode: Node = {
+ id,
+ // Text lives in `label` because the API serializer only persists top-level
+ // node fields; text_content is not in the schema and was lost on reload.
+ // TextNode and the edit modal both already fall back to label.
+ type: 'text',
+ position: { x: 250, y: 250 },
+ data: {
+ label: data.text,
+ type: 'text',
+ status: 'unknown',
+ services: [],
+ custom_colors: {
+ border: data.border_color,
+ border_style: data.border_style,
+ border_width: data.border_width,
+ background: data.background_color,
+ text_color: data.text_color,
+ text_size: data.text_size,
+ font: data.font,
+ },
+ },
+ width: 200,
+ height: 60,
+ }
+ addNode(newNode)
+ }, [addNode, snapshotHistory])
+
+ const handleUpdateText = useCallback((data: TextFormData) => {
+ if (!editingTextId) return
+ snapshotHistory()
+ const existing = nodes.find((n) => n.id === editingTextId)
+ updateNode(editingTextId, {
+ label: data.text,
+ // Clear stale text_content if present from older builds — label is the
+ // source of truth now.
+ text_content: undefined,
+ custom_colors: {
+ ...existing?.data.custom_colors,
+ border: data.border_color,
+ border_style: data.border_style,
+ border_width: data.border_width,
+ background: data.background_color,
+ text_color: data.text_color,
+ text_size: data.text_size,
+ font: data.font,
+ },
+ })
+ setEditingTextId(null)
+ }, [editingTextId, nodes, updateNode, setEditingTextId, snapshotHistory])
+
+ const handleDeleteText = useCallback(() => {
+ if (!editingTextId) return
+ snapshotHistory()
+ deleteNode(editingTextId)
+ setEditingTextId(null)
+ }, [editingTextId, deleteNode, setEditingTextId, snapshotHistory])
+
const handleDeleteGroupRect = useCallback(() => {
if (!editingGroupRectId) return
snapshotHistory()
@@ -387,16 +450,14 @@ export default function App() {
if (edgeData.type === 'virtual') {
const src = nodes.find((n) => n.id === pendingConnection.source)
const tgt = nodes.find((n) => n.id === pendingConnection.target)
- const srcType = src?.data.type as NodeData['type']
- const tgtType = tgt?.data.type as NodeData['type']
- if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) {
- updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
- } else if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) {
- updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
- } else if (srcType === 'docker_container' && tgtType === 'docker_host') {
- updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
- } else if (tgtType === 'docker_container' && srcType === 'docker_host') {
- updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
+ if (src && tgt) {
+ const assignment = resolveVirtualEdgeParent(
+ { id: src.id, type: src.data.type as NodeData['type'] },
+ { id: tgt.id, type: tgt.data.type as NodeData['type'] },
+ )
+ if (assignment) {
+ updateNode(assignment.childId, { parent_id: assignment.parentId })
+ }
}
}
setPendingConnection(null)
@@ -407,6 +468,11 @@ export default function App() {
}, [])
const handleNodeDoubleClick = useCallback((node: Node) => {
+ // 'group' uses inline rename (pencil button in header). Opening the
+ // generic NodeModal would clobber the group's height (via the
+ // properties-clears-height rule in updateNode) and lose its children.
+ // 'groupRect' has its own onDoubleClick that already routes to GroupRectModal.
+ if (node.data.type === 'group' || node.data.type === 'groupRect') return
handleEditNode(node.id)
}, [handleEditNode])
@@ -443,6 +509,7 @@ export default function App() {
setAddNodeOpen(true)}
onAddGroupRect={() => setAddGroupRectOpen(true)}
+ onAddText={() => setAddTextOpen(true)}
onScan={() => setScanConfigOpen(true)}
onZigbeeImport={() => setZigbeeImportOpen(true)}
onSave={handleSave}
@@ -483,9 +550,7 @@ 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 }))}
+ parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))}
/>
{/* key forces re-mount when editing a different node, resetting form state */}
@@ -496,9 +561,25 @@ 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 }))}
+ parentCandidates={(() => {
+ const descendants = new Set()
+ if (editNodeId) {
+ const queue = [editNodeId]
+ while (queue.length) {
+ const id = queue.shift()!
+ for (const n of nodes) {
+ if (n.data.parent_id === id && !descendants.has(n.id)) {
+ descendants.add(n.id)
+ queue.push(n.id)
+ }
+ }
+ }
+ }
+ return nodes
+ .filter((n) => !descendants.has(n.id))
+ .map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))
+ })()}
+ currentNodeId={editNodeId ?? undefined}
/>
+ setAddTextOpen(false)}
+ onSubmit={handleAddText}
+ title="Add Text"
+ />
+
+ setEditingTextId(null)}
+ onSubmit={handleUpdateText}
+ onDelete={handleDeleteText}
+ initial={(() => {
+ const n = editingTextId ? nodes.find((nd) => nd.id === editingTextId) : null
+ if (!n) return undefined
+ const rc = n.data.custom_colors ?? {}
+ return {
+ text: n.data.text_content ?? n.data.label ?? '',
+ font: rc.font ?? 'inter',
+ text_color: rc.text_color ?? '#e6edf3',
+ text_size: rc.text_size ?? 14,
+ border_color: rc.border ?? '#30363d',
+ border_style: (rc.border_style ?? 'none') as TextFormData['border_style'],
+ border_width: rc.border_width ?? 1,
+ background_color: rc.background ?? '#00000000',
+ }
+ })()}
+ title="Edit Text"
+ />
+
{/* key forces re-mount on open so useState captures current theme as original */}
= {
+ 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/LiveView.tsx b/frontend/src/components/LiveView.tsx
index 930fb5d..b53d3ab 100644
--- a/frontend/src/components/LiveView.tsx
+++ b/frontend/src/components/LiveView.tsx
@@ -29,7 +29,7 @@ import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { liveviewApi } from '@/api/client'
-import type { NodeData } from '@/types'
+import type { NodeData, CustomStyleDef } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STORAGE_KEY = 'homelable_canvas'
@@ -40,6 +40,8 @@ function LiveViewCanvas() {
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
const { fitView } = useReactFlow()
const activeTheme = useThemeStore((s) => s.activeTheme)
+ const setTheme = useThemeStore((s) => s.setTheme)
+ const setCustomStyle = useThemeStore((s) => s.setCustomStyle)
const theme = THEMES[activeTheme]
// Derive initial view state synchronously (avoids calling setState inside an effect):
// - standalone → always ready (localStorage, no key required)
@@ -73,9 +75,12 @@ function LiveViewCanvas() {
const { nodes: apiNodes, edges: apiEdges } = res.data
const proxmoxMap = new Map(
(apiNodes as ApiNode[])
- .filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
- .map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
+ .filter((n: ApiNode) => n.type === 'group' || n.container_mode === true)
+ .map((n: ApiNode) => [n.id, true])
)
+ const savedTheme = res.data.viewport?.theme_id
+ if (savedTheme) setTheme(savedTheme)
+ if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
loadCanvas(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
@@ -87,7 +92,7 @@ function LiveViewCanvas() {
const detail: string = err.response.data?.detail ?? ''
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
})
- }, [loadCanvas])
+ }, [loadCanvas, setTheme, setCustomStyle])
useEffect(() => {
if (!fitViewPending || nodes.length === 0) return
diff --git a/frontend/src/components/__tests__/LiveView.test.tsx b/frontend/src/components/__tests__/LiveView.test.tsx
index 603d1fa..89c6b39 100644
--- a/frontend/src/components/__tests__/LiveView.test.tsx
+++ b/frontend/src/components/__tests__/LiveView.test.tsx
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { useCanvasStore } from '@/stores/canvasStore'
+import { useThemeStore } from '@/stores/themeStore'
// ── Mock heavy dependencies ────────────────────────────────────────────────
@@ -124,6 +125,55 @@ describe('LiveView (non-standalone)', () => {
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
})
+ // ── Nested children (docker_container inside docker_host) ────────────────
+
+ it('nests docker_container under docker_host parent (container_mode=true)', async () => {
+ setSearch('?key=valid')
+ const nestedPayload = {
+ data: {
+ nodes: [
+ {
+ id: 'host', type: 'docker', label: 'Docker Host', status: 'online',
+ services: [], pos_x: 0, pos_y: 0, container_mode: true,
+ created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
+ },
+ {
+ id: 'ctr', type: 'docker_container', label: 'nginx', status: 'online',
+ services: [], pos_x: 20, pos_y: 30, parent_id: 'host',
+ created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
+ },
+ ],
+ edges: [],
+ viewport: { x: 0, y: 0, zoom: 1 },
+ },
+ }
+ vi.mocked(liveviewApi.load).mockResolvedValue(nestedPayload as never)
+ render()
+ await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
+ const ctr = useCanvasStore.getState().nodes.find((n) => n.id === 'ctr')
+ expect(ctr?.parentId).toBe('host')
+ expect(ctr?.extent).toBe('parent')
+ })
+
+ // ── Theme + custom_style applied from payload ────────────────────────────
+
+ it('applies viewport.theme_id and custom_style from the payload', async () => {
+ setSearch('?key=valid')
+ const styledPayload = {
+ data: {
+ nodes: [],
+ edges: [],
+ viewport: { x: 0, y: 0, zoom: 1, theme_id: 'matrix' },
+ custom_style: { fontFamily: 'Inter', nodeRadius: 12 },
+ },
+ }
+ vi.mocked(liveviewApi.load).mockResolvedValue(styledPayload as never)
+ render()
+ await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
+ expect(useThemeStore.getState().activeTheme).toBe('matrix')
+ expect(useThemeStore.getState().customStyle).toEqual({ fontFamily: 'Inter', nodeRadius: 12 })
+ })
+
// ── No editing props passed ───────────────────────────────────────────────
it('does not show any Access Denied when key is valid', async () => {
diff --git a/frontend/src/components/canvas/AlignmentGuides.tsx b/frontend/src/components/canvas/AlignmentGuides.tsx
new file mode 100644
index 0000000..ecad7ea
--- /dev/null
+++ b/frontend/src/components/canvas/AlignmentGuides.tsx
@@ -0,0 +1,70 @@
+import { useViewport } from '@xyflow/react'
+import type { Guide } from '@/utils/alignment'
+
+interface AlignmentGuidesProps {
+ guides: Guide[]
+ color?: string
+}
+
+/**
+ * SVG overlay that draws alignment guide lines on top of the React Flow canvas.
+ * Coordinates are in canvas (flow) space; we read the viewport transform to
+ * project them into screen space so lines stay locked to nodes when the user
+ * pans or zooms.
+ */
+export function AlignmentGuides({ guides, color = '#00d4ff' }: AlignmentGuidesProps) {
+ const { x: vx, y: vy, zoom } = useViewport()
+
+ if (guides.length === 0) return null
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx
index 9ec2726..7433492 100644
--- a/frontend/src/components/canvas/CanvasContainer.tsx
+++ b/frontend/src/components/canvas/CanvasContainer.tsx
@@ -20,6 +20,8 @@ import { THEMES } from '@/utils/themes'
import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes'
import { SearchBar } from './SearchBar'
+import { AlignmentGuides } from './AlignmentGuides'
+import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
import type { NodeData, EdgeData } from '@/types'
interface CanvasContainerProps {
@@ -83,6 +85,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
[]
)
+ const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
+
return (