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

--- @@ -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 ( + + {guides.map((g, i) => { + if (g.axis === 'x') { + const x = g.position * zoom + vx + const y1 = g.start * zoom + vy + const y2 = g.end * zoom + vy + return ( + + ) + } + const y = g.position * zoom + vy + const x1 = g.start * zoom + vx + const x2 = g.end * zoom + vx + 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 (
+ setLassoMode((m) => !m)} diff --git a/frontend/src/components/canvas/__tests__/AlignmentGuides.test.tsx b/frontend/src/components/canvas/__tests__/AlignmentGuides.test.tsx new file mode 100644 index 0000000..d178fdf --- /dev/null +++ b/frontend/src/components/canvas/__tests__/AlignmentGuides.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from 'vitest' +import { render } from '@testing-library/react' +import { AlignmentGuides } from '../AlignmentGuides' +import type { Guide } from '@/utils/alignment' + +vi.mock('@xyflow/react', () => ({ + useViewport: () => ({ x: 50, y: 100, zoom: 2 }), +})) + +describe('AlignmentGuides', () => { + it('renders nothing when no guides', () => { + const { container } = render() + expect(container.querySelector('svg')).toBeNull() + }) + + it('projects an x-axis guide through the viewport transform', () => { + const guides: Guide[] = [{ axis: 'x', position: 100, start: 0, end: 200 }] + const { container } = render() + const line = container.querySelector('line')! + // x = position * zoom + vx → 100*2 + 50 = 250 + expect(line.getAttribute('x1')).toBe('250') + expect(line.getAttribute('x2')).toBe('250') + // y1 = start * zoom + vy → 0*2 + 100 = 100; y2 = 200*2 + 100 = 500 + expect(line.getAttribute('y1')).toBe('100') + expect(line.getAttribute('y2')).toBe('500') + }) + + it('projects a y-axis guide horizontally', () => { + const guides: Guide[] = [{ axis: 'y', position: 50, start: 10, end: 60 }] + const { container } = render() + const line = container.querySelector('line')! + // y = 50*2 + 100 = 200; x1 = 10*2 + 50 = 70; x2 = 60*2 + 50 = 170 + expect(line.getAttribute('y1')).toBe('200') + expect(line.getAttribute('y2')).toBe('200') + expect(line.getAttribute('x1')).toBe('70') + expect(line.getAttribute('x2')).toBe('170') + }) + + it('renders one line per guide', () => { + const guides: Guide[] = [ + { axis: 'x', position: 100, start: 0, end: 200 }, + { axis: 'y', position: 50, start: 10, end: 60 }, + ] + const { container } = render() + expect(container.querySelectorAll('line')).toHaveLength(2) + }) +}) diff --git a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx index ae5591f..91ec10b 100644 --- a/frontend/src/components/canvas/__tests__/BaseNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/BaseNode.test.tsx @@ -44,11 +44,13 @@ vi.mock('@/utils/nodeColors', () => ({ vi.mock('@/utils/nodeIcons', () => ({ resolveNodeIcon: (_typeIcon: unknown) => _typeIcon, + isBrandIconKey: (k: string | undefined) => !!k && k.startsWith('brand:'), })) vi.mock('@/utils/maskIp', () => ({ maskIp: (ip: string) => ip, splitIps: (ip: string) => ip ? ip.split(',').map((s: string) => s.trim()).filter(Boolean) : [], + primaryIp: (ip: string) => ip ? ip.split(',')[0].trim() : '', })) vi.mock('@/utils/propertyIcons', () => ({ @@ -169,6 +171,49 @@ describe('BaseNode — properties rendering', () => { }) }) +describe('BaseNode — services visibility toggle', () => { + it('does not render service toggle button on the node', () => { + renderBaseNode({ services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }] }) + expect(screen.queryByTitle('Show services')).toBeNull() + }) + + it('renders service rows when services are toggled on', () => { + renderBaseNode({ + ip: '192.168.1.10', + custom_colors: { show_services: true }, + services: [ + { service_name: 'nginx', port: 80, protocol: 'tcp' }, + { service_name: 'ssh', port: 22, protocol: 'tcp' }, + ], + }) + + expect(screen.getByText('nginx')).toBeDefined() + expect(screen.getByText('80')).toBeDefined() + expect(screen.getByText('ssh')).toBeDefined() + }) + + it('renders clickable service links for web services', () => { + renderBaseNode({ + ip: '192.168.1.10', + custom_colors: { show_services: true }, + services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }], + }) + + const link = screen.getByRole('link', { name: /nginx/i }) as HTMLAnchorElement + expect(link.getAttribute('href')).toBe('http://192.168.1.10:80') + }) + + it('keeps non-web services as non-clickable rows', () => { + renderBaseNode({ + ip: '192.168.1.10', + custom_colors: { show_services: true }, + services: [{ service_name: 'ssh', port: 22, protocol: 'tcp' }], + }) + + expect(screen.queryByRole('link', { name: /ssh/i })).toBeNull() + }) +}) + describe('BaseNode — legacy hardware fallback', () => { it('renders legacy hardware when properties is undefined and show_hardware is true', () => { renderBaseNode({ diff --git a/frontend/src/components/canvas/__tests__/GroupNode.test.tsx b/frontend/src/components/canvas/__tests__/GroupNode.test.tsx index f99ffae..926e4c8 100644 --- a/frontend/src/components/canvas/__tests__/GroupNode.test.tsx +++ b/frontend/src/components/canvas/__tests__/GroupNode.test.tsx @@ -11,6 +11,8 @@ vi.mock('@xyflow/react', () => ({ NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
), + Handle: () => null, + Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' }, useReactFlow: () => ({}), })) diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index 3a6c24d..2a4fb8b 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import { BaseEdge, EdgeLabelRenderer, @@ -179,9 +179,113 @@ function segmentMidpoints( }) } +// ── Endpoint dot (interactive reconnection handle pinned to handle) ────────── + +interface EndpointDotProps { + edgeId: string + role: 'source' | 'target' + x: number + y: number + position?: string + color: string + source: string + target: string + sourceHandle: string | null | undefined + targetHandle: string | null | undefined + onDrag: (pos: { x: number; y: number } | null) => void +} + +/** + * Interactive endpoint marker rendered above the node layer (via + * EdgeLabelRenderer). On pointerup it inspects the element under the cursor + * for a React Flow handle (`[data-handleid]`) and calls `reconnectEdge` with + * the new endpoint. Drop on empty space leaves the edge unchanged. + * + * Handles are nudged 3px inward (toward the node) because React Flow's edge + * endpoint coords sit at the outer edge of the handle box, not its center. + */ +function EndpointDot({ edgeId, role, x, y, position, color, source, target, sourceHandle, targetHandle, onDrag }: EndpointDotProps) { + const reconnectEdge = useCanvasStore((s) => s.reconnectEdge) + const { screenToFlowPosition } = useReactFlow() + + const offset = 3 + let dx = 0, dy = 0 + if (position === 'bottom') dy = -offset + else if (position === 'top') dy = offset + else if (position === 'left') dx = offset + else if (position === 'right') dx = -offset + + const onPointerDown = useCallback((e: React.PointerEvent) => { + e.stopPropagation() + e.currentTarget.setPointerCapture(e.pointerId) + }, []) + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (e.buttons !== 1) return + onDrag(screenToFlowPosition({ x: e.clientX, y: e.clientY })) + }, [onDrag, screenToFlowPosition]) + + const onPointerUp = useCallback((e: React.PointerEvent) => { + e.currentTarget.releasePointerCapture(e.pointerId) + // Find the topmost handle under cursor, skipping the dragged dot itself. + const stack = document.elementsFromPoint(e.clientX, e.clientY) + let handleEl: HTMLElement | null = null + for (const node of stack) { + const h = (node as HTMLElement).closest?.('[data-handleid]') as HTMLElement | null + if (h) { handleEl = h; break } + } + onDrag(null) + if (!handleEl) return // dropped on empty space → keep edge unchanged + const newHandleId = handleEl.getAttribute('data-handleid') + const newNodeId = handleEl.getAttribute('data-nodeid') + if (!newHandleId || !newNodeId) return + if (role === 'source') { + reconnectEdge(edgeId, { source: newNodeId, target, sourceHandle: newHandleId, targetHandle: targetHandle ?? null }) + } else { + reconnectEdge(edgeId, { source, target: newNodeId, sourceHandle: sourceHandle ?? null, targetHandle: newHandleId }) + } + }, [edgeId, role, source, target, sourceHandle, targetHandle, reconnectEdge, onDrag]) + + return ( +
+ ) +} + // ── Main edge component ────────────────────────────────────────────────────── -export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps>) { +export function HomelableEdge({ id, source, target, sourceHandleId, targetHandleId, sourceX: rawSourceX, sourceY: rawSourceY, targetX: rawTargetX, targetY: rawTargetY, sourcePosition, targetPosition, data, selected }: EdgeProps>) { + const [drag, setDrag] = useState<{ role: 'source' | 'target'; x: number; y: number } | null>(null) + + const sourceX = drag?.role === 'source' ? drag.x : rawSourceX + const sourceY = drag?.role === 'source' ? drag.y : rawSourceY + const targetX = drag?.role === 'target' ? drag.x : rawTargetX + const targetY = drag?.role === 'target' ? drag.y : rawTargetY + + const onSourceDrag = useCallback((pos: { x: number; y: number } | null) => { + setDrag(pos ? { role: 'source', x: pos.x, y: pos.y } : null) + }, []) + const onTargetDrag = useCallback((pos: { x: number; y: number } | null) => { + setDrag(pos ? { role: 'target', x: pos.x, y: pos.y } : null) + }, []) + const activeTheme = useThemeStore((s) => s.activeTheme) const theme = THEMES[activeTheme] const sourceType = useStore((s) => s.nodeLookup.get(source)?.type) @@ -311,6 +415,38 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
)} + {/* Endpoint dots — visual indicators for reconnection targets */} + {selected && ( + <> + + + + )} + {/* Existing waypoint drag handles */} {selected && waypoints.map((wp, idx) => { const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1] diff --git a/frontend/src/components/canvas/nodes/BaseNode.tsx b/frontend/src/components/canvas/nodes/BaseNode.tsx index c239452..1c8c398 100644 --- a/frontend/src/components/canvas/nodes/BaseNode.tsx +++ b/frontend/src/components/canvas/nodes/BaseNode.tsx @@ -1,15 +1,17 @@ import { createElement, useEffect, useMemo } from 'react' import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react' -import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react' +import { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react' import type { NodeData } from '@/types' import { resolveNodeColors } from '@/utils/nodeColors' -import { resolveNodeIcon } from '@/utils/nodeIcons' +import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons' +import { NodeIcon } from '@/components/ui/NodeIcon' import { resolvePropertyIcon } from '@/utils/propertyIcons' import { useThemeStore } from '@/stores/themeStore' import { THEMES } from '@/utils/themes' import { useCanvasStore } from '@/stores/canvasStore' -import { maskIp, splitIps } from '@/utils/maskIp' +import { maskIp, primaryIp, splitIps } from '@/utils/maskIp' import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils' +import { getServiceUrl } from '@/utils/serviceUrl' interface BaseNodeProps extends NodeProps> { icon: LucideIcon @@ -35,6 +37,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: const colors = resolveNodeColors(data, activeTheme) const statusColor = theme.colors.statusColors[data.status] const isOnline = data.status === 'online' + const services = data.services ?? [] + const showServices = data.custom_colors?.show_services === true + const serviceHost = data.ip ? primaryIp(data.ip) : data.hostname // Properties: prefer new system; fall back to legacy hardware fields for unmigrated nodes const visibleProperties = data.properties?.filter((p) => p.visible) ?? null @@ -94,7 +99,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: background: theme.colors.nodeIconBackground, }} > - {createElement(resolvedIcon, { size: 15 })} + {isBrandIconKey(data.custom_icon) + ? + : createElement(resolvedIcon, { size: 15 })}
{/* Label + IP */} @@ -138,6 +145,74 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: )} + {showServices && services.length > 0 && ( + <> +
+
+ {services.map((svc, idx) => { + const url = getServiceUrl(svc, serviceHost) + const row = ( +
+ +
+ {/* LEFT: service name */} + + {svc.service_name} + + + {/* RIGHT: path + port */} +
+ {svc.path && ( + + {svc.path} + + )} + + + {svc.port} + + +
+
+
+ ) + + if (!url) return
{row}
+ + return ( + e.stopPropagation()} + > + {row} + + ) + })} +
+ + )} + {/* Legacy hardware section — fallback for nodes not yet migrated */} {showLegacyHardware && ( <> diff --git a/frontend/src/components/canvas/nodes/GroupNode.tsx b/frontend/src/components/canvas/nodes/GroupNode.tsx index e8396b6..0b3c058 100644 --- a/frontend/src/components/canvas/nodes/GroupNode.tsx +++ b/frontend/src/components/canvas/nodes/GroupNode.tsx @@ -1,11 +1,15 @@ import { useState } from 'react' -import { type NodeProps, type Node, NodeResizer } from '@xyflow/react' +import { type NodeProps, type Node, NodeResizer, Handle, Position } from '@xyflow/react' import { Layers, Pencil, Check, X } from 'lucide-react' import { useCanvasStore } from '@/stores/canvasStore' +import { useThemeStore } from '@/stores/themeStore' +import { THEMES } from '@/utils/themes' import { STATUS_COLORS, type NodeData } from '@/types' export function GroupNode({ id, data, selected }: NodeProps>) { const { nodes, updateNode, snapshotHistory } = useCanvasStore() + const activeTheme = useThemeStore((s) => s.activeTheme) + const theme = THEMES[activeTheme] const showBorder = data.custom_colors?.show_border !== false const isVisible = showBorder || selected @@ -49,6 +53,29 @@ export function GroupNode({ id, data, selected }: NodeProps>) { handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }} /> + {/* 4 snap-point handles — one per side. Source + invisible target overlay for each. */} + {([ + ['group-top', Position.Top], + ['group-right', Position.Right], + ['group-bottom', Position.Bottom], + ['group-left', Position.Left], + ] as const).map(([hid, pos]) => ( + + + + + ))} + {/* Header */} {isVisible && (
>) { - const { data, selected } = props + const { id, data, selected } = props + const updateNodeInternals = useUpdateNodeInternals() + useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals]) const activeTheme = useThemeStore((s) => s.activeTheme) const hideIp = useCanvasStore((s) => s.hideIp) @@ -87,7 +91,9 @@ export function ProxmoxGroupNode(props: NodeProps>) { background: theme.colors.nodeIconBackground, }} > - {createElement(resolvedIcon, { size: 12 })} + {isBrandIconKey(data.custom_icon) + ? + : createElement(resolvedIcon, { size: 12 })}
>) { style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }} /> - - + {bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => { + const sourceId = bottomHandleId(idx) + const targetId = `${sourceId}-t` + return ( + + + + + ) + })} {/* Cluster handles */} = { + inter: 'Inter, sans-serif', + mono: '"JetBrains Mono", monospace', + serif: 'Georgia, serif', + sans: 'system-ui, sans-serif', +} + +export function TextNode({ id, data, selected }: NodeProps>) { + const setEditingTextId = useCanvasStore((s) => s.setEditingTextId) + + const rc = data.custom_colors ?? {} + const borderColor = rc.border ?? '#30363d' + const borderStyle = rc.border_style ?? 'none' + const borderWidth = rc.border_width ?? 1 + const backgroundColor = rc.background ?? 'transparent' + const textColor = rc.text_color ?? '#e6edf3' + const textSize: number = rc.text_size ?? 14 + const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter + + const content = data.text_content ?? data.label ?? '' + + return ( + <> + + +
{ + e.stopPropagation() + setEditingTextId(id) + }} + > + {content} +
+ + ) +} diff --git a/frontend/src/components/canvas/nodes/__tests__/GroupNode.test.tsx b/frontend/src/components/canvas/nodes/__tests__/GroupNode.test.tsx new file mode 100644 index 0000000..6b0f158 --- /dev/null +++ b/frontend/src/components/canvas/nodes/__tests__/GroupNode.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render } from '@testing-library/react' +import { ReactFlowProvider } from '@xyflow/react' +import { GroupNode } from '../GroupNode' +import { useCanvasStore } from '@/stores/canvasStore' +import { useThemeStore } from '@/stores/themeStore' +import type { NodeData } from '@/types' +import type { NodeProps, Node } from '@xyflow/react' + +function renderNode(data: Partial = {}, selected = false) { + const fullData: NodeData = { + label: 'Group A', + type: 'group', + status: 'unknown', + services: [], + ...data, + } + const props = { + id: 'g1', + data: fullData, + selected, + type: 'group', + 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('GroupNode', () => { + beforeEach(() => { + useCanvasStore.setState({ nodes: [], hideIp: false }) + useThemeStore.setState({ activeTheme: 'default' }) + }) + + it('renders label', () => { + const { getByText } = renderNode({ label: 'My Group' }) + expect(getByText('My Group')).toBeDefined() + }) + + it('renders 4 source handles (one per side)', () => { + const { container } = renderNode() + expect(container.querySelector('.react-flow__handle-top.source')).not.toBeNull() + expect(container.querySelector('.react-flow__handle-right.source')).not.toBeNull() + expect(container.querySelector('.react-flow__handle-bottom.source')).not.toBeNull() + expect(container.querySelector('.react-flow__handle-left.source')).not.toBeNull() + }) + + it('renders 4 target handles (one per side)', () => { + const { container } = renderNode() + expect(container.querySelector('.react-flow__handle-top.target')).not.toBeNull() + expect(container.querySelector('.react-flow__handle-right.target')).not.toBeNull() + expect(container.querySelector('.react-flow__handle-bottom.target')).not.toBeNull() + expect(container.querySelector('.react-flow__handle-left.target')).not.toBeNull() + }) + + it('source handles carry side-specific ids', () => { + const { container } = renderNode() + expect(container.querySelector('[data-handleid="group-top"]')).not.toBeNull() + expect(container.querySelector('[data-handleid="group-right"]')).not.toBeNull() + expect(container.querySelector('[data-handleid="group-bottom"]')).not.toBeNull() + expect(container.querySelector('[data-handleid="group-left"]')).not.toBeNull() + }) +}) 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..156cd0d --- /dev/null +++ b/frontend/src/components/canvas/nodes/__tests__/ProxmoxGroupNode.test.tsx @@ -0,0 +1,123 @@ +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('container mode renders bottom_handles snap points', () => { + const { container } = renderNode({ bottom_handles: 4 }) + const sourceHandles = container.querySelectorAll('.react-flow__handle-bottom.source') + expect(sourceHandles.length).toBe(4) + }) + + it('container mode default has single bottom handle', () => { + const { container } = renderNode({}) + const sourceHandles = container.querySelectorAll('.react-flow__handle-bottom.source') + expect(sourceHandles.length).toBe(1) + }) + + 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/canvas/nodes/__tests__/TextNode.test.tsx b/frontend/src/components/canvas/nodes/__tests__/TextNode.test.tsx new file mode 100644 index 0000000..3bd67df --- /dev/null +++ b/frontend/src/components/canvas/nodes/__tests__/TextNode.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { render, fireEvent } from '@testing-library/react' +import { ReactFlowProvider } from '@xyflow/react' +import { TextNode } from '../TextNode' +import { useCanvasStore } from '@/stores/canvasStore' +import type { NodeData } from '@/types' +import type { NodeProps, Node } from '@xyflow/react' + +function renderNode(data: Partial = {}) { + const fullData: NodeData = { + label: '', + type: 'text', + status: 'unknown', + services: [], + text_content: 'Hello', + ...data, + } + const props = { + id: 't1', + data: fullData, + selected: false, + type: 'text', + zIndex: 0, + isConnectable: true, + xPos: 0, + yPos: 0, + dragging: false, + deletable: true, + draggable: true, + selectable: true, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + width: 200, + height: 60, + dragHandle: undefined, + parentId: undefined, + sourcePosition: undefined, + targetPosition: undefined, + } as unknown as NodeProps> + return render( + + + + ) +} + +describe('TextNode', () => { + beforeEach(() => { + useCanvasStore.setState({ editingTextId: null }) + }) + + it('renders text_content', () => { + const { getByText } = renderNode({ text_content: 'My label' }) + expect(getByText('My label')).toBeDefined() + }) + + it('falls back to label when text_content is missing', () => { + const { getByText } = renderNode({ text_content: undefined, label: 'Fallback' }) + expect(getByText('Fallback')).toBeDefined() + }) + + it('double-click sets editingTextId in store', () => { + const { getByText } = renderNode({ text_content: 'Edit me' }) + fireEvent.doubleClick(getByText('Edit me')) + expect(useCanvasStore.getState().editingTextId).toBe('t1') + }) +}) diff --git a/frontend/src/components/canvas/nodes/nodeTypes.ts b/frontend/src/components/canvas/nodes/nodeTypes.ts index c390c4a..34d25ec 100644 --- a/frontend/src/components/canvas/nodes/nodeTypes.ts +++ b/frontend/src/components/canvas/nodes/nodeTypes.ts @@ -2,6 +2,7 @@ import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcN import { ProxmoxGroupNode } from './ProxmoxGroupNode' import { GroupRectNode } from './GroupRectNode' import { GroupNode } from './GroupNode' +import { TextNode } from './TextNode' export const nodeTypes = { isp: IspNode, @@ -24,6 +25,7 @@ export const nodeTypes = { generic: GenericNode, groupRect: GroupRectNode, group: GroupNode, + text: TextNode, zigbee_coordinator: ZigbeeCoordinatorNode, zigbee_router: ZigbeeRouterNode, zigbee_enddevice: ZigbeeEndDeviceNode, diff --git a/frontend/src/components/modals/BrandIconPicker.tsx b/frontend/src/components/modals/BrandIconPicker.tsx new file mode 100644 index 0000000..0f1c22c --- /dev/null +++ b/frontend/src/components/modals/BrandIconPicker.tsx @@ -0,0 +1,85 @@ +import { useMemo, useState } from 'react' +import { Input } from '@/components/ui/input' +import { brandIconUrl, BRAND_ICON_PREFIX } from '@/utils/nodeIcons' +import dashboardIcons from '@/data/dashboardIcons.json' + +const SLUGS: string[] = dashboardIcons as string[] +const PAGE = 120 + +interface BrandIconPickerProps { + value?: string + onSelect: (key: string) => void +} + +export function BrandIconPicker({ value, onSelect }: BrandIconPickerProps) { + const [query, setQuery] = useState('') + const [limit, setLimit] = useState(PAGE) + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase() + if (!q) return SLUGS + return SLUGS.filter((s) => s.includes(q)) + }, [query]) + + const visible = filtered.slice(0, limit) + const selectedSlug = value?.startsWith(BRAND_ICON_PREFIX) ? value.slice(BRAND_ICON_PREFIX.length) : null + + return ( +
+ { setQuery(e.target.value); setLimit(PAGE) }} + placeholder={`Search ${SLUGS.length} brand icons...`} + className="bg-[#0d1117] border-[#30363d] text-xs h-7" + aria-label="Brand icon search" + /> +
+ {filtered.length} match{filtered.length === 1 ? '' : 'es'} · icons served via jsDelivr CDN +
+
+
+ {visible.map((slug) => { + const selected = slug === selectedSlug + return ( + + ) + })} +
+ {filtered.length > limit && ( + + )} + {filtered.length === 0 && ( +
No icons match.
+ )} +
+
+ ) +} diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx index 0c5b2f2..20aaac1 100644 --- a/frontend/src/components/modals/CustomStyleModal.tsx +++ b/frontend/src/components/modals/CustomStyleModal.tsx @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle, Flame, + Radio, Zap, Lightbulb, type LucideIcon, } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' @@ -21,7 +22,8 @@ import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types' const EDITABLE_NODE_TYPES: NodeType[] = [ 'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', - 'docker_container', 'generic', + 'docker_container', 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice', + 'generic', ] const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] @@ -30,7 +32,9 @@ const NODE_ICONS: Record = { isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers, vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi, camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap, - docker_host: Anchor, docker_container: Package, generic: Circle, + docker_host: Anchor, docker_container: Package, + zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb, + generic: Circle, } // ── Default style for a node type (from default theme) ───────────────────── diff --git a/frontend/src/components/modals/GroupRectModal.module.css b/frontend/src/components/modals/GroupRectModal.module.css new file mode 100644 index 0000000..65194c0 --- /dev/null +++ b/frontend/src/components/modals/GroupRectModal.module.css @@ -0,0 +1,41 @@ +/* Slider container: strip native chrome so custom track/thumb align cleanly */ +.slider-thumb { + -webkit-appearance: none; + appearance: none; + background: transparent; + height: 13px; /* match thumb height so vertical centering is the input's box center */ +} +.slider-thumb:focus { + outline: none; +} + +/* Track */ +.slider-accent::-webkit-slider-runnable-track { + height: 4px; + background: #00d4ff; + border-radius: 2px; +} +.slider-accent::-moz-range-track { + height: 4px; + background: #00d4ff; + border-radius: 2px; +} + +/* Thumb — must offset on webkit so it centers on the 4px track */ +.slider-thumb::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 13px; + height: 13px; + border-radius: 50%; + background: #00d4ff; + border: 2px solid #21262d; + margin-top: -4.5px; /* (13 - 4) / 2 */ +} +.slider-thumb::-moz-range-thumb { + width: 13px; + height: 13px; + border-radius: 50%; + background: #00d4ff; + border: 2px solid #21262d; +} diff --git a/frontend/src/components/modals/GroupRectModal.tsx b/frontend/src/components/modals/GroupRectModal.tsx index 209b57b..051a936 100644 --- a/frontend/src/components/modals/GroupRectModal.tsx +++ b/frontend/src/components/modals/GroupRectModal.tsx @@ -7,6 +7,7 @@ import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import type { TextPosition } from '@/types' import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils' +import styles from './GroupRectModal.module.css' export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none' @@ -88,6 +89,8 @@ const TEXT_POSITIONS: { value: TextPosition; label: string }[] = [ { value: 'bottom-right', label: '↘' }, ] +const getFontLabel = (value: string) => FONTS.find((f) => f.value === value)?.label ?? value + interface GroupRectModalProps { open: boolean onClose: () => void @@ -138,8 +141,10 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
set('z_order', v !== null ? Number(v) : 1)}> - + diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index c1666ba..d5b851f 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -8,18 +8,22 @@ import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select' import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types' import { resolveNodeColors } from '@/utils/nodeColors' -import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons' +import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons' +import { BrandIconPicker } from './BrandIconPicker' import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils' +import { getValidParentTypes } from '@/utils/virtualEdgeParent' const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [ { label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] }, { label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] }, { label: 'IoT', types: ['iot', 'camera', 'cpl'] }, + { label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] }, { label: 'Generic', types: ['computer', 'generic', 'groupRect'] }, ] 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', @@ -45,22 +49,39 @@ const DEFAULT_DATA: Partial = { custom_icon: undefined, } +interface ParentCandidate { + id: string + label: string + type: NodeType +} + interface NodeModalProps { open: boolean onClose: () => void onSubmit: (data: Partial) => void initial?: Partial title?: string - parentContainerNodes?: { id: string; label: string; nodeType?: NodeType }[] + parentCandidates?: ParentCandidate[] + currentNodeId?: string } // 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', parentCandidates = [], currentNodeId }: 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') const [labelError, setLabelError] = useState(false) + const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors }) + const showServicesEnabled = form.custom_colors?.show_services === true + const hasAppearanceOverrides = Boolean( + form.custom_colors?.border + || form.custom_colors?.background + || form.custom_colors?.icon + ) const set = (key: keyof NodeData, value: unknown) => setForm((f) => ({ ...f, [key]: value })) @@ -74,20 +95,25 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' setLabelError(false) const selectedType = (form.type ?? 'generic') as NodeType const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType) + const validParentTypes = getValidParentTypes(selectedType) + let safeParentId = form.parent_id + if (validParentTypes.length === 0) { + safeParentId = undefined + } else if (safeParentId) { + const parent = parentCandidates.find((n) => n.id === safeParentId) + if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined + } onSubmit({ ...form, + parent_id: safeParentId, container_mode: canUseContainerMode ? !!form.container_mode : false, }) onClose() } - const filteredParentNodes = form.type === 'docker_container' - ? parentContainerNodes.filter((n) => n.nodeType === 'docker_host') - : parentContainerNodes - return ( !o && onClose()}> - + {title} @@ -97,7 +123,15 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' {/* Type + Icon on the same row */}
- { + const t = v as NodeType + setForm((f) => { + const next: Partial = { ...f, type: t } + if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod + if (getValidParentTypes(t).length === 0) next.parent_id = undefined + return next + }) + }}> {NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]} @@ -144,6 +178,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' > {(() => { + if (isBrandIconKey(form.custom_icon)) { + const slug = brandIconSlug(form.custom_icon!) + return <>{slug}{slug} + } const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon) if (entry) { return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}{entry.label} @@ -159,6 +197,37 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' {/* Inline icon picker - full width, shown below the type+icon row */} {iconPickerOpen && (
+
+ + +
+ {iconTab === 'brand' ? ( + { set('custom_icon', key); setIconPickerOpen(false) }} + /> + ) : ( + <> setIconSearch(e.target.value)} @@ -204,6 +273,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' ) })}
+ + )}
)} @@ -242,77 +313,121 @@ 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']}`} + /> +
+ )} + + {/* Parent Container */} + {(() => { + const childType = (form.type ?? 'generic') as NodeType + const validParentTypes = getValidParentTypes(childType) + if (validParentTypes.length === 0) return null + const validParents = parentCandidates.filter( + (n) => n.id !== currentNodeId && validParentTypes.includes(n.type), + ) + if (validParents.length === 0) return null + return ( +
+ + +
+ ) + })()} + {/* Container mode */} {CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
- Allow other nodes to nest inside this node + + Allow other nodes to nest inside this node + +
+ + +
+ )} + + {/* Service visibility */} + {form.type !== 'groupRect' && form.type !== 'group' && ( +
+
+ + Display discovered services on the node card
@@ -322,10 +437,20 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
- {form.custom_colors && ( + {hasAppearanceOverrides && (
- {!form.custom_colors && ( -

Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.

- )} +
+ {!hasAppearanceOverrides && ( +

Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.

+ )} +
{/* Bottom connection points (not for group containers) */} 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/TextModal.tsx b/frontend/src/components/modals/TextModal.tsx new file mode 100644 index 0000000..3e744c6 --- /dev/null +++ b/frontend/src/components/modals/TextModal.tsx @@ -0,0 +1,272 @@ +import { useState } from 'react' +import modalStyles from './modal-interactive.module.css' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils' + +export type TextBorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none' + +export interface TextFormData { + text: string + font: string + text_color: string + text_size: number + border_color: string + border_style: TextBorderStyle + border_width: number + background_color: string +} + +const BORDER_STYLES: { value: TextBorderStyle; label: string; preview: string }[] = [ + { value: 'none', label: 'None', preview: ' ' }, + { value: 'solid', label: 'Solid', preview: '───' }, + { value: 'dashed', label: 'Dashed', preview: '╌╌╌' }, + { value: 'dotted', label: 'Dotted', preview: '···' }, + { value: 'double', label: 'Double', preview: '═══' }, +] + +const TEXT_SIZES: { value: number; label: string }[] = [ + { value: 10, label: '10' }, + { value: 12, label: '12' }, + { value: 14, label: '14' }, + { value: 18, label: '18' }, + { value: 24, label: '24' }, + { value: 32, label: '32' }, +] + +const BORDER_WIDTHS: { value: number; label: string }[] = [ + { value: 1, label: '1px' }, + { value: 2, label: '2px' }, + { value: 3, label: '3px' }, + { value: 4, label: '4px' }, + { value: 5, label: '5px' }, +] + +const FONTS = [ + { value: 'inter', label: 'Inter (sans-serif)' }, + { value: 'mono', label: 'JetBrains Mono' }, + { value: 'serif', label: 'Serif' }, + { value: 'sans', label: 'System Sans' }, +] + +const DEFAULT_FORM: TextFormData = { + text: '', + font: 'inter', + text_color: '#e6edf3', + text_size: 14, + border_color: '#30363d', + border_style: 'none', + border_width: 1, + background_color: '#00000000', +} + +interface TextModalProps { + open: boolean + onClose: () => void + onSubmit: (data: TextFormData) => void + onDelete?: () => void + initial?: Partial + title?: string +} + +export function TextModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Text' }: TextModalProps) { + const [form, setForm] = useState({ ...DEFAULT_FORM, ...initial }) + + const set = (key: K, value: TextFormData[K]) => + setForm((f) => ({ ...f, [key]: value })) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + onSubmit(form) + onClose() + } + + const colorFields = [ + { key: 'text_color' as const, label: 'Text' }, + { key: 'border_color' as const, label: 'Border' }, + { key: 'background_color' as const, label: 'Background' }, + ] + + return ( + !o && onClose()}> + + + {title} + + +
+ {/* Text content */} +
+ +