Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f78bac6e6b | |||
| a0bfb97517 | |||
| ff9c4735e0 | |||
| 2f3e986d4a | |||
| 0a4bac30ac | |||
| ba2f6564ec | |||
| 754a79d21f | |||
| b52bbc6d9f | |||
| c67b1775a5 | |||
| 5940e11860 | |||
| fa8f949b45 | |||
| 6e17a2afc6 | |||
| 2375efa6b7 | |||
| 61fbebb13e | |||
| 05fb67e7f0 | |||
| 1a426f6301 | |||
| f56dfea838 | |||
| 6784b8f6be | |||
| 01fce64b2c | |||
| 79093fdcfb | |||
| 4ce8a12720 | |||
| cc2cc72482 | |||
| 73b479a408 | |||
| 16183285b5 | |||
| 3b3cc1eba0 | |||
| 3da5517996 | |||
| cb26da3de5 | |||
| 23a0a47a7f | |||
| aac6c09a04 | |||
| bf90d6312b | |||
| ec15c260e1 | |||
| 47ab3a9a76 | |||
| e8bcf04b46 |
@@ -2,9 +2,11 @@ import hmac
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import CanvasState, Design, Edge, Node
|
from app.db.models import CanvasState, Design, Edge, Node
|
||||||
@@ -15,6 +17,26 @@ from app.schemas.nodes import NodeResponse
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class LiveViewConfigResponse(BaseModel):
|
||||||
|
"""Whether live view is enabled, plus the key (admin-only) to build share links."""
|
||||||
|
|
||||||
|
enabled: bool
|
||||||
|
key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_model=LiveViewConfigResponse)
|
||||||
|
async def liveview_config(
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> LiveViewConfigResponse:
|
||||||
|
"""Authenticated: expose the configured live view key so the UI can build a
|
||||||
|
ready-to-use share link (e.g. /view?key=...&design=<id>).
|
||||||
|
|
||||||
|
Only reachable by a logged-in user — the key is never exposed publicly.
|
||||||
|
"""
|
||||||
|
key = settings.liveview_key or None
|
||||||
|
return LiveViewConfigResponse(enabled=bool(key), key=key)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=CanvasStateResponse)
|
@router.get("", response_model=CanvasStateResponse)
|
||||||
async def liveview_canvas(
|
async def liveview_canvas(
|
||||||
key: str | None = Query(default=None),
|
key: str | None = Query(default=None),
|
||||||
|
|||||||
@@ -20,6 +20,34 @@ from app.services.zigbee_service import build_zigbee_properties
|
|||||||
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
||||||
|
|
||||||
|
|
||||||
|
def build_mac_property(mac: str | None) -> list[dict[str, Any]]:
|
||||||
|
"""Build a NodeProperty list carrying a device MAC address.
|
||||||
|
|
||||||
|
Shape matches the frontend ``NodeProperty`` type
|
||||||
|
(``{key, value, icon, visible}``). Hidden by default — the user opts in to
|
||||||
|
showing it on the canvas card from the right panel. Returns an empty list
|
||||||
|
when no MAC is known.
|
||||||
|
"""
|
||||||
|
if not mac:
|
||||||
|
return []
|
||||||
|
return [{"key": "MAC", "value": mac, "icon": None, "visible": False}]
|
||||||
|
|
||||||
|
|
||||||
|
def merge_mac_property(
|
||||||
|
props: list[dict[str, Any]] | None, mac: str | None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Append a MAC NodeProperty to ``props`` unless one is already present.
|
||||||
|
|
||||||
|
Preserves any user-supplied properties (and an existing MAC row's
|
||||||
|
visibility) untouched. Used on approve so the scanned MAC is not lost.
|
||||||
|
"""
|
||||||
|
out = [dict(p) for p in (props or [])]
|
||||||
|
if not mac or any(p.get("key") == "MAC" for p in out):
|
||||||
|
return out
|
||||||
|
out.append({"key": "MAC", "value": mac, "icon": None, "visible": False})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
class BulkActionRequest(BaseModel):
|
class BulkActionRequest(BaseModel):
|
||||||
device_ids: list[str]
|
device_ids: list[str]
|
||||||
|
|
||||||
@@ -138,13 +166,14 @@ async def bulk_approve_devices(
|
|||||||
label=device.hostname or device.friendly_name or device.ip or "device",
|
label=device.hostname or device.friendly_name or device.ip or "device",
|
||||||
type=node_type,
|
type=node_type,
|
||||||
ip=device.ip,
|
ip=device.ip,
|
||||||
|
mac=device.mac,
|
||||||
hostname=device.hostname,
|
hostname=device.hostname,
|
||||||
status="online" if is_zigbee else "unknown",
|
status="online" if is_zigbee else "unknown",
|
||||||
services=device.services or [],
|
services=device.services or [],
|
||||||
ieee_address=device.ieee_address,
|
ieee_address=device.ieee_address,
|
||||||
properties=build_zigbee_properties(
|
properties=build_zigbee_properties(
|
||||||
device.ieee_address, device.vendor, device.model, device.lqi
|
device.ieee_address, device.vendor, device.model, device.lqi
|
||||||
) if is_zigbee else [],
|
) if is_zigbee else build_mac_property(device.mac),
|
||||||
# Default to ping so the status checker actually polls the new node.
|
# Default to ping so the status checker actually polls the new node.
|
||||||
# Without this the scheduler skips it (check_method NULL → no check).
|
# Without this the scheduler skips it (check_method NULL → no check).
|
||||||
check_method="none" if is_zigbee else ("ping" if device.ip else None),
|
check_method="none" if is_zigbee else ("ping" if device.ip else None),
|
||||||
@@ -245,17 +274,21 @@ async def approve_device(
|
|||||||
raise HTTPException(status_code=409, detail="Device already processed")
|
raise HTTPException(status_code=409, detail="Device already processed")
|
||||||
device.status = "approved"
|
device.status = "approved"
|
||||||
_is_zigbee = node_data.type in _ZIGBEE_TYPES
|
_is_zigbee = node_data.type in _ZIGBEE_TYPES
|
||||||
|
# Prefer the MAC discovered during the scan (stored on the pending device);
|
||||||
|
# fall back to whatever the approve payload carried.
|
||||||
|
_mac = device.mac or node_data.mac
|
||||||
node = Node(
|
node = Node(
|
||||||
label=node_data.label,
|
label=node_data.label,
|
||||||
type=node_data.type,
|
type=node_data.type,
|
||||||
ip=node_data.ip,
|
ip=node_data.ip,
|
||||||
|
mac=_mac,
|
||||||
hostname=node_data.hostname,
|
hostname=node_data.hostname,
|
||||||
status="online" if _is_zigbee else node_data.status,
|
status="online" if _is_zigbee else node_data.status,
|
||||||
services=node_data.services or [],
|
services=node_data.services or [],
|
||||||
ieee_address=device.ieee_address,
|
ieee_address=device.ieee_address,
|
||||||
properties=build_zigbee_properties(
|
properties=build_zigbee_properties(
|
||||||
device.ieee_address, device.vendor, device.model, device.lqi
|
device.ieee_address, device.vendor, device.model, device.lqi
|
||||||
) if _is_zigbee else (node_data.properties or []),
|
) if _is_zigbee else merge_mac_property(node_data.properties, _mac),
|
||||||
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
|
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,
|
check_target=None if _is_zigbee else node_data.check_target,
|
||||||
design_id=node_design_id,
|
design_id=node_design_id,
|
||||||
|
|||||||
@@ -228,7 +228,13 @@ async def _persist_pending_import(
|
|||||||
pending.vendor = n.get("vendor") or pending.vendor
|
pending.vendor = n.get("vendor") or pending.vendor
|
||||||
if n.get("lqi") is not None:
|
if n.get("lqi") is not None:
|
||||||
pending.lqi = n.get("lqi")
|
pending.lqi = n.get("lqi")
|
||||||
if pending.status == "hidden":
|
if pending.status == "approved":
|
||||||
|
# The device was approved earlier but its canvas Node no longer
|
||||||
|
# exists (no Node matched the IEEE above) — it was deleted. Revive
|
||||||
|
# the row to "pending" so it reappears in the Pending list on
|
||||||
|
# re-import instead of being silently swallowed. (Issue #167)
|
||||||
|
pending.status = "pending"
|
||||||
|
elif pending.status == "hidden":
|
||||||
# Re-imported a hidden device → leave it hidden, just refresh fields.
|
# Re-imported a hidden device → leave it hidden, just refresh fields.
|
||||||
pass
|
pass
|
||||||
pending_updated += 1
|
pending_updated += 1
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ async def init_db() -> None:
|
|||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_port_numbers BOOLEAN NOT NULL DEFAULT 0")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class Node(Base):
|
|||||||
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
show_port_numbers: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||||
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class NodeSave(BaseModel):
|
|||||||
ram_gb: float | None = None
|
ram_gb: float | None = None
|
||||||
disk_gb: float | None = None
|
disk_gb: float | None = None
|
||||||
show_hardware: bool = False
|
show_hardware: bool = False
|
||||||
|
show_port_numbers: bool = False
|
||||||
properties: list[Any] = []
|
properties: list[Any] = []
|
||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class NodeBase(BaseModel):
|
|||||||
ram_gb: float | None = None
|
ram_gb: float | None = None
|
||||||
disk_gb: float | None = None
|
disk_gb: float | None = None
|
||||||
show_hardware: bool = False
|
show_hardware: bool = False
|
||||||
|
show_port_numbers: bool = False
|
||||||
properties: list[dict[str, Any]] = []
|
properties: list[dict[str, Any]] = []
|
||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
@@ -60,6 +61,7 @@ class NodeUpdate(BaseModel):
|
|||||||
ram_gb: float | None = None
|
ram_gb: float | None = None
|
||||||
disk_gb: float | None = None
|
disk_gb: float | None = None
|
||||||
show_hardware: bool | None = None
|
show_hardware: bool | None = None
|
||||||
|
show_port_numbers: bool | None = None
|
||||||
properties: list[dict[str, Any]] | None = None
|
properties: list[dict[str, Any]] | None = None
|
||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
|
|||||||
@@ -199,6 +199,24 @@ async def test_save_canvas_show_hardware_defaults_false(client: AsyncClient, hea
|
|||||||
assert canvas["nodes"][0]["show_hardware"] is False
|
assert canvas["nodes"][0]["show_hardware"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# Regression (#184): show_port_numbers was dropped by the save schema, so the
|
||||||
|
# toggle reset on every reload.
|
||||||
|
async def test_save_canvas_persists_show_port_numbers(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(show_port_numbers=True)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["show_port_numbers"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_show_port_numbers_defaults_false(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["show_port_numbers"] is False
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
|
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
|
||||||
n1 = node_payload(cpu_count=8, ram_gb=32.0)
|
n1 = node_payload(cpu_count=8, ram_gb=32.0)
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|||||||
@@ -146,3 +146,84 @@ async def test_liveview_disabled_after_key_cleared(client: AsyncClient):
|
|||||||
res = await client.get("/api/v1/liveview?key=was-enabled")
|
res = await client.get("/api/v1/liveview?key=was-enabled")
|
||||||
assert res.status_code == 403
|
assert res.status_code == 403
|
||||||
assert res.json()["detail"] == "Live view is disabled"
|
assert res.json()["detail"] == "Live view is disabled"
|
||||||
|
|
||||||
|
|
||||||
|
# ── /config (authenticated) — key used to build share links ──────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_config_requires_auth(client: AsyncClient):
|
||||||
|
"""The config endpoint exposes the key, so it must reject unauthenticated calls."""
|
||||||
|
settings.liveview_key = "secret"
|
||||||
|
res = await client.get("/api/v1/liveview/config")
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_config_returns_key_when_enabled(client: AsyncClient, auth_headers):
|
||||||
|
settings.liveview_key = "share-me"
|
||||||
|
headers = await auth_headers()
|
||||||
|
res = await client.get("/api/v1/liveview/config", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert body == {"enabled": True, "key": "share-me"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_config_disabled_hides_key(client: AsyncClient, auth_headers):
|
||||||
|
settings.liveview_key = None
|
||||||
|
headers = await auth_headers()
|
||||||
|
res = await client.get("/api/v1/liveview/config", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json() == {"enabled": False, "key": None}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_config_empty_key_disabled(client: AsyncClient, auth_headers):
|
||||||
|
settings.liveview_key = ""
|
||||||
|
headers = await auth_headers()
|
||||||
|
res = await client.get("/api/v1/liveview/config", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json() == {"enabled": False, "key": None}
|
||||||
|
|
||||||
|
|
||||||
|
# ── design_id selects which canvas is rendered ───────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_design_id_selects_canvas(client: AsyncClient, auth_headers):
|
||||||
|
"""?design_id=<id> renders that design's canvas, not the first one."""
|
||||||
|
settings.liveview_key = "test-key"
|
||||||
|
headers = await auth_headers()
|
||||||
|
|
||||||
|
# Create two designs
|
||||||
|
d1 = (await client.post("/api/v1/designs", json={"name": "Network"}, headers=headers)).json()
|
||||||
|
d2 = (await client.post("/api/v1/designs", json={"name": "Electrical"}, headers=headers)).json()
|
||||||
|
|
||||||
|
# Save a distinct node into each design
|
||||||
|
for design, node_id, label in ((d1, "n-net", "Net Node"), (d2, "n-elec", "Elec Node")):
|
||||||
|
payload = {
|
||||||
|
"nodes": [{
|
||||||
|
"id": node_id,
|
||||||
|
"type": "server",
|
||||||
|
"label": label,
|
||||||
|
"status": "online",
|
||||||
|
"services": [],
|
||||||
|
"pos_x": 0,
|
||||||
|
"pos_y": 0,
|
||||||
|
}],
|
||||||
|
"edges": [],
|
||||||
|
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||||
|
"design_id": design["id"],
|
||||||
|
}
|
||||||
|
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||||
|
|
||||||
|
# Requesting d2 returns only the electrical node
|
||||||
|
res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d2['id']}")
|
||||||
|
assert res.status_code == 200
|
||||||
|
nodes = res.json()["nodes"]
|
||||||
|
assert [n["id"] for n in nodes] == ["n-elec"]
|
||||||
|
|
||||||
|
# Requesting d1 returns only the network node
|
||||||
|
res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d1['id']}")
|
||||||
|
assert res.status_code == 200
|
||||||
|
nodes = res.json()["nodes"]
|
||||||
|
assert [n["id"] for n in nodes] == ["n-net"]
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Backward-compatibility tests for the legacy → multi-design migration.
|
||||||
|
|
||||||
|
Simulates a database created by a pre-"designs" version of the app and asserts
|
||||||
|
that running init_db() adopts all existing nodes/edges/canvas into a single
|
||||||
|
default "Network Topology" design with no data loss. The rest of the test suite
|
||||||
|
builds the *current* schema via create_all and never exercises this upgrade
|
||||||
|
path, so this file guards real users upgrading in place.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
import app.db.database as database
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def legacy_engine(tmp_path, monkeypatch):
|
||||||
|
"""Point the module-global engine + sqlite_path at a throwaway legacy DB."""
|
||||||
|
db_path = tmp_path / "legacy.db"
|
||||||
|
monkeypatch.setattr(database.settings, "sqlite_path", str(db_path))
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||||
|
monkeypatch.setattr(database, "engine", engine)
|
||||||
|
return db_path, engine
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_legacy_schema(engine) -> None:
|
||||||
|
"""Create the pre-designs schema (no design_id, integer canvas_state PK)."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"CREATE TABLE nodes (id VARCHAR PRIMARY KEY, type VARCHAR, label VARCHAR, "
|
||||||
|
"status VARCHAR, services JSON, pos_x FLOAT, pos_y FLOAT)"
|
||||||
|
)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"CREATE TABLE edges (id VARCHAR PRIMARY KEY, source VARCHAR, target VARCHAR, type VARCHAR)"
|
||||||
|
)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"CREATE TABLE canvas_state (id INTEGER PRIMARY KEY, viewport JSON, "
|
||||||
|
"custom_style JSON, saved_at DATETIME)"
|
||||||
|
)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO nodes (id, type, label, status, services, pos_x, pos_y) "
|
||||||
|
"VALUES ('n1','server','Old Server','online','[]',10,20)"
|
||||||
|
)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO nodes (id, type, label, status, services, pos_x, pos_y) "
|
||||||
|
"VALUES ('n2','router','Old Router','offline','[]',30,40)"
|
||||||
|
)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO edges (id, source, target, type) VALUES ('e1','n1','n2','ethernet')"
|
||||||
|
)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO canvas_state (id, viewport, custom_style, saved_at) "
|
||||||
|
"VALUES (1, '{\"x\":5,\"y\":6,\"zoom\":2}', NULL, '2024-01-01 00:00:00')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_legacy_canvas_migrates_into_default_design(legacy_engine):
|
||||||
|
db_path, engine = legacy_engine
|
||||||
|
await _build_legacy_schema(engine)
|
||||||
|
|
||||||
|
await database.init_db()
|
||||||
|
|
||||||
|
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||||
|
try:
|
||||||
|
async with check.begin() as conn:
|
||||||
|
# Exactly one seeded default design.
|
||||||
|
designs = (await conn.exec_driver_sql(
|
||||||
|
"SELECT id, name, design_type, icon FROM designs"
|
||||||
|
)).fetchall()
|
||||||
|
assert len(designs) == 1
|
||||||
|
did, name, dtype, icon = designs[0]
|
||||||
|
assert name == "Network Topology"
|
||||||
|
assert dtype == "network"
|
||||||
|
assert icon == "dashboard"
|
||||||
|
|
||||||
|
# Every legacy node adopted into the default design, data preserved.
|
||||||
|
nodes = (await conn.exec_driver_sql(
|
||||||
|
"SELECT id, label, status, design_id FROM nodes ORDER BY id"
|
||||||
|
)).fetchall()
|
||||||
|
assert [(n[0], n[1], n[2]) for n in nodes] == [
|
||||||
|
("n1", "Old Server", "online"),
|
||||||
|
("n2", "Old Router", "offline"),
|
||||||
|
]
|
||||||
|
assert all(n[3] == did for n in nodes)
|
||||||
|
|
||||||
|
# Legacy edge adopted too.
|
||||||
|
edge = (await conn.exec_driver_sql(
|
||||||
|
"SELECT design_id FROM edges WHERE id='e1'"
|
||||||
|
)).fetchone()
|
||||||
|
assert edge[0] == did
|
||||||
|
|
||||||
|
# canvas_state rebuilt with design_id PK; the old id=1 row maps to the
|
||||||
|
# default design and the viewport survives.
|
||||||
|
cs = (await conn.exec_driver_sql(
|
||||||
|
"SELECT design_id, viewport FROM canvas_state"
|
||||||
|
)).fetchall()
|
||||||
|
assert len(cs) == 1
|
||||||
|
assert cs[0][0] == did
|
||||||
|
assert "zoom" in (cs[0][1] or "")
|
||||||
|
finally:
|
||||||
|
await check.dispose()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_migration_is_idempotent(legacy_engine):
|
||||||
|
"""Running init_db twice must not duplicate the design or drop any data."""
|
||||||
|
db_path, engine = legacy_engine
|
||||||
|
await _build_legacy_schema(engine)
|
||||||
|
|
||||||
|
await database.init_db()
|
||||||
|
await database.init_db() # second boot — should be a no-op
|
||||||
|
|
||||||
|
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||||
|
try:
|
||||||
|
async with check.begin() as conn:
|
||||||
|
designs = (await conn.exec_driver_sql("SELECT id FROM designs")).fetchall()
|
||||||
|
assert len(designs) == 1
|
||||||
|
did = designs[0][0]
|
||||||
|
|
||||||
|
nodes = (await conn.exec_driver_sql(
|
||||||
|
"SELECT design_id FROM nodes"
|
||||||
|
)).fetchall()
|
||||||
|
assert len(nodes) == 2
|
||||||
|
assert all(n[0] == did for n in nodes)
|
||||||
|
|
||||||
|
cs = (await conn.exec_driver_sql("SELECT design_id FROM canvas_state")).fetchall()
|
||||||
|
assert len(cs) == 1
|
||||||
|
assert cs[0][0] == did
|
||||||
|
finally:
|
||||||
|
await check.dispose()
|
||||||
|
await engine.dispose()
|
||||||
@@ -698,6 +698,144 @@ async def test_bulk_approve_zigbee_populates_properties(
|
|||||||
assert node.check_method == "none"
|
assert node.check_method == "none"
|
||||||
|
|
||||||
|
|
||||||
|
# --- MAC address propagation on approve (issue #168) ---
|
||||||
|
|
||||||
|
def test_build_mac_property_returns_hidden_row():
|
||||||
|
from app.api.routes.scan import build_mac_property
|
||||||
|
|
||||||
|
assert build_mac_property("aa:bb:cc:dd:ee:ff") == [
|
||||||
|
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_mac_property_empty_when_no_mac():
|
||||||
|
from app.api.routes.scan import build_mac_property
|
||||||
|
|
||||||
|
assert build_mac_property(None) == []
|
||||||
|
assert build_mac_property("") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_mac_property_appends_when_absent():
|
||||||
|
from app.api.routes.scan import merge_mac_property
|
||||||
|
|
||||||
|
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
|
||||||
|
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
|
||||||
|
assert {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} in merged
|
||||||
|
# Existing prop preserved untouched.
|
||||||
|
assert existing[0] in merged
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_mac_property_idempotent_and_preserves_visibility():
|
||||||
|
from app.api.routes.scan import merge_mac_property
|
||||||
|
|
||||||
|
existing = [{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}]
|
||||||
|
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
|
||||||
|
# No duplicate MAC row; user's visible=True choice kept.
|
||||||
|
macs = [p for p in merged if p["key"] == "MAC"]
|
||||||
|
assert len(macs) == 1
|
||||||
|
assert macs[0]["visible"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_mac_property_noop_without_mac():
|
||||||
|
from app.api.routes.scan import merge_mac_property
|
||||||
|
|
||||||
|
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
|
||||||
|
assert merge_mac_property(existing, None) == existing
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_device_copies_mac_to_node_and_properties(
|
||||||
|
client: AsyncClient, headers, pending_device, db_session
|
||||||
|
):
|
||||||
|
"""Approving a scanned device must carry its MAC onto the node + properties."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.models import Node as NodeModel
|
||||||
|
# Payload intentionally omits mac — it must come from the pending device.
|
||||||
|
res = await client.post(
|
||||||
|
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||||
|
json={"label": "My Server", "type": "server", "ip": "192.168.1.100", "status": "unknown", "services": []},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
node = (
|
||||||
|
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
|
||||||
|
).scalar_one()
|
||||||
|
assert node.mac == "aa:bb:cc:dd:ee:ff"
|
||||||
|
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
||||||
|
assert mac_props == [
|
||||||
|
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_device_does_not_duplicate_mac_property(
|
||||||
|
client: AsyncClient, headers, pending_device, db_session
|
||||||
|
):
|
||||||
|
"""If the approve payload already carries a MAC prop, don't add a second one."""
|
||||||
|
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": "My Server",
|
||||||
|
"type": "server",
|
||||||
|
"ip": "192.168.1.100",
|
||||||
|
"status": "unknown",
|
||||||
|
"services": [],
|
||||||
|
"properties": [
|
||||||
|
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
node = (
|
||||||
|
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
|
||||||
|
).scalar_one()
|
||||||
|
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
||||||
|
assert len(mac_props) == 1
|
||||||
|
# User's visibility choice is preserved.
|
||||||
|
assert mac_props[0]["visible"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_approve_copies_mac_to_node_and_properties(
|
||||||
|
client: AsyncClient, headers, db_session
|
||||||
|
):
|
||||||
|
"""Bulk approve must also propagate the scanned MAC to node + properties."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.models import Node as NodeModel
|
||||||
|
device = PendingDevice(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
ip="192.168.1.55",
|
||||||
|
mac="11:22:33:44:55:66",
|
||||||
|
hostname="host-mac",
|
||||||
|
services=[],
|
||||||
|
suggested_type="generic",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db_session.add(device)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/scan/pending/bulk-approve",
|
||||||
|
json={"device_ids": [device.id]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
node = (
|
||||||
|
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.55"))
|
||||||
|
).scalar_one()
|
||||||
|
assert node.mac == "11:22:33:44:55:66"
|
||||||
|
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
||||||
|
assert mac_props == [
|
||||||
|
{"key": "MAC", "value": "11:22:33:44:55:66", "icon": None, "visible": False}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
|
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."""
|
"""Approved devices with an IP must default to ping; otherwise scheduler skips them."""
|
||||||
|
|||||||
@@ -459,6 +459,92 @@ async def test_persist_pending_import_skips_pending_for_approved_node(
|
|||||||
assert all(p["visible"] is False for p in refreshed.properties)
|
assert all(p["visible"] is False for p in refreshed.properties)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_revives_orphaned_approved_device(
|
||||||
|
db_session,
|
||||||
|
) -> None:
|
||||||
|
"""Regression for #167: approve → delete node → re-import must re-list device.
|
||||||
|
|
||||||
|
When a device was approved (PendingDevice.status="approved") and its canvas
|
||||||
|
Node was later deleted, the orphaned "approved" row must be reset to
|
||||||
|
"pending" on re-import so it shows up in the Pending list again — instead of
|
||||||
|
being silently swallowed (re-import reports "found" but Pending stays empty).
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
from app.db.models import PendingDevice
|
||||||
|
|
||||||
|
# Simulate prior approve: a PendingDevice marked approved, but NO matching
|
||||||
|
# Node exists (the user deleted the canvas node afterwards).
|
||||||
|
orphan = PendingDevice(
|
||||||
|
ieee_address="0xR1",
|
||||||
|
friendly_name="router_1",
|
||||||
|
hostname="router_1",
|
||||||
|
suggested_type="zigbee_router",
|
||||||
|
device_subtype="Router",
|
||||||
|
model="CC2530",
|
||||||
|
vendor="TI",
|
||||||
|
lqi=220,
|
||||||
|
status="approved",
|
||||||
|
discovery_source="zigbee",
|
||||||
|
)
|
||||||
|
db_session.add(orphan)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
|
||||||
|
# No new row created for 0xR1 — the existing one was updated/revived.
|
||||||
|
revived = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert revived.status == "pending"
|
||||||
|
# End device 0xE1 is brand new → created as pending; router was updated.
|
||||||
|
assert result.pending_created == 1
|
||||||
|
assert result.pending_updated == 1
|
||||||
|
|
||||||
|
# It is now visible to the Pending list (status filter == "pending").
|
||||||
|
listed = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.status == "pending")
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
assert {p.ieee_address for p in listed} == {"0xR1", "0xE1"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_keeps_hidden_hidden_on_reimport(
|
||||||
|
db_session,
|
||||||
|
) -> None:
|
||||||
|
"""A user-hidden device must stay hidden on re-import (not revived like #167)."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
from app.db.models import PendingDevice
|
||||||
|
|
||||||
|
hidden = PendingDevice(
|
||||||
|
ieee_address="0xR1",
|
||||||
|
friendly_name="router_1",
|
||||||
|
suggested_type="zigbee_router",
|
||||||
|
device_subtype="Router",
|
||||||
|
status="hidden",
|
||||||
|
discovery_source="zigbee",
|
||||||
|
)
|
||||||
|
db_session.add(hidden)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
|
||||||
|
still_hidden = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert still_hidden.status == "hidden"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_persist_pending_import_preserves_user_visibility(db_session) -> None:
|
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."""
|
"""If user has already made props visible, re-import must not flip them back."""
|
||||||
|
|||||||
Generated
+14
-14
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.2.0",
|
"version": "2.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.2.0",
|
"version": "2.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
@@ -3237,9 +3237,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
|
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
@@ -3651,9 +3651,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -4250,9 +4250,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -8040,9 +8040,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.2.0",
|
"version": "2.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+34
-8
@@ -20,6 +20,7 @@ import { LoginPage } from '@/components/LoginPage'
|
|||||||
import { NodeModal } from '@/components/modals/NodeModal'
|
import { NodeModal } from '@/components/modals/NodeModal'
|
||||||
import { EdgeModal } from '@/components/modals/EdgeModal'
|
import { EdgeModal } from '@/components/modals/EdgeModal'
|
||||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||||
|
import { SettingsModal } from '@/components/modals/SettingsModal'
|
||||||
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
||||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||||
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
||||||
@@ -31,7 +32,7 @@ import { useCanvasStore } from '@/stores/canvasStore'
|
|||||||
import { useDesignStore } from '@/stores/designStore'
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { canvasApi, designsApi } from '@/api/client'
|
import { canvasApi, designsApi, liveviewApi } from '@/api/client'
|
||||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||||
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
||||||
@@ -41,7 +42,7 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
|||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
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 { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo } = useCanvasStore()
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||||
@@ -69,6 +70,7 @@ export default function App() {
|
|||||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||||
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||||
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
||||||
|
|
||||||
@@ -211,12 +213,8 @@ export default function App() {
|
|||||||
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
||||||
const undoRef = useRef(undo)
|
const undoRef = useRef(undo)
|
||||||
const redoRef = useRef(redo)
|
const redoRef = useRef(redo)
|
||||||
const copyRef = useRef(copySelectedNodes)
|
|
||||||
const pasteRef = useRef(pasteNodes)
|
|
||||||
useEffect(() => { undoRef.current = undo }, [undo])
|
useEffect(() => { undoRef.current = undo }, [undo])
|
||||||
useEffect(() => { redoRef.current = redo }, [redo])
|
useEffect(() => { redoRef.current = redo }, [redo])
|
||||||
useEffect(() => { copyRef.current = copySelectedNodes }, [copySelectedNodes])
|
|
||||||
useEffect(() => { pasteRef.current = pasteNodes }, [pasteNodes])
|
|
||||||
|
|
||||||
// Global keyboard shortcuts
|
// Global keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -230,8 +228,8 @@ export default function App() {
|
|||||||
if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return }
|
if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return }
|
||||||
if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return }
|
if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return }
|
||||||
if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return }
|
if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return }
|
||||||
if (ctrl && e.key === 'c' && !isInput) { copyRef.current(); return }
|
// Copy/paste (Ctrl/Cmd+C/V) handled in CanvasContainer so paste can place
|
||||||
if (ctrl && e.key === 'v' && !isInput) { pasteRef.current(); return }
|
// nodes under the cursor / viewport center.
|
||||||
if (e.key === '?' && !isInput) { setShortcutsOpen(true); return }
|
if (e.key === '?' && !isInput) { setShortcutsOpen(true); return }
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
@@ -457,6 +455,28 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
|
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
|
||||||
|
|
||||||
|
// Open the read-only live view of the currently active design in a new tab.
|
||||||
|
// Standalone has no backend/key — it reads localStorage, so just open /view.
|
||||||
|
// Otherwise fetch the configured live view key and build /view?key=...&design=<id>.
|
||||||
|
const handleViewOnly = useCallback(async () => {
|
||||||
|
if (STANDALONE) {
|
||||||
|
window.open('/view', '_blank', 'noopener,noreferrer')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await liveviewApi.getConfig()
|
||||||
|
if (!res.data.enabled || !res.data.key) {
|
||||||
|
toast.error('Live view is disabled — set LIVEVIEW_KEY in the backend .env')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({ key: res.data.key })
|
||||||
|
if (activeDesignId) params.set('design', activeDesignId)
|
||||||
|
window.open(`/view?${params.toString()}`, '_blank', 'noopener,noreferrer')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to open live view')
|
||||||
|
}
|
||||||
|
}, [activeDesignId])
|
||||||
|
|
||||||
const handleExport = useCallback(() => {
|
const handleExport = useCallback(() => {
|
||||||
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
||||||
if (!el) { toast.error('Canvas not ready'); return }
|
if (!el) { toast.error('Canvas not ready'); return }
|
||||||
@@ -586,6 +606,7 @@ export default function App() {
|
|||||||
onScan={() => setScanConfigOpen(true)}
|
onScan={() => setScanConfigOpen(true)}
|
||||||
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
forceView={sidebarForceView}
|
forceView={sidebarForceView}
|
||||||
onOpenPending={openPendingModal}
|
onOpenPending={openPendingModal}
|
||||||
/>
|
/>
|
||||||
@@ -601,6 +622,7 @@ export default function App() {
|
|||||||
onExportMd={handleExportMd}
|
onExportMd={handleExportMd}
|
||||||
onExportYaml={handleExportYaml}
|
onExportYaml={handleExportYaml}
|
||||||
onImportYaml={handleImportYaml}
|
onImportYaml={handleImportYaml}
|
||||||
|
onViewOnly={handleViewOnly}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||||
@@ -782,6 +804,10 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||||
|
|
||||||
|
{!STANDALONE && (
|
||||||
|
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
<PendingDevicesModal
|
<PendingDevicesModal
|
||||||
open={pendingModalOpen}
|
open={pendingModalOpen}
|
||||||
onClose={() => setPendingModalOpen(false)}
|
onClose={() => setPendingModalOpen(false)}
|
||||||
|
|||||||
@@ -158,6 +158,16 @@ describe('api/client', () => {
|
|||||||
expect(api.get).not.toHaveBeenCalled()
|
expect(api.get).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('liveviewApi.load forwards design as design_id when provided', () => {
|
||||||
|
mod.liveviewApi.load('k-1', 'design-9')
|
||||||
|
expect(publicApi.get).toHaveBeenCalledWith('/liveview', { params: { key: 'k-1', design_id: 'design-9' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liveviewApi.getConfig hits the authenticated config endpoint', () => {
|
||||||
|
mod.liveviewApi.getConfig()
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/liveview/config')
|
||||||
|
})
|
||||||
|
|
||||||
it('scanApi endpoints route correctly', () => {
|
it('scanApi endpoints route correctly', () => {
|
||||||
mod.scanApi.trigger()
|
mod.scanApi.trigger()
|
||||||
expect(api.post).toHaveBeenCalledWith('/scan/trigger')
|
expect(api.post).toHaveBeenCalledWith('/scan/trigger')
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ export const edgesApi = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const liveviewApi = {
|
export const liveviewApi = {
|
||||||
load: (key: string) => publicApi.get('/liveview', { params: { key } }),
|
load: (key: string, design?: string) =>
|
||||||
|
publicApi.get('/liveview', { params: { key, ...(design ? { design_id: design } : {}) } }),
|
||||||
|
getConfig: () => api.get<{ enabled: boolean; key: string | null }>('/liveview/config'),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const scanApi = {
|
export const scanApi = {
|
||||||
|
|||||||
@@ -68,10 +68,14 @@ function LiveViewCanvas() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Already handled synchronously in useState initializer
|
// Already handled synchronously in useState initializer
|
||||||
const key = new URLSearchParams(window.location.search).get('key')
|
const search = new URLSearchParams(window.location.search)
|
||||||
|
const key = search.get('key')
|
||||||
if (!key) return
|
if (!key) return
|
||||||
|
// Optional ?design=<id> selects which canvas to render; backend falls back
|
||||||
|
// to the first design when omitted.
|
||||||
|
const design = search.get('design') ?? undefined
|
||||||
|
|
||||||
liveviewApi.load(key)
|
liveviewApi.load(key, design)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||||
const proxmoxMap = new Map<string, boolean>(
|
const proxmoxMap = new Map<string, boolean>(
|
||||||
@@ -158,6 +162,8 @@ function LiveViewCanvas() {
|
|||||||
elementsSelectable={false}
|
elementsSelectable={false}
|
||||||
panOnDrag
|
panOnDrag
|
||||||
zoomOnScroll
|
zoomOnScroll
|
||||||
|
minZoom={0.25}
|
||||||
|
maxZoom={2.5}
|
||||||
colorMode={theme.colors.reactFlowColorMode}
|
colorMode={theme.colors.reactFlowColorMode}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
|
|||||||
@@ -5,9 +5,15 @@ import { useThemeStore } from '@/stores/themeStore'
|
|||||||
|
|
||||||
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Capture props passed to ReactFlow so we can assert zoom bounds etc.
|
||||||
|
let rfProps: Record<string, unknown> = {}
|
||||||
|
|
||||||
vi.mock('@xyflow/react', () => ({
|
vi.mock('@xyflow/react', () => ({
|
||||||
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
ReactFlow: () => <div data-testid="react-flow" />,
|
ReactFlow: (props: Record<string, unknown>) => {
|
||||||
|
rfProps = props
|
||||||
|
return <div data-testid="react-flow" />
|
||||||
|
},
|
||||||
Background: () => null,
|
Background: () => null,
|
||||||
Controls: () => null,
|
Controls: () => null,
|
||||||
BackgroundVariant: { Dots: 'dots' },
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
@@ -49,6 +55,7 @@ const canvasPayload = {
|
|||||||
|
|
||||||
describe('LiveView (non-standalone)', () => {
|
describe('LiveView (non-standalone)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
rfProps = {}
|
||||||
vi.mocked(liveviewApi.load).mockReset()
|
vi.mocked(liveviewApi.load).mockReset()
|
||||||
useCanvasStore.setState({ nodes: [], edges: [] })
|
useCanvasStore.setState({ nodes: [], edges: [] })
|
||||||
})
|
})
|
||||||
@@ -111,7 +118,26 @@ describe('LiveView (non-standalone)', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('react-flow')).toBeDefined()
|
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||||
})
|
})
|
||||||
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key')
|
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key', undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forwards ?design=<id> to the API so a specific canvas is loaded', async () => {
|
||||||
|
setSearch('?key=correct-key&design=elec-123')
|
||||||
|
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||||
|
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key', 'elec-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows zooming out to 0.25 so large infra fits (matches the editor)', async () => {
|
||||||
|
setSearch('?key=correct-key')
|
||||||
|
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||||
|
// Without an explicit minZoom, React Flow defaults to 0.5 and big canvases
|
||||||
|
// can't zoom out far enough to fit.
|
||||||
|
expect(rfProps.minZoom).toBe(0.25)
|
||||||
|
expect(rfProps.maxZoom).toBe(2.5)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads nodes into the canvas store on success', async () => {
|
it('loads nodes into the canvas store on success', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -40,8 +40,34 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
onNodesChange, onEdgesChange,
|
onNodesChange, onEdgesChange,
|
||||||
setSelectedNode, snapshotHistory,
|
setSelectedNode, snapshotHistory,
|
||||||
fitViewPending, clearFitViewPending,
|
fitViewPending, clearFitViewPending,
|
||||||
|
copySelectedNodes, pasteNodes,
|
||||||
} = useCanvasStore()
|
} = useCanvasStore()
|
||||||
const { fitView } = useReactFlow()
|
const { fitView, screenToFlowPosition } = useReactFlow()
|
||||||
|
|
||||||
|
// Track the last cursor position over the canvas so paste lands under it.
|
||||||
|
const cursorRef = useRef<{ x: number; y: number } | null>(null)
|
||||||
|
const onMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
|
cursorRef.current = { x: e.clientX, y: e.clientY }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Copy / paste shortcuts. Registered here (inside ReactFlowProvider) so paste
|
||||||
|
// can project the cursor / viewport center into flow coordinates.
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (!(e.ctrlKey || e.metaKey)) return
|
||||||
|
const el = e.target as HTMLElement
|
||||||
|
const isInput = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable
|
||||||
|
if (isInput) return
|
||||||
|
if (e.key === 'c') {
|
||||||
|
copySelectedNodes()
|
||||||
|
} else if (e.key === 'v') {
|
||||||
|
const screen = cursorRef.current ?? { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
||||||
|
pasteNodes(screenToFlowPosition(screen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [copySelectedNodes, pasteNodes, screenToFlowPosition])
|
||||||
|
|
||||||
// Fit view after canvas loads (fitViewPending is set by loadCanvas)
|
// Fit view after canvas loads (fitViewPending is set by loadCanvas)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -100,7 +126,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={visibleNodes}
|
nodes={visibleNodes}
|
||||||
edges={visibleEdges}
|
edges={visibleEdges}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
|
import type { EdgeProps, Edge } from '@xyflow/react'
|
||||||
|
import { HomelableEdge } from '../index'
|
||||||
|
import type { EdgeData } from '@/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression: edge flow animations must use CSS, never SVG SMIL <animate>.
|
||||||
|
*
|
||||||
|
* SMIL <animate> keeps running while the tab is hidden and leaks memory in
|
||||||
|
* Chrome over time (RAM climbed only when the canvas tab was backgrounded).
|
||||||
|
* CSS animations pause when the tab is hidden and don't leak — so the rendered
|
||||||
|
* output must contain a CSS `animation` on the path and zero <animate> nodes.
|
||||||
|
*/
|
||||||
|
function renderEdge(data: Partial<EdgeData> = {}) {
|
||||||
|
const props = {
|
||||||
|
id: 'e1',
|
||||||
|
source: 'a',
|
||||||
|
target: 'b',
|
||||||
|
sourceX: 0,
|
||||||
|
sourceY: 0,
|
||||||
|
targetX: 100,
|
||||||
|
targetY: 100,
|
||||||
|
sourcePosition: 'bottom',
|
||||||
|
targetPosition: 'top',
|
||||||
|
data: { type: 'ethernet', ...data } as EdgeData,
|
||||||
|
selected: false,
|
||||||
|
} as unknown as EdgeProps<Edge<EdgeData>>
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<svg>
|
||||||
|
<HomelableEdge {...props} />
|
||||||
|
</svg>
|
||||||
|
</ReactFlowProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HomelableEdge animation', () => {
|
||||||
|
it('renders snake animation as CSS, not SMIL <animate>', () => {
|
||||||
|
const { container } = renderEdge({ animated: 'snake' })
|
||||||
|
expect(container.querySelector('animate')).toBeNull()
|
||||||
|
const animated = Array.from(container.querySelectorAll('path')).find((p) =>
|
||||||
|
(p.getAttribute('style') ?? '').includes('homelable-snake'),
|
||||||
|
)
|
||||||
|
expect(animated).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders flow animation as CSS, not SMIL <animate>', () => {
|
||||||
|
const { container } = renderEdge({ animated: 'flow' })
|
||||||
|
expect(container.querySelector('animate')).toBeNull()
|
||||||
|
const animated = Array.from(container.querySelectorAll('path')).find((p) =>
|
||||||
|
(p.getAttribute('style') ?? '').includes('homelable-flow'),
|
||||||
|
)
|
||||||
|
expect(animated).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('legacy animated:true maps to snake CSS animation', () => {
|
||||||
|
const { container } = renderEdge({ animated: true })
|
||||||
|
expect(container.querySelector('animate')).toBeNull()
|
||||||
|
const animated = Array.from(container.querySelectorAll('path')).find((p) =>
|
||||||
|
(p.getAttribute('style') ?? '').includes('homelable-snake'),
|
||||||
|
)
|
||||||
|
expect(animated).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-animated edge has no flow animation and no <animate>', () => {
|
||||||
|
const { container } = renderEdge({ animated: false })
|
||||||
|
expect(container.querySelector('animate')).toBeNull()
|
||||||
|
const animated = Array.from(container.querySelectorAll('path')).find((p) => {
|
||||||
|
const s = p.getAttribute('style') ?? ''
|
||||||
|
return s.includes('homelable-snake') || s.includes('homelable-flow')
|
||||||
|
})
|
||||||
|
expect(animated).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -378,14 +378,14 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
|||||||
strokeWidth={((style.strokeWidth as number ?? 2) + 1.5) * 2}
|
strokeWidth={((style.strokeWidth as number ?? 2) + 1.5) * 2}
|
||||||
strokeDasharray="20 10000"
|
strokeDasharray="20 10000"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{
|
||||||
>
|
pointerEvents: 'none',
|
||||||
{isBidirectional ? (
|
// CSS (not SMIL) so it pauses when the tab is hidden — see index.css.
|
||||||
<animate attributeName="stroke-dashoffset" values="-10000;0;-10000" keyTimes="0;0.5;1" dur="20s" repeatCount="indefinite" />
|
// Bidirectional yo-yos via `alternate` (10s each way = 20s round trip,
|
||||||
) : (
|
// matching the old SMIL keyTimes); unidirectional loops in one direction.
|
||||||
<animate attributeName="stroke-dashoffset" from="-10000" to="0" dur="10s" repeatCount="indefinite" />
|
animation: `homelable-snake 10s linear infinite${isBidirectional ? ' alternate' : ''}`,
|
||||||
)}
|
}}
|
||||||
</path>
|
/>
|
||||||
)}
|
)}
|
||||||
{animMode === 'flow' && (
|
{animMode === 'flow' && (
|
||||||
<path
|
<path
|
||||||
@@ -396,10 +396,11 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
|||||||
strokeDasharray="6 12"
|
strokeDasharray="6 12"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeOpacity={0.85}
|
strokeOpacity={0.85}
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{
|
||||||
>
|
pointerEvents: 'none',
|
||||||
<animate attributeName="stroke-dashoffset" from="0" to="18" dur="1.2s" repeatCount="indefinite" />
|
animation: 'homelable-flow 1.2s linear infinite',
|
||||||
</path>
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ import {
|
|||||||
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
|
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
|
||||||
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2,
|
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { scanApi } from '@/api/client'
|
import { scanApi } from '@/api/client'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
import type { NodeType, ServiceInfo } from '@/types'
|
import type { NodeType, ServiceInfo } from '@/types'
|
||||||
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
|
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
|
||||||
|
import { buildMacProperty } from '@/utils/macProperty'
|
||||||
|
|
||||||
interface PendingDevicesModalProps {
|
interface PendingDevicesModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -255,11 +256,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
const fallbackLabel = deviceLabel(device)
|
const fallbackLabel = deviceLabel(device)
|
||||||
const type = (device.suggested_type ?? 'generic') as NodeType
|
const type = (device.suggested_type ?? 'generic') as NodeType
|
||||||
const zigbee = isZigbeeType(type)
|
const zigbee = isZigbeeType(type)
|
||||||
const properties = zigbee ? buildZigbeeProperties(device) : []
|
const properties = zigbee ? buildZigbeeProperties(device) : buildMacProperty(device.mac)
|
||||||
const nodeData = {
|
const nodeData = {
|
||||||
label: fallbackLabel,
|
label: fallbackLabel,
|
||||||
type,
|
type,
|
||||||
ip: device.ip ?? undefined,
|
ip: device.ip ?? undefined,
|
||||||
|
mac: device.mac ?? undefined,
|
||||||
hostname: device.hostname ?? undefined,
|
hostname: device.hostname ?? undefined,
|
||||||
status: zigbee ? 'online' : 'unknown',
|
status: zigbee ? 'online' : 'unknown',
|
||||||
services: (device.services ?? []) as ServiceInfo[],
|
services: (device.services ?? []) as ServiceInfo[],
|
||||||
@@ -325,10 +327,11 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
label: deviceLabel(d),
|
label: deviceLabel(d),
|
||||||
type,
|
type,
|
||||||
ip: d.ip ?? undefined,
|
ip: d.ip ?? undefined,
|
||||||
|
mac: d.mac ?? undefined,
|
||||||
hostname: d.hostname ?? undefined,
|
hostname: d.hostname ?? undefined,
|
||||||
status: zigbee ? ('online' as const) : ('unknown' as const),
|
status: zigbee ? ('online' as const) : ('unknown' as const),
|
||||||
services: (d.services ?? []) as ServiceInfo[],
|
services: (d.services ?? []) as ServiceInfo[],
|
||||||
properties: zigbee ? buildZigbeeProperties(d) : [],
|
properties: zigbee ? buildZigbeeProperties(d) : buildMacProperty(d.mac),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -370,12 +373,20 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
if (e.key === '/') { e.preventDefault(); searchRef.current?.focus() }
|
if (e.key === '/') { e.preventDefault(); searchRef.current?.focus() }
|
||||||
else if (e.key.toLowerCase() === 's') { e.preventDefault(); if (selectMode) exitSelectMode(); else enterSelectMode() }
|
else if (e.key.toLowerCase() === 's') { e.preventDefault(); if (selectMode) exitSelectMode(); else enterSelectMode() }
|
||||||
else if (e.key.toLowerCase() === 'a' && selectMode) { e.preventDefault(); selectAllVisible() }
|
else if (e.key.toLowerCase() === 'a' && selectMode) { e.preventDefault(); selectAllVisible() }
|
||||||
else if (e.key === 'Enter' && selectMode && selectedIds.size > 0) { e.preventDefault(); handleBulkApprove() }
|
else if (e.key === 'Enter' && selectMode && selectedIds.size > 0) {
|
||||||
|
// Enter confirms the bulk action for the current view: approving
|
||||||
|
// hidden devices would be wrong — they restore.
|
||||||
|
e.preventDefault()
|
||||||
|
if (statusFilter === 'hidden') handleBulkRestore()
|
||||||
|
else handleBulkApprove()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
return () => window.removeEventListener('keydown', handler)
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
// statusFilter is included so Enter dispatches the correct bulk action
|
||||||
|
// (approve vs restore) even if the device list doesn't change on switch.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [open, selectMode, selectedIds, filtered])
|
}, [open, selectMode, selectedIds, filtered, statusFilter])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -405,9 +416,19 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Close">
|
{/* Route the close X through Base UI's DialogClose (same path as
|
||||||
|
outside-click) instead of a raw onClick — the latter's synthetic
|
||||||
|
click was being dropped on Firefox/Windows. */}
|
||||||
|
<DialogClose
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</DialogClose>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { settingsApi } from '@/api/client'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
type AlignmentSettings,
|
||||||
|
readAlignmentSettings,
|
||||||
|
writeAlignmentSettings,
|
||||||
|
subscribeAlignmentSettings,
|
||||||
|
} from '@/utils/alignmentSettings'
|
||||||
|
|
||||||
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
|
interface SettingsModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||||
|
const [interval, setIntervalValue] = useState(60)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||||
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
|
const setHideIp = useCanvasStore((s) => s.setHideIp)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || STANDALONE) return
|
||||||
|
settingsApi.get()
|
||||||
|
.then((res) => setIntervalValue(res.data.interval_seconds))
|
||||||
|
.catch(() => {/* use default */})
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
||||||
|
|
||||||
|
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
||||||
|
const next = { ...alignment, ...patch }
|
||||||
|
setAlignment(next)
|
||||||
|
writeAlignmentSettings(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
// Canvas prefs (alignment, hide-IP) persist on change; only the backend
|
||||||
|
// status-check interval needs an API round-trip.
|
||||||
|
if (STANDALONE) {
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await settingsApi.save({ interval_seconds: interval })
|
||||||
|
toast.success('Settings saved')
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save settings')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="bg-[#161b22] border-border max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-foreground">Settings</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-5 py-2">
|
||||||
|
{/* Status checker */}
|
||||||
|
{!STANDALONE && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={3600}
|
||||||
|
value={interval}
|
||||||
|
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }}
|
||||||
|
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">seconds</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||||
|
How often node health is polled (ping, HTTP, SSH…)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Canvas */}
|
||||||
|
<div className="pt-3 border-t border-border space-y-3">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Canvas</span>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||||
|
<span className="text-xs text-foreground">Snap to nodes</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={alignment.enabled}
|
||||||
|
onChange={(e) => updateAlignment({ enabled: e.target.checked })}
|
||||||
|
className="cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Toggle alignment guides"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||||
|
<span className="text-xs text-foreground">Hide IP addresses</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideIp}
|
||||||
|
onChange={(e) => setHideIp(e.target.checked)}
|
||||||
|
className="cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Toggle IP address masking"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={alignment.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||||
|
<label className="text-xs text-muted-foreground">Snap distance</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={2}
|
||||||
|
max={16}
|
||||||
|
step={1}
|
||||||
|
value={alignment.threshold}
|
||||||
|
onChange={(e) => updateAlignment({ threshold: Number(e.target.value) })}
|
||||||
|
className="flex-1 cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Alignment snap threshold"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[11px] text-foreground w-8 text-right">{alignment.threshold}px</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||||
|
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
style={{ background: '#00d4ff', color: '#0d1117' }}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ const mockApprove = vi.fn()
|
|||||||
const mockHide = vi.fn()
|
const mockHide = vi.fn()
|
||||||
const mockPending = vi.fn()
|
const mockPending = vi.fn()
|
||||||
const mockHidden = vi.fn()
|
const mockHidden = vi.fn()
|
||||||
|
const mockAddNode = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/api/client', () => ({
|
vi.mock('@/api/client', () => ({
|
||||||
scanApi: {
|
scanApi: {
|
||||||
@@ -69,7 +70,7 @@ const DEVICE_ZIGBEE = {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.mocked(useCanvasStore).mockReturnValue({
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
addNode: vi.fn(),
|
addNode: mockAddNode,
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
} as unknown as ReturnType<typeof useCanvasStore>)
|
} as unknown as ReturnType<typeof useCanvasStore>)
|
||||||
// setState is used by injectAutoEdges
|
// setState is used by injectAutoEdges
|
||||||
@@ -98,6 +99,14 @@ describe('PendingDevicesModal', () => {
|
|||||||
expect(screen.getByText('living-room-bulb')).toBeInTheDocument()
|
expect(screen.getByText('living-room-bulb')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('closes via the X button (routes through DialogClose, not a raw onClick)', async () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<PendingDevicesModal open onClose={onClose} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('shows source chip ZIGBEE for zigbee device', async () => {
|
it('shows source chip ZIGBEE for zigbee device', async () => {
|
||||||
render(<PendingDevicesModal {...baseProps} />)
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
@@ -174,6 +183,34 @@ describe('PendingDevicesModal', () => {
|
|||||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('bulk approve carries the scanned MAC onto the canvas node (#168)', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
|
||||||
|
await waitFor(() => expect(mockAddNode).toHaveBeenCalledTimes(2))
|
||||||
|
|
||||||
|
// dev-a is an IP device with a MAC → node carries mac + a MAC property row.
|
||||||
|
const ipNode = mockAddNode.mock.calls
|
||||||
|
.map((c) => c[0])
|
||||||
|
.find((n) => n.id === 'n1')
|
||||||
|
expect(ipNode.data.mac).toBe('aa:bb:cc:dd:ee:01')
|
||||||
|
expect(ipNode.data.properties).toContainEqual({
|
||||||
|
key: 'MAC',
|
||||||
|
value: 'aa:bb:cc:dd:ee:01',
|
||||||
|
icon: null,
|
||||||
|
visible: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// dev-b is zigbee with no MAC → no MAC property row.
|
||||||
|
const zbNode = mockAddNode.mock.calls
|
||||||
|
.map((c) => c[0])
|
||||||
|
.find((n) => n.id === 'n2')
|
||||||
|
expect(zbNode.data.properties.some((p: { key: string }) => p.key === 'MAC')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('bulk hide calls API with selected ids', async () => {
|
it('bulk hide calls API with selected ids', async () => {
|
||||||
render(<PendingDevicesModal {...baseProps} />)
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
@@ -213,4 +250,25 @@ describe('PendingDevicesModal', () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: /Restore \(1\)/ }))
|
fireEvent.click(screen.getByRole('button', { name: /Restore \(1\)/ }))
|
||||||
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
|
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('Enter confirms approve in pending select mode', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
fireEvent.keyDown(window, { key: 'Enter' })
|
||||||
|
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a']))
|
||||||
|
expect(mockBulkRestore).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Enter restores (not approves) in hidden select mode', async () => {
|
||||||
|
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
|
||||||
|
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
fireEvent.keyDown(window, { key: 'Enter' })
|
||||||
|
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
|
||||||
|
expect(mockBulkApprove).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { SettingsModal } from '../SettingsModal'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
settingsApi: {
|
||||||
|
get: vi.fn(),
|
||||||
|
save: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { settingsApi } from '@/api/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
describe('SettingsModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
|
||||||
|
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
|
||||||
|
vi.mocked(toast.success).mockReset()
|
||||||
|
vi.mocked(toast.error).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads interval from API when opened', async () => {
|
||||||
|
render(<SettingsModal open onClose={vi.fn()} />)
|
||||||
|
await waitFor(() => expect(settingsApi.get).toHaveBeenCalledOnce())
|
||||||
|
expect(screen.getByText('Status check interval (s)')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fetch when closed', () => {
|
||||||
|
render(<SettingsModal open={false} onClose={vi.fn()} />)
|
||||||
|
expect(settingsApi.get).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('displays interval loaded from API', async () => {
|
||||||
|
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 120 } } as never)
|
||||||
|
render(<SettingsModal open onClose={vi.fn()} />)
|
||||||
|
expect(await screen.findByDisplayValue('120')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves interval and closes on Save click', async () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<SettingsModal open onClose={onClose} />)
|
||||||
|
const input = await screen.findByDisplayValue('60')
|
||||||
|
fireEvent.change(input, { target: { value: '180' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180 })
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('Settings saved')
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast and stays open when save fails', async () => {
|
||||||
|
vi.mocked(settingsApi.save).mockRejectedValue(new Error('network'))
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<SettingsModal open onClose={onClose} />)
|
||||||
|
await screen.findByDisplayValue('60')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Failed to save settings')
|
||||||
|
})
|
||||||
|
expect(onClose).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reflects and persists the hide-IP preference', async () => {
|
||||||
|
useCanvasStore.setState({ hideIp: false })
|
||||||
|
localStorage.removeItem('homelable.hideIp')
|
||||||
|
render(<SettingsModal open onClose={vi.fn()} />)
|
||||||
|
const checkbox = screen.getByLabelText('Toggle IP address masking') as HTMLInputElement
|
||||||
|
expect(checkbox.checked).toBe(false)
|
||||||
|
fireEvent.click(checkbox)
|
||||||
|
expect(useCanvasStore.getState().hideIp).toBe(true)
|
||||||
|
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose on Cancel', async () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<SettingsModal open onClose={onClose} />)
|
||||||
|
await screen.findByDisplayValue('60')
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,26 +1,20 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useDesignStore } from '@/stores/designStore'
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { designsApi, scanApi, settingsApi } from '@/api/client'
|
import { designsApi, scanApi } from '@/api/client'
|
||||||
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
||||||
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
|
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
|
||||||
import type { Design } from '@/types'
|
import type { Design } from '@/types'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||||
import {
|
|
||||||
type AlignmentSettings,
|
|
||||||
readAlignmentSettings,
|
|
||||||
writeAlignmentSettings,
|
|
||||||
subscribeAlignmentSettings,
|
|
||||||
} from '@/utils/alignmentSettings'
|
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
type SidebarView = 'canvas' | 'history' | 'settings'
|
type SidebarView = 'canvas' | 'history'
|
||||||
|
|
||||||
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
|
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
|
||||||
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
|
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
|
||||||
@@ -45,11 +39,12 @@ interface SidebarProps {
|
|||||||
onScan: () => void
|
onScan: () => void
|
||||||
onZigbeeImport: () => void
|
onZigbeeImport: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
|
onOpenSettings: () => void
|
||||||
forceView?: SidebarView
|
forceView?: SidebarView
|
||||||
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, onOpenSettings, forceView, onOpenPending }: SidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
||||||
const [prevForceView, setPrevForceView] = useState(forceView)
|
const [prevForceView, setPrevForceView] = useState(forceView)
|
||||||
@@ -95,7 +90,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
const { nodes, hasUnsavedChanges } = useCanvasStore()
|
||||||
|
|
||||||
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||||
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||||
@@ -225,7 +220,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
{!collapsed && activeView !== 'canvas' && (
|
{!collapsed && activeView !== 'canvas' && (
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
||||||
{activeView === 'history' && <ScanHistoryPanel />}
|
{activeView === 'history' && <ScanHistoryPanel />}
|
||||||
{activeView === 'settings' && <SettingsPanel />}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -259,30 +253,20 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
||||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||||
<SidebarItem
|
|
||||||
icon={hideIp ? EyeOff : Eye}
|
|
||||||
label={hideIp ? 'Show IPs' : 'Hide IPs'}
|
|
||||||
collapsed={collapsed}
|
|
||||||
onClick={toggleHideIp}
|
|
||||||
active={hideIp}
|
|
||||||
/>
|
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={Save}
|
icon={Save}
|
||||||
label="Save Canvas"
|
label="Save Canvas"
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onClick={onSave}
|
onClick={() => onSave()}
|
||||||
badge={hasUnsavedChanges}
|
badge={hasUnsavedChanges}
|
||||||
accent
|
accent
|
||||||
/>
|
/>
|
||||||
{!STANDALONE && (
|
<SidebarItem
|
||||||
<SidebarItem
|
icon={Settings}
|
||||||
icon={Settings}
|
label="Settings"
|
||||||
label="Settings"
|
collapsed={collapsed}
|
||||||
collapsed={collapsed}
|
onClick={onOpenSettings}
|
||||||
active={activeView === 'settings'}
|
/>
|
||||||
onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!STANDALONE && (
|
{!STANDALONE && (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={LogOut}
|
icon={LogOut}
|
||||||
@@ -440,105 +424,6 @@ function ScanHistoryPanel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsPanel() {
|
|
||||||
const [interval, setIntervalValue] = useState(60)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
settingsApi.get()
|
|
||||||
.then((res) => setIntervalValue(res.data.interval_seconds))
|
|
||||||
.catch(() => {/* use default */})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
|
||||||
|
|
||||||
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
|
||||||
const next = { ...alignment, ...patch }
|
|
||||||
setAlignment(next)
|
|
||||||
writeAlignmentSettings(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
await settingsApi.save({ interval_seconds: interval })
|
|
||||||
toast.success('Settings saved')
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to save settings')
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-3 space-y-4">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Settings</span>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={10}
|
|
||||||
max={3600}
|
|
||||||
value={interval}
|
|
||||||
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }}
|
|
||||||
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">seconds</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
|
||||||
How often node health is polled (ping, HTTP, SSH…)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="w-full py-1.5 rounded-md text-xs font-medium bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? 'Saving…' : 'Save'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="pt-3 border-t border-border space-y-3">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Canvas</span>
|
|
||||||
|
|
||||||
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
|
||||||
<span className="text-xs text-foreground">Snap to nodes</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={alignment.enabled}
|
|
||||||
onChange={(e) => updateAlignment({ enabled: e.target.checked })}
|
|
||||||
className="cursor-pointer accent-[#00d4ff]"
|
|
||||||
aria-label="Toggle alignment guides"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className={alignment.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
|
||||||
<label className="text-xs text-muted-foreground">Snap distance</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={2}
|
|
||||||
max={16}
|
|
||||||
step={1}
|
|
||||||
value={alignment.threshold}
|
|
||||||
onChange={(e) => updateAlignment({ threshold: Number(e.target.value) })}
|
|
||||||
className="flex-1 cursor-pointer accent-[#00d4ff]"
|
|
||||||
aria-label="Alignment snap threshold"
|
|
||||||
/>
|
|
||||||
<span className="font-mono text-[11px] text-foreground w-8 text-right">{alignment.threshold}px</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
|
||||||
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function VersionBadge() {
|
function VersionBadge() {
|
||||||
const current = __APP_VERSION__
|
const current = __APP_VERSION__
|
||||||
const { latest, hasUpdate } = useLatestRelease(current)
|
const { latest, hasUpdate } = useLatestRelease(current)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown, Upload } from 'lucide-react'
|
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown, Upload, Eye } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -15,9 +15,10 @@ interface ToolbarProps {
|
|||||||
onExportMd: () => void
|
onExportMd: () => void
|
||||||
onExportYaml: () => void
|
onExportYaml: () => void
|
||||||
onImportYaml: (content: string) => void
|
onImportYaml: (content: string) => void
|
||||||
|
onViewOnly: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml }: ToolbarProps) {
|
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml, onViewOnly }: ToolbarProps) {
|
||||||
const { hasUnsavedChanges, past, future } = useCanvasStore()
|
const { hasUnsavedChanges, past, future } = useCanvasStore()
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -81,6 +82,9 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
|
||||||
<Table2 size={14} /> MD
|
<Table2 size={14} /> MD
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onViewOnly} title="Open read-only live view of this canvas">
|
||||||
|
<Eye size={14} /> View
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
||||||
<HelpCircle size={14} />
|
<HelpCircle size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -91,7 +95,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
background: hasUnsavedChanges ? '#00d4ff' : undefined,
|
background: hasUnsavedChanges ? '#00d4ff' : undefined,
|
||||||
color: hasUnsavedChanges ? '#0d1117' : undefined,
|
color: hasUnsavedChanges ? '#0d1117' : undefined,
|
||||||
}}
|
}}
|
||||||
onClick={onSave}
|
onClick={() => onSave()}
|
||||||
>
|
>
|
||||||
{hasUnsavedChanges && (
|
{hasUnsavedChanges && (
|
||||||
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-[#e3b341] border border-[#161b22]" />
|
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-[#e3b341] border border-[#161b22]" />
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
||||||
import { Sidebar } from '../Sidebar'
|
|
||||||
import * as canvasStore from '@/stores/canvasStore'
|
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
|
||||||
|
|
||||||
vi.mock('@/stores/canvasStore')
|
|
||||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
|
||||||
vi.mock('@/api/client', () => ({
|
|
||||||
scanApi: {
|
|
||||||
trigger: vi.fn(),
|
|
||||||
pending: vi.fn().mockResolvedValue({ data: [] }),
|
|
||||||
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
|
||||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
|
||||||
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
|
|
||||||
},
|
|
||||||
settingsApi: {
|
|
||||||
get: vi.fn(),
|
|
||||||
save: vi.fn(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { settingsApi } from '@/api/client'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
function renderSidebar() {
|
|
||||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
|
||||||
nodes: [],
|
|
||||||
hasUnsavedChanges: false,
|
|
||||||
hideIp: false,
|
|
||||||
toggleHideIp: vi.fn(),
|
|
||||||
addNode: vi.fn(),
|
|
||||||
scanEventTs: 0,
|
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
|
||||||
|
|
||||||
return render(
|
|
||||||
<TooltipProvider>
|
|
||||||
<Sidebar
|
|
||||||
onAddNode={vi.fn()}
|
|
||||||
onAddGroupRect={vi.fn()}
|
|
||||||
onScan={vi.fn()}
|
|
||||||
onSave={vi.fn()}
|
|
||||||
onNodeApproved={vi.fn()}
|
|
||||||
/>
|
|
||||||
</TooltipProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('SettingsPanel', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
|
|
||||||
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
|
|
||||||
vi.mocked(toast.success).mockReset()
|
|
||||||
vi.mocked(toast.error).mockReset()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('opens when Settings item is clicked', async () => {
|
|
||||||
renderSidebar()
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(settingsApi.get).toHaveBeenCalledOnce()
|
|
||||||
})
|
|
||||||
expect(screen.getByText('Status check interval (s)')).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('displays interval loaded from API', async () => {
|
|
||||||
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 120 } } as never)
|
|
||||||
renderSidebar()
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
|
||||||
const input = await screen.findByDisplayValue('120')
|
|
||||||
expect(input).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('saves interval via settingsApi on Save click', async () => {
|
|
||||||
renderSidebar()
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
|
||||||
const input = await screen.findByDisplayValue('60')
|
|
||||||
fireEvent.change(input, { target: { value: '180' } })
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180 })
|
|
||||||
expect(toast.success).toHaveBeenCalledWith('Settings saved')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows error toast when save fails', async () => {
|
|
||||||
vi.mocked(settingsApi.save).mockRejectedValue(new Error('network'))
|
|
||||||
renderSidebar()
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
|
||||||
await screen.findByDisplayValue('60')
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(toast.error).toHaveBeenCalledWith('Failed to save settings')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('closes panel when Settings is clicked again', async () => {
|
|
||||||
renderSidebar()
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
|
||||||
await screen.findByText('Status check interval (s)')
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
|
||||||
expect(screen.queryByText('Status check interval (s)')).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -46,15 +46,12 @@ const makeNode = (id: string, status: NodeData['status'], type: NodeData['type']
|
|||||||
data: { label: id, type, status, services: [] },
|
data: { label: id, type, status, services: [] },
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockToggleHideIp = vi.fn()
|
|
||||||
const mockLogout = vi.fn()
|
const mockLogout = vi.fn()
|
||||||
|
|
||||||
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
||||||
vi.mocked(useCanvasStore).mockReturnValue({
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
nodes: [],
|
nodes: [],
|
||||||
hasUnsavedChanges: false,
|
hasUnsavedChanges: false,
|
||||||
hideIp: false,
|
|
||||||
toggleHideIp: mockToggleHideIp,
|
|
||||||
addNode: vi.fn(),
|
addNode: vi.fn(),
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -73,6 +70,7 @@ const defaultProps = {
|
|||||||
onScan: vi.fn(),
|
onScan: vi.fn(),
|
||||||
onZigbeeImport: vi.fn(),
|
onZigbeeImport: vi.fn(),
|
||||||
onSave: vi.fn(),
|
onSave: vi.fn(),
|
||||||
|
onOpenSettings: vi.fn(),
|
||||||
onOpenPending: vi.fn(),
|
onOpenPending: vi.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,16 +188,19 @@ describe('Sidebar', () => {
|
|||||||
expect(defaultProps.onSave).toHaveBeenCalledOnce()
|
expect(defaultProps.onSave).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('calls toggleHideIp when Hide IPs is clicked', () => {
|
// Regression (#186): the click handler must not forward the MouseEvent as an
|
||||||
|
// argument — handleSave treats its first arg as a designIdOverride, so leaking
|
||||||
|
// the event corrupts design_id and the save silently fails.
|
||||||
|
it('calls onSave with no arguments (does not leak the click event)', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Hide IPs'))
|
fireEvent.click(screen.getByText('Save Canvas'))
|
||||||
expect(mockToggleHideIp).toHaveBeenCalledOnce()
|
expect(defaultProps.onSave).toHaveBeenCalledWith()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows Show IPs label when hideIp is true', () => {
|
it('calls onOpenSettings when Settings is clicked', () => {
|
||||||
mockStore({ hideIp: true })
|
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
expect(screen.getByText('Show IPs')).toBeInTheDocument()
|
fireEvent.click(screen.getByText('Settings'))
|
||||||
|
expect(defaultProps.onOpenSettings).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Unsaved changes badge ──────────────────────────────────────────────────
|
// ── Unsaved changes badge ──────────────────────────────────────────────────
|
||||||
@@ -257,14 +258,10 @@ describe('Sidebar', () => {
|
|||||||
await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument())
|
await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument())
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggles Settings panel on Settings click', async () => {
|
it('calls onOpenSettings when Settings is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Settings'))
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
|
|
||||||
)
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||||
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
|
expect(defaultProps.onOpenSettings).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Logout ─────────────────────────────────────────────────────────────────
|
// ── Logout ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { Toolbar } from '../Toolbar'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore')
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/Logo', () => ({
|
||||||
|
Logo: () => <div data-testid="logo" />,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
||||||
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
|
hasUnsavedChanges: false,
|
||||||
|
past: [],
|
||||||
|
future: [],
|
||||||
|
...overrides,
|
||||||
|
} as ReturnType<typeof useCanvasStore>)
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
onSave: vi.fn(),
|
||||||
|
onAutoLayout: vi.fn(),
|
||||||
|
onExport: vi.fn(),
|
||||||
|
onChangeStyle: vi.fn(),
|
||||||
|
onUndo: vi.fn(),
|
||||||
|
onRedo: vi.fn(),
|
||||||
|
onShortcuts: vi.fn(),
|
||||||
|
onExportMd: vi.fn(),
|
||||||
|
onExportYaml: vi.fn(),
|
||||||
|
onImportYaml: vi.fn(),
|
||||||
|
onViewOnly: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Toolbar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockStore()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSave when Save is clicked', () => {
|
||||||
|
render(<Toolbar {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Save'))
|
||||||
|
expect(defaultProps.onSave).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Regression (#186): the click handler must not forward the MouseEvent as an
|
||||||
|
// argument — handleSave treats its first arg as a designIdOverride, so leaking
|
||||||
|
// the event corrupts design_id and the save silently fails.
|
||||||
|
it('calls onSave with no arguments (does not leak the click event)', () => {
|
||||||
|
render(<Toolbar {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByText('Save'))
|
||||||
|
expect(defaultProps.onSave).toHaveBeenCalledWith()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,6 +11,18 @@
|
|||||||
to { stroke-dashoffset: 0; }
|
to { stroke-dashoffset: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Edge flow animations — CSS (not SVG SMIL) so the browser pauses them when the
|
||||||
|
tab is hidden. SMIL <animate> keeps running in background tabs and leaks
|
||||||
|
memory in Chrome over time; CSS animations do neither. */
|
||||||
|
@keyframes homelable-snake {
|
||||||
|
from { stroke-dashoffset: -10000; }
|
||||||
|
to { stroke-dashoffset: 0; }
|
||||||
|
}
|
||||||
|
@keyframes homelable-flow {
|
||||||
|
from { stroke-dashoffset: 0; }
|
||||||
|
to { stroke-dashoffset: 18; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Disable React Flow's built-in edgeupdater entirely — HomelableEdge renders
|
/* Disable React Flow's built-in edgeupdater entirely — HomelableEdge renders
|
||||||
its own interactive endpoint dots in EdgeLabelRenderer (above the node
|
its own interactive endpoint dots in EdgeLabelRenderer (above the node
|
||||||
layer) so the node Handle DOM cannot steal the reconnection drag. */
|
layer) so the node Handle DOM cannot steal the reconnection drag. */
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe('canvasStore', () => {
|
|||||||
editingTextId: null,
|
editingTextId: null,
|
||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
clipboard: [],
|
clipboard: { nodes: [], edges: [] },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -690,29 +690,136 @@ describe('canvasStore', () => {
|
|||||||
})
|
})
|
||||||
useCanvasStore.getState().copySelectedNodes()
|
useCanvasStore.getState().copySelectedNodes()
|
||||||
const { clipboard } = useCanvasStore.getState()
|
const { clipboard } = useCanvasStore.getState()
|
||||||
expect(clipboard).toHaveLength(1)
|
expect(clipboard.nodes).toHaveLength(1)
|
||||||
expect(clipboard[0].id).toBe('a')
|
expect(clipboard.nodes[0].id).toBe('a')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pasteNodes creates new nodes with new IDs and offset position', () => {
|
it('copySelectedNodes captures edges whose endpoints are both selected', () => {
|
||||||
const node = { ...makeNode('src'), position: { x: 100, y: 100 }, selected: true }
|
useCanvasStore.setState({
|
||||||
useCanvasStore.setState({ nodes: [node], edges: [], clipboard: [node] })
|
nodes: [
|
||||||
|
{ ...makeNode('a'), selected: true },
|
||||||
|
{ ...makeNode('b'), selected: true },
|
||||||
|
{ ...makeNode('c'), selected: false },
|
||||||
|
],
|
||||||
|
edges: [makeEdge('e-ab', 'a', 'b'), makeEdge('e-bc', 'b', 'c')],
|
||||||
|
})
|
||||||
|
useCanvasStore.getState().copySelectedNodes()
|
||||||
|
const { clipboard } = useCanvasStore.getState()
|
||||||
|
expect(clipboard.nodes.map((n) => n.id).sort()).toEqual(['a', 'b'])
|
||||||
|
expect(clipboard.edges).toHaveLength(1)
|
||||||
|
expect(clipboard.edges[0].id).toBe('e-ab')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copySelectedNodes pulls in children of a selected group', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [
|
||||||
|
{ ...makeNode('g', { type: 'group' }), type: 'group', selected: true },
|
||||||
|
{ ...makeNode('child', { parent_id: 'g' }), parentId: 'g', selected: false },
|
||||||
|
],
|
||||||
|
edges: [],
|
||||||
|
})
|
||||||
|
useCanvasStore.getState().copySelectedNodes()
|
||||||
|
expect(useCanvasStore.getState().clipboard.nodes.map((n) => n.id).sort()).toEqual(['child', 'g'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pasteNodes creates new nodes with new IDs and a cascade offset by default', () => {
|
||||||
|
const node = { ...makeNode('src'), position: { x: 100, y: 100 } }
|
||||||
|
useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [node], edges: [] } })
|
||||||
useCanvasStore.getState().pasteNodes()
|
useCanvasStore.getState().pasteNodes()
|
||||||
const { nodes } = useCanvasStore.getState()
|
const { nodes } = useCanvasStore.getState()
|
||||||
expect(nodes).toHaveLength(2)
|
expect(nodes).toHaveLength(1)
|
||||||
const pasted = nodes.find((n) => n.id !== 'src')!
|
const pasted = nodes[0]
|
||||||
expect(pasted).toBeDefined()
|
expect(pasted.id).not.toBe('src')
|
||||||
expect(pasted.position.x).toBe(150)
|
expect(pasted.position).toEqual({ x: 150, y: 150 })
|
||||||
expect(pasted.position.y).toBe(150)
|
expect(pasted.selected).toBe(true)
|
||||||
expect(pasted.selected).toBe(false)
|
})
|
||||||
|
|
||||||
|
it('pasteNodes centers the pasted bounding box on the target point', () => {
|
||||||
|
const node = { ...makeNode('src'), position: { x: 0, y: 0 }, width: 100, height: 100 }
|
||||||
|
useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [node], edges: [] } })
|
||||||
|
useCanvasStore.getState().pasteNodes({ x: 500, y: 300 })
|
||||||
|
const pasted = useCanvasStore.getState().nodes[0]
|
||||||
|
// bbox center (50,50) shifted onto (500,300) → top-left at (450,250)
|
||||||
|
expect(pasted.position).toEqual({ x: 450, y: 250 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pasteNodes remaps edge endpoints to the new node IDs', () => {
|
||||||
|
const a = { ...makeNode('a') }
|
||||||
|
const b = { ...makeNode('b') }
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
clipboard: { nodes: [a, b], edges: [makeEdge('e-ab', 'a', 'b')] },
|
||||||
|
})
|
||||||
|
useCanvasStore.getState().pasteNodes()
|
||||||
|
const { nodes, edges } = useCanvasStore.getState()
|
||||||
|
expect(edges).toHaveLength(1)
|
||||||
|
const ids = nodes.map((n) => n.id)
|
||||||
|
expect(ids).toContain(edges[0].source)
|
||||||
|
expect(ids).toContain(edges[0].target)
|
||||||
|
expect(edges[0].source).not.toBe('a')
|
||||||
|
expect(edges[0].id).not.toBe('e-ab')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pasteNodes preserves parent-child relationship under remapped IDs', () => {
|
||||||
|
const group = { ...makeNode('g', { type: 'group' }), type: 'group', position: { x: 0, y: 0 } }
|
||||||
|
const child = { ...makeNode('child', { parent_id: 'g' }), parentId: 'g', extent: 'parent' as const, position: { x: 20, y: 30 } }
|
||||||
|
useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [group, child], edges: [] } })
|
||||||
|
useCanvasStore.getState().pasteNodes()
|
||||||
|
const { nodes } = useCanvasStore.getState()
|
||||||
|
const newGroup = nodes.find((n) => n.data.type === 'group')!
|
||||||
|
const newChild = nodes.find((n) => n.id !== newGroup.id)!
|
||||||
|
expect(newChild.parentId).toBe(newGroup.id)
|
||||||
|
expect(newChild.data.parent_id).toBe(newGroup.id)
|
||||||
|
// Child keeps its parent-relative position (no offset applied to children)
|
||||||
|
expect(newChild.position).toEqual({ x: 20, y: 30 })
|
||||||
|
// Group (the root) precedes its child in the array
|
||||||
|
expect(nodes.findIndex((n) => n.id === newGroup.id)).toBeLessThan(
|
||||||
|
nodes.findIndex((n) => n.id === newChild.id),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clipboard survives loadCanvas so nodes can be pasted into another design', () => {
|
||||||
|
useCanvasStore.setState({
|
||||||
|
nodes: [{ ...makeNode('a'), selected: true }],
|
||||||
|
edges: [],
|
||||||
|
})
|
||||||
|
useCanvasStore.getState().copySelectedNodes()
|
||||||
|
// Switch to another design: loadCanvas replaces nodes/edges.
|
||||||
|
useCanvasStore.getState().loadCanvas([makeNode('other')], [])
|
||||||
|
expect(useCanvasStore.getState().clipboard.nodes).toHaveLength(1)
|
||||||
|
useCanvasStore.getState().pasteNodes()
|
||||||
|
const ids = useCanvasStore.getState().nodes.map((n) => n.id)
|
||||||
|
expect(ids).toContain('other')
|
||||||
|
expect(ids).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pasteNodes does nothing when clipboard is empty', () => {
|
it('pasteNodes does nothing when clipboard is empty', () => {
|
||||||
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: [] })
|
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: { nodes: [], edges: [] } })
|
||||||
useCanvasStore.getState().pasteNodes()
|
useCanvasStore.getState().pasteNodes()
|
||||||
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Hide IP preference (persisted to localStorage) ---
|
||||||
|
|
||||||
|
it('toggleHideIp flips the flag and persists it', () => {
|
||||||
|
localStorage.removeItem('homelable.hideIp')
|
||||||
|
useCanvasStore.setState({ hideIp: false })
|
||||||
|
useCanvasStore.getState().toggleHideIp()
|
||||||
|
expect(useCanvasStore.getState().hideIp).toBe(true)
|
||||||
|
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
|
||||||
|
useCanvasStore.getState().toggleHideIp()
|
||||||
|
expect(useCanvasStore.getState().hideIp).toBe(false)
|
||||||
|
expect(localStorage.getItem('homelable.hideIp')).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setHideIp sets the flag and persists it', () => {
|
||||||
|
localStorage.removeItem('homelable.hideIp')
|
||||||
|
useCanvasStore.getState().setHideIp(true)
|
||||||
|
expect(useCanvasStore.getState().hideIp).toBe(true)
|
||||||
|
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
// --- Node resizing (width / height) ---
|
// --- Node resizing (width / height) ---
|
||||||
|
|
||||||
it('addNode preserves explicit width and height', () => {
|
it('addNode preserves explicit width and height', () => {
|
||||||
@@ -867,7 +974,7 @@ describe('canvasStore — custom style apply', () => {
|
|||||||
editingTextId: null,
|
editingTextId: null,
|
||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
clipboard: [],
|
clipboard: { nodes: [], edges: [] },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeSty
|
|||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
||||||
import { applyOpacity } from '@/utils/colorUtils'
|
import { applyOpacity } from '@/utils/colorUtils'
|
||||||
|
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
|
||||||
|
|
||||||
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||||
|
type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||||
|
|
||||||
|
/** Resolve a node's effective parent id from either the RF field or domain data. */
|
||||||
|
const parentIdOf = (n: Node<NodeData>): string | undefined => n.parentId ?? n.data.parent_id ?? undefined
|
||||||
|
|
||||||
interface CanvasState {
|
interface CanvasState {
|
||||||
nodes: Node<NodeData>[]
|
nodes: Node<NodeData>[]
|
||||||
@@ -31,10 +36,12 @@ interface CanvasState {
|
|||||||
undo: () => void
|
undo: () => void
|
||||||
redo: () => void
|
redo: () => void
|
||||||
|
|
||||||
// Clipboard
|
// Clipboard — survives design switches so nodes can be pasted into another design
|
||||||
clipboard: Node<NodeData>[]
|
clipboard: Clipboard
|
||||||
copySelectedNodes: () => void
|
copySelectedNodes: () => void
|
||||||
pasteNodes: () => void
|
/** Paste clipboard into the current canvas. `center` (flow coords) lands the
|
||||||
|
* pasted bounding-box center under the cursor / viewport center. */
|
||||||
|
pasteNodes: (center?: { x: number; y: number }) => void
|
||||||
|
|
||||||
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
|
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
|
||||||
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
||||||
@@ -63,6 +70,7 @@ interface CanvasState {
|
|||||||
notifyScanDeviceFound: () => void
|
notifyScanDeviceFound: () => void
|
||||||
hideIp: boolean
|
hideIp: boolean
|
||||||
toggleHideIp: () => void
|
toggleHideIp: () => void
|
||||||
|
setHideIp: (value: boolean) => void
|
||||||
applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void
|
applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void
|
||||||
applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void
|
applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void
|
||||||
applyAllCustomStyles: (def: CustomStyleDef) => void
|
applyAllCustomStyles: (def: CustomStyleDef) => void
|
||||||
@@ -76,13 +84,13 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
selectedNodeIds: [],
|
selectedNodeIds: [],
|
||||||
editingGroupRectId: null,
|
editingGroupRectId: null,
|
||||||
editingTextId: null,
|
editingTextId: null,
|
||||||
hideIp: false,
|
hideIp: readHideIp(),
|
||||||
scanEventTs: 0,
|
scanEventTs: 0,
|
||||||
fitViewPending: false,
|
fitViewPending: false,
|
||||||
|
|
||||||
past: [],
|
past: [],
|
||||||
future: [],
|
future: [],
|
||||||
clipboard: [],
|
clipboard: { nodes: [], edges: [] },
|
||||||
|
|
||||||
snapshotHistory: () =>
|
snapshotHistory: () =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -117,24 +125,100 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
copySelectedNodes: () =>
|
copySelectedNodes: () =>
|
||||||
set((state) => ({
|
|
||||||
clipboard: state.nodes.filter((n) => n.selected),
|
|
||||||
})),
|
|
||||||
|
|
||||||
pasteNodes: () =>
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (state.clipboard.length === 0) return state
|
// Start from explicitly selected nodes, then pull in all descendants so a
|
||||||
const newNodes = state.clipboard.map((n) => ({
|
// copied group / container brings its children along.
|
||||||
...n,
|
const ids = new Set(state.nodes.filter((n) => n.selected).map((n) => n.id))
|
||||||
|
if (ids.size === 0) return { clipboard: { nodes: [], edges: [] } }
|
||||||
|
let grew = true
|
||||||
|
while (grew) {
|
||||||
|
grew = false
|
||||||
|
for (const n of state.nodes) {
|
||||||
|
const pid = parentIdOf(n)
|
||||||
|
if (pid && ids.has(pid) && !ids.has(n.id)) {
|
||||||
|
ids.add(n.id)
|
||||||
|
grew = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nodes = state.nodes.filter((n) => ids.has(n.id))
|
||||||
|
// Keep only edges whose both endpoints are inside the copied set.
|
||||||
|
const edges = state.edges.filter((e) => ids.has(e.source) && ids.has(e.target))
|
||||||
|
return { clipboard: { nodes, edges } }
|
||||||
|
}),
|
||||||
|
|
||||||
|
pasteNodes: (center) =>
|
||||||
|
set((state) => {
|
||||||
|
const clip = state.clipboard
|
||||||
|
if (clip.nodes.length === 0) return state
|
||||||
|
|
||||||
|
// Fresh ids for every copied node; edges/parent links are remapped through it.
|
||||||
|
const idMap = new Map<string, string>()
|
||||||
|
clip.nodes.forEach((n) => idMap.set(n.id, generateUUID()))
|
||||||
|
|
||||||
|
// A "root" is a copied node whose parent was not also copied — these carry
|
||||||
|
// absolute positions and receive the paste offset; children move with them.
|
||||||
|
const isRoot = (n: Node<NodeData>) => {
|
||||||
|
const pid = parentIdOf(n)
|
||||||
|
return !pid || !idMap.has(pid)
|
||||||
|
}
|
||||||
|
const roots = clip.nodes.filter(isRoot)
|
||||||
|
|
||||||
|
// Default cascade offset; when a target center is given, shift the root
|
||||||
|
// bounding-box center onto it instead.
|
||||||
|
let offsetX = 50
|
||||||
|
let offsetY = 50
|
||||||
|
if (center && roots.length > 0) {
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
||||||
|
for (const n of roots) {
|
||||||
|
const w = n.width ?? n.measured?.width ?? 200
|
||||||
|
const h = n.height ?? n.measured?.height ?? 80
|
||||||
|
minX = Math.min(minX, n.position.x)
|
||||||
|
minY = Math.min(minY, n.position.y)
|
||||||
|
maxX = Math.max(maxX, n.position.x + w)
|
||||||
|
maxY = Math.max(maxY, n.position.y + h)
|
||||||
|
}
|
||||||
|
offsetX = center.x - (minX + maxX) / 2
|
||||||
|
offsetY = center.y - (minY + maxY) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const pasted = clip.nodes.map((n) => {
|
||||||
|
const root = isRoot(n)
|
||||||
|
const newParentId = root ? undefined : idMap.get(parentIdOf(n)!)
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
id: idMap.get(n.id)!,
|
||||||
|
position: root
|
||||||
|
? { x: n.position.x + offsetX, y: n.position.y + offsetY }
|
||||||
|
: { ...n.position },
|
||||||
|
selected: true,
|
||||||
|
parentId: newParentId,
|
||||||
|
extent: newParentId ? ('parent' as const) : undefined,
|
||||||
|
data: { ...n.data, parent_id: newParentId },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const pastedEdges = clip.edges.map((e) => ({
|
||||||
|
...e,
|
||||||
id: generateUUID(),
|
id: generateUUID(),
|
||||||
position: { x: n.position.x + 50, y: n.position.y + 50 },
|
source: idMap.get(e.source)!,
|
||||||
|
target: idMap.get(e.target)!,
|
||||||
selected: false,
|
selected: false,
|
||||||
parentId: undefined,
|
|
||||||
extent: undefined,
|
|
||||||
data: { ...n.data, parent_id: undefined },
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// React Flow requires parents before children within the appended block.
|
||||||
|
const parents = pasted.filter((n) => !n.parentId)
|
||||||
|
const children = pasted.filter((n) => !!n.parentId)
|
||||||
|
const pastedNodes = [...parents, ...children]
|
||||||
|
|
||||||
|
// Deselect everything already on the canvas so only the paste is selected.
|
||||||
|
const existing = state.nodes.map((n) => (n.selected ? { ...n, selected: false } : n))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nodes: [...state.nodes, ...newNodes],
|
nodes: [...existing, ...pastedNodes],
|
||||||
|
edges: [...state.edges, ...pastedEdges],
|
||||||
|
selectedNodeId: null,
|
||||||
|
selectedNodeIds: pastedNodes.map((n) => n.id),
|
||||||
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||||
future: [],
|
future: [],
|
||||||
hasUnsavedChanges: true,
|
hasUnsavedChanges: true,
|
||||||
@@ -497,13 +581,24 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
|||||||
|
|
||||||
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
||||||
|
|
||||||
toggleHideIp: () => set((s) => ({ hideIp: !s.hideIp })),
|
toggleHideIp: () => set((s) => {
|
||||||
|
const hideIp = !s.hideIp
|
||||||
|
writeHideIp(hideIp)
|
||||||
|
return { hideIp }
|
||||||
|
}),
|
||||||
|
|
||||||
|
setHideIp: (value) => {
|
||||||
|
writeHideIp(value)
|
||||||
|
set({ hideIp: value })
|
||||||
|
},
|
||||||
|
|
||||||
loadCanvas: (nodes, edges) => {
|
loadCanvas: (nodes, edges) => {
|
||||||
// React Flow requires parents before children in the array
|
// React Flow requires parents before children in the array
|
||||||
const parents = nodes.filter((n) => !n.parentId)
|
const parents = nodes.filter((n) => !n.parentId)
|
||||||
const children = nodes.filter((n) => !!n.parentId)
|
const children = nodes.filter((n) => !!n.parentId)
|
||||||
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [], fitViewPending: true })
|
// NOTE: clipboard is intentionally preserved here so nodes copied in one
|
||||||
|
// design can be pasted after switching to another design.
|
||||||
|
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], fitViewPending: true })
|
||||||
},
|
},
|
||||||
|
|
||||||
clearFitViewPending: () => set({ fitViewPending: false }),
|
clearFitViewPending: () => set({ fitViewPending: false }),
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
|
||||||
|
|
||||||
|
describe('ipDisplay persistence', () => {
|
||||||
|
beforeEach(() => localStorage.clear())
|
||||||
|
|
||||||
|
it('defaults to false when nothing is stored', () => {
|
||||||
|
expect(readHideIp()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips true', () => {
|
||||||
|
writeHideIp(true)
|
||||||
|
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
|
||||||
|
expect(readHideIp()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips false', () => {
|
||||||
|
writeHideIp(true)
|
||||||
|
writeHideIp(false)
|
||||||
|
expect(readHideIp()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { buildMacProperty } from '../macProperty'
|
||||||
|
|
||||||
|
describe('buildMacProperty', () => {
|
||||||
|
it('returns a hidden MAC property row for a MAC', () => {
|
||||||
|
expect(buildMacProperty('aa:bb:cc:dd:ee:ff')).toEqual([
|
||||||
|
{ key: 'MAC', value: 'aa:bb:cc:dd:ee:ff', icon: null, visible: false },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns an empty array when MAC is null/undefined/empty', () => {
|
||||||
|
expect(buildMacProperty(null)).toEqual([])
|
||||||
|
expect(buildMacProperty(undefined)).toEqual([])
|
||||||
|
expect(buildMacProperty('')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Persisted client-side preference for masking IP addresses on the canvas.
|
||||||
|
// Kept in localStorage (per-user UI preference, not canvas data) so it
|
||||||
|
// survives a page reload.
|
||||||
|
|
||||||
|
const KEY = 'homelable.hideIp'
|
||||||
|
|
||||||
|
export function readHideIp(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(KEY) === 'true'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeHideIp(value: boolean): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, String(value))
|
||||||
|
} catch {
|
||||||
|
/* quota / SSR */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { NodeProperty } from '@/types'
|
||||||
|
|
||||||
|
/** Build the MAC address property row shown in the right panel.
|
||||||
|
* Hidden by default — the user opts in to showing it on the canvas card.
|
||||||
|
* Matches backend `build_mac_property`. Returns an empty array when no MAC. */
|
||||||
|
export function buildMacProperty(mac?: string | null): NodeProperty[] {
|
||||||
|
if (!mac) return []
|
||||||
|
return [{ key: 'MAC', value: mac, icon: null, visible: false }]
|
||||||
|
}
|
||||||
+128
-78
@@ -4,87 +4,133 @@ from mcp.types import Tool, TextContent
|
|||||||
from .backend_client import backend
|
from .backend_client import backend
|
||||||
|
|
||||||
|
|
||||||
|
NODE_TYPES = ["isp", "router", "switch", "server", "proxmox", "vm", "lxc", "nas", "iot", "ap", "generic"]
|
||||||
|
|
||||||
|
# Shared field schemas mirroring backend NodeBase / NodeUpdate (backend/app/schemas/nodes.py).
|
||||||
|
# create_node and update_node both expose these so the MCP is symmetric with what the
|
||||||
|
# backend already validates and stores. _dispatch forwards args verbatim, so any field
|
||||||
|
# advertised here is accepted by the backend.
|
||||||
|
_NODE_FIELDS = {
|
||||||
|
"label": {"type": "string"},
|
||||||
|
"ip": {"type": "string"},
|
||||||
|
"hostname": {"type": "string"},
|
||||||
|
"mac": {"type": "string", "description": "MAC address."},
|
||||||
|
"os": {"type": "string", "description": "Operating system / distribution."},
|
||||||
|
"status": {"type": "string", "enum": ["online", "offline", "unknown", "pending"]},
|
||||||
|
"check_method": {"type": "string", "description": "Status check method (ping, http, https, ssh, prometheus, tcp)."},
|
||||||
|
"check_target": {"type": "string", "description": "Target host/URL used by the status check."},
|
||||||
|
"services": {"type": "array", "items": {"type": "object"}, "description": "Running services detected or documented on the node."},
|
||||||
|
"notes": {"type": "string", "description": "Free-text notes / documentation for the node."},
|
||||||
|
"parent_id": {"type": "string", "description": "ID of the parent node (e.g. Proxmox host for a VM/LXC). Pass null to detach."},
|
||||||
|
"container_mode": {"type": "boolean", "description": "Render this node as a container/group that can hold children."},
|
||||||
|
"custom_icon": {"type": "string", "description": "Override icon name for the node."},
|
||||||
|
"cpu_count": {"type": "integer", "description": "Number of CPU cores/threads."},
|
||||||
|
"cpu_model": {"type": "string", "description": "CPU model name."},
|
||||||
|
"ram_gb": {"type": "number", "description": "RAM in gigabytes."},
|
||||||
|
"disk_gb": {"type": "number", "description": "Disk capacity in gigabytes."},
|
||||||
|
"show_hardware": {"type": "boolean", "description": "Display hardware specs on the node card."},
|
||||||
|
"properties": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Arbitrary key/value metadata shown on the node.",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["name", "value"],
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string"},
|
||||||
|
"value": {"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tools() -> list[Tool]:
|
||||||
|
create_node_props = {
|
||||||
|
"type": {"type": "string", "enum": NODE_TYPES},
|
||||||
|
**_NODE_FIELDS,
|
||||||
|
}
|
||||||
|
create_node_props["status"] = {**_NODE_FIELDS["status"], "default": "unknown"}
|
||||||
|
|
||||||
|
update_node_props = {
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"type": {"type": "string", "enum": NODE_TYPES},
|
||||||
|
**_NODE_FIELDS,
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
Tool(name="create_node", description="Add a new node to the homelab canvas", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["type", "label"],
|
||||||
|
"properties": create_node_props,
|
||||||
|
}),
|
||||||
|
Tool(name="update_node", description="Update an existing node", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id"],
|
||||||
|
"properties": update_node_props,
|
||||||
|
}),
|
||||||
|
Tool(name="delete_node", description="Delete a node from the canvas", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id"],
|
||||||
|
"properties": {"id": {"type": "string"}},
|
||||||
|
}),
|
||||||
|
Tool(name="create_edge", description="Create a network link between two nodes", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["source", "target"],
|
||||||
|
"properties": {
|
||||||
|
"source": {"type": "string"},
|
||||||
|
"target": {"type": "string"},
|
||||||
|
"type": {"type": "string", "enum": ["ethernet", "wifi", "iot", "vlan", "virtual"], "default": "ethernet"},
|
||||||
|
"label": {"type": "string"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Tool(name="delete_edge", description="Delete a network link", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id"],
|
||||||
|
"properties": {"id": {"type": "string"}},
|
||||||
|
}),
|
||||||
|
Tool(name="trigger_scan", description="Trigger a network discovery scan", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ranges": {"type": "array", "items": {"type": "string"}, "description": "CIDR ranges to scan (uses configured defaults if omitted)"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Tool(name="approve_device", description="Approve a pending discovered device and create a node", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id"],
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"type": {"type": "string", "enum": NODE_TYPES, "default": "generic"},
|
||||||
|
"label": {"type": "string"},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Tool(name="hide_device", description="Hide a pending discovered device", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id"],
|
||||||
|
"properties": {"id": {"type": "string"}},
|
||||||
|
}),
|
||||||
|
Tool(name="get_canvas", description="Get the full canvas: all nodes and edges in the homelab topology", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
}),
|
||||||
|
Tool(name="list_nodes", description="List all nodes (devices) in the homelab", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
}),
|
||||||
|
Tool(name="list_pending_devices", description="List devices discovered by scan but not yet approved or hidden", inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS = _build_tools()
|
||||||
|
|
||||||
|
|
||||||
def register_tools(server: Server):
|
def register_tools(server: Server):
|
||||||
|
|
||||||
@server.list_tools()
|
@server.list_tools()
|
||||||
async def list_tools():
|
async def list_tools():
|
||||||
return [
|
return TOOLS
|
||||||
Tool(name="create_node", description="Add a new node to the homelab canvas", inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"required": ["type", "label"],
|
|
||||||
"properties": {
|
|
||||||
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"]},
|
|
||||||
"label": {"type": "string"},
|
|
||||||
"ip": {"type": "string"},
|
|
||||||
"hostname": {"type": "string"},
|
|
||||||
"status": {"type": "string", "enum": ["online","offline","unknown","pending"], "default": "unknown"},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Tool(name="update_node", description="Update an existing node", inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"required": ["id"],
|
|
||||||
"properties": {
|
|
||||||
"id": {"type": "string"},
|
|
||||||
"label": {"type": "string"},
|
|
||||||
"ip": {"type": "string"},
|
|
||||||
"hostname": {"type": "string"},
|
|
||||||
"status": {"type": "string"},
|
|
||||||
"parent_id": {"type": "string", "description": "ID of the parent node (e.g. Proxmox host for a VM/LXC). Pass null to detach."},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Tool(name="delete_node", description="Delete a node from the canvas", inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"required": ["id"],
|
|
||||||
"properties": {"id": {"type": "string"}},
|
|
||||||
}),
|
|
||||||
Tool(name="create_edge", description="Create a network link between two nodes", inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"required": ["source", "target"],
|
|
||||||
"properties": {
|
|
||||||
"source": {"type": "string"},
|
|
||||||
"target": {"type": "string"},
|
|
||||||
"type": {"type": "string", "enum": ["ethernet","wifi","iot","vlan","virtual"], "default": "ethernet"},
|
|
||||||
"label": {"type": "string"},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Tool(name="delete_edge", description="Delete a network link", inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"required": ["id"],
|
|
||||||
"properties": {"id": {"type": "string"}},
|
|
||||||
}),
|
|
||||||
Tool(name="trigger_scan", description="Trigger a network discovery scan", inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"ranges": {"type": "array", "items": {"type": "string"}, "description": "CIDR ranges to scan (uses configured defaults if omitted)"},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Tool(name="approve_device", description="Approve a pending discovered device and create a node", inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"required": ["id"],
|
|
||||||
"properties": {
|
|
||||||
"id": {"type": "string"},
|
|
||||||
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"], "default": "generic"},
|
|
||||||
"label": {"type": "string"},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Tool(name="hide_device", description="Hide a pending discovered device", inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"required": ["id"],
|
|
||||||
"properties": {"id": {"type": "string"}},
|
|
||||||
}),
|
|
||||||
Tool(name="get_canvas", description="Get the full canvas: all nodes and edges in the homelab topology", inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {},
|
|
||||||
}),
|
|
||||||
Tool(name="list_nodes", description="List all nodes (devices) in the homelab", inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {},
|
|
||||||
}),
|
|
||||||
Tool(name="list_pending_devices", description="List devices discovered by scan but not yet approved or hidden", inputSchema={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {},
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
@server.call_tool()
|
@server.call_tool()
|
||||||
async def call_tool(name: str, arguments: dict):
|
async def call_tool(name: str, arguments: dict):
|
||||||
@@ -94,7 +140,11 @@ def register_tools(server: Server):
|
|||||||
|
|
||||||
def _slim_canvas(raw: dict) -> dict:
|
def _slim_canvas(raw: dict) -> dict:
|
||||||
"""Strip React Flow layout/style fields — keep only semantic data for AI use."""
|
"""Strip React Flow layout/style fields — keep only semantic data for AI use."""
|
||||||
NODE_KEEP = {"id", "type", "label", "ip", "hostname", "status", "services", "description", "parentId"}
|
NODE_KEEP = {
|
||||||
|
"id", "type", "label", "ip", "hostname", "mac", "os", "status", "services",
|
||||||
|
"notes", "description", "properties", "cpu_count", "cpu_model", "ram_gb",
|
||||||
|
"disk_gb", "parentId",
|
||||||
|
}
|
||||||
EDGE_KEEP = {"id", "source", "target", "type", "label"}
|
EDGE_KEEP = {"id", "source", "target", "type", "label"}
|
||||||
|
|
||||||
def slim_node(n: dict) -> dict:
|
def slim_node(n: dict) -> dict:
|
||||||
|
|||||||
+83
-1
@@ -1,6 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
from app.tools import _dispatch
|
from app.tools import TOOLS, _dispatch
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -32,6 +32,39 @@ async def test_update_node_parent_id(mock_backend):
|
|||||||
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {"parent_id": "proxmox-1"})
|
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {"parent_id": "proxmox-1"})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_create_node_full_properties(mock_backend):
|
||||||
|
args = {
|
||||||
|
"type": "proxmox",
|
||||||
|
"label": "pve1",
|
||||||
|
"os": "Proxmox VE 8",
|
||||||
|
"notes": "Main hypervisor",
|
||||||
|
"services": [{"name": "ssh", "port": 22}],
|
||||||
|
"cpu_count": 16,
|
||||||
|
"cpu_model": "Ryzen 9 5950X",
|
||||||
|
"ram_gb": 64,
|
||||||
|
"disk_gb": 2000,
|
||||||
|
"show_hardware": True,
|
||||||
|
"properties": [{"name": "rack", "value": "A1"}],
|
||||||
|
}
|
||||||
|
await _dispatch("create_node", dict(args))
|
||||||
|
# All extra fields forwarded to the backend unchanged.
|
||||||
|
mock_backend.post.assert_called_once_with("/api/v1/nodes", args)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_update_node_properties(mock_backend):
|
||||||
|
await _dispatch("update_node", {
|
||||||
|
"id": "42",
|
||||||
|
"os": "Debian 12",
|
||||||
|
"properties": [{"name": "role", "value": "db"}],
|
||||||
|
})
|
||||||
|
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {
|
||||||
|
"os": "Debian 12",
|
||||||
|
"properties": [{"name": "role", "value": "db"}],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_delete_node(mock_backend):
|
async def test_delete_node(mock_backend):
|
||||||
await _dispatch("delete_node", {"id": "42"})
|
await _dispatch("delete_node", {"id": "42"})
|
||||||
@@ -100,6 +133,55 @@ async def test_get_canvas(mock_backend):
|
|||||||
assert "viewport" not in result
|
assert "viewport" not in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_get_canvas_keeps_documentation_fields(mock_backend):
|
||||||
|
mock_backend.get = AsyncMock(return_value={
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n1",
|
||||||
|
"type": "proxmox",
|
||||||
|
"position": {"x": 0, "y": 0},
|
||||||
|
"data": {
|
||||||
|
"label": "pve1",
|
||||||
|
"os": "Proxmox VE 8",
|
||||||
|
"notes": "Main hypervisor",
|
||||||
|
"cpu_count": 16,
|
||||||
|
"ram_gb": 64,
|
||||||
|
"properties": [{"name": "rack", "value": "A1"}],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [],
|
||||||
|
})
|
||||||
|
result = await _dispatch("get_canvas", {})
|
||||||
|
node = result["nodes"][0]
|
||||||
|
assert node["os"] == "Proxmox VE 8"
|
||||||
|
assert node["notes"] == "Main hypervisor"
|
||||||
|
assert node["cpu_count"] == 16
|
||||||
|
assert node["ram_gb"] == 64
|
||||||
|
assert node["properties"] == [{"name": "rack", "value": "A1"}]
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_schema(name: str) -> dict:
|
||||||
|
tool = next(t for t in TOOLS if t.name == name)
|
||||||
|
return tool.inputSchema["properties"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_node_schema_exposes_full_node_fields():
|
||||||
|
props = _tool_schema("create_node")
|
||||||
|
for field in ("os", "notes", "services", "cpu_count", "ram_gb", "disk_gb", "properties", "mac"):
|
||||||
|
assert field in props, f"create_node schema missing {field}"
|
||||||
|
# type stays an enum of the canonical node types
|
||||||
|
assert "enum" in props["type"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_node_schema_exposes_full_node_fields():
|
||||||
|
props = _tool_schema("update_node")
|
||||||
|
for field in ("os", "notes", "services", "cpu_count", "ram_gb", "disk_gb", "properties", "mac"):
|
||||||
|
assert field in props, f"update_node schema missing {field}"
|
||||||
|
assert "id" in props
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_list_nodes(mock_backend):
|
async def test_list_nodes(mock_backend):
|
||||||
mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "Freebox"}])
|
mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "Freebox"}])
|
||||||
|
|||||||
Reference in New Issue
Block a user