Compare commits

..

33 Commits

Author SHA1 Message Date
Rémy f78bac6e6b Merge pull request #192 from Pouzor/chore/bump-2.4.0
chore: bump version to 2.4.0
2026-06-05 16:24:23 +02:00
Pouzor a0bfb97517 chore: bump version to 2.4.0
ha-relevant: no
2026-06-05 16:21:43 +02:00
Rémy ff9c4735e0 Merge pull request #191 from Pouzor/fix/persist-show-port-numbers
fix(nodes): persist show_port_numbers across reloads
2026-06-05 16:04:27 +02:00
Pouzor 2f3e986d4a fix(nodes): persist show_port_numbers across reloads
The Show Port Numbers toggle reset on every reload because the backend
never stored it: the column was missing from the Node model and the field
was stripped by NodeSave/NodeBase/NodeUpdate schemas, so canvas/save
silently dropped it.

Add the show_port_numbers column (idempotent migration), and the field to
the node schemas so it round-trips through save/load.

Add regression tests covering persistence and the default-false case.

Fixes #184

ha-relevant: yes
2026-06-05 15:53:58 +02:00
Rémy 0a4bac30ac Merge pull request #190 from Pouzor/fix/save-button-event-arg
fix(canvas): Save button no-op due to leaked click event
2026-06-05 15:29:51 +02:00
Pouzor ba2f6564ec fix(canvas): Save button no-op due to leaked click event
The Toolbar and Sidebar Save buttons wired onClick={onSave} directly, so
React passed the MouseEvent as the first argument. handleSave treats its
first arg as a designIdOverride, corrupting design_id and making the save
fail silently. Ctrl+S worked because it calls handleSave() with no args.

Wrap both handlers as onClick={() => onSave()} so no event leaks through.

Add regression tests asserting onSave is called with zero arguments.

Fixes #186

ha-relevant: no
2026-06-05 15:22:13 +02:00
Rémy 754a79d21f Merge pull request #189 from Pouzor/feat/cross-design-copy-paste
feat(canvas): cross-design copy/paste + persisted Hide-IP in Settings
2026-06-05 14:24:33 +02:00
Pouzor b52bbc6d9f feat(settings): move Hide IP toggle into Settings modal, persist it
Hide-IP was a sidebar button held only in memory, so it reset on reload.
Moved it into the Settings modal Canvas section and persist it to
localStorage (new ipDisplay util); the canvas store now seeds hideIp from
storage and writes through on toggleHideIp/setHideIp. Settings is now also
reachable in standalone (no-backend) builds, with the backend-only status
interval guarded so the modal still works there.

ha-relevant: yes
2026-06-05 11:26:28 +02:00
Pouzor c67b1775a5 feat(canvas): copy/paste nodes across designs
Clipboard now holds nodes + internal edges and survives design switches
(loadCanvas no longer clears it), so a selection copied in one design can
be pasted into another. Copy pulls in children of selected groups/
containers; paste remaps node/edge/parent IDs and lands the bounding-box
center under the cursor (or viewport center). Shortcut handling moved into
CanvasContainer for flow-coordinate projection.

ha-relevant: yes
2026-06-05 10:52:44 +02:00
Rémy 5940e11860 Merge pull request #188 from Pouzor/refactor/settings-modal
refactor(settings): move sidebar settings into a modal
2026-06-05 10:17:22 +02:00
Pouzor fa8f949b45 refactor(settings): move sidebar settings into a modal
Clicking Settings now opens a dialog with the status-check interval and
canvas snap options plus Save/Cancel, replacing the inline left-panel view.
Removes the 'settings' Sidebar view and SettingsPanel component.

ha-relevant: maybe
2026-06-05 10:00:39 +02:00
Rémy 6e17a2afc6 Merge pull request #187 from Pouzor/fix/edge-anim-css
refactor(edges): CSS animations for snake/flow edges (drop SVG SMIL)
2026-06-05 02:03:06 +02:00
Pouzor 2375efa6b7 refactor(edges): use CSS animations for snake/flow edges instead of SVG SMIL
Snake and flow edge animations used SVG SMIL <animate repeatCount="indefinite">.
SMIL keeps running while a tab is hidden (CSS animations pause) and grows memory
in Chrome over long-lived background tabs. Convert both to CSS @keyframes
(homelable-snake / homelable-flow), matching the existing basic-dash pattern.

Identical visuals and timings; bidirectional snake yo-yos via `alternate`.
Adds a regression test asserting animated edges emit zero <animate> nodes and a
CSS animation instead.

ha-relevant: yes
2026-06-05 01:54:54 +02:00
Rémy 61fbebb13e Merge pull request #185 from Pouzor/feat/liveview-design-param
feat(liveview): header View link opens active design as read-only canvas
2026-06-04 21:35:08 +02:00
Pouzor 05fb67e7f0 feat(liveview): header View link opens active design as read-only canvas
Add an authenticated GET /api/v1/liveview/config endpoint exposing the
configured LIVEVIEW_KEY to logged-in admins, so the UI builds a
ready-to-use share link. The header View button opens
/view?key=...&design=<activeDesignId>; LiveView forwards ?design=<id>
to the public endpoint, which renders that design's canvas.

ha-relevant: maybe
2026-06-04 14:10:29 +02:00
Rémy 1a426f6301 Merge pull request #177 from pranjal-joshi/feat/electrical
feat: Multi Design Canvas + new node type for electrical devices
2026-06-04 13:20:22 +02:00
Pouzor f56dfea838 fix(liveview): allow zoom-out to 0.25 so large infra fits
View-only mode set no minZoom, so React Flow defaulted to 0.5 and big
canvases couldn't zoom out far enough to fit. Match the editor's bounds
(minZoom 0.25, maxZoom 2.5). Add a regression test asserting the props.

ha-relevant: maybe
2026-06-04 13:00:29 +02:00
Pouzor 6784b8f6be refactor(ui): harden pending-devices keyboard handler
Address review nits on the close/Enter fix:
- Add statusFilter to the keydown effect deps so Enter dispatches the
  correct bulk action regardless of whether the device list changes
- Drop redundant title="Close" on the X (aria-label is enough)

ha-relevant: maybe
2026-06-03 01:31:50 +02:00
Pouzor 01fce64b2c fix(ui): Enter restores hidden devices instead of approving them
In the Hidden view the only bulk action is Restore, but the Enter
shortcut in select mode always called handleBulkApprove — approving
hidden devices. Dispatch on statusFilter: restore when hidden, approve
when pending. Add regression tests for both views.

ha-relevant: maybe
2026-06-03 01:09:36 +02:00
Pouzor 79093fdcfb fix(ui): close pending/hidden devices modal via the X on Firefox
The close X used a raw <button onClick={onClose}>, bypassing Base UI's
dismissal. Outside-click worked (it goes through onOpenChange) but the
synthetic click on the X was dropped on Firefox/Windows. Route the X
through Base UI's DialogClose, the same path as outside-click and the
library's default close button. Add a regression test.

ha-relevant: maybe
2026-06-03 00:59:28 +02:00
Pouzor 4ce8a12720 test: guard legacy -> multi-design migration (no data loss on upgrade)
Builds a real pre-designs database (nodes/edges without design_id, integer
canvas_state PK) and asserts init_db() adopts everything into a single
default Network Topology design with the viewport preserved, and that the
migration is idempotent across reboots. The rest of the suite uses create_all
and never exercised this in-place upgrade path.

ha-relevant: yes
2026-06-02 16:52:42 +02:00
Pouzor cc2cc72482 Merge remote-tracking branch 'origin/main' into feat/electrical 2026-06-02 16:10:46 +02:00
Rémy 73b479a408 Merge pull request #182 from Pouzor/chore/dependabot-qs-brace-expansion
chore(deps): fix Dependabot alerts (qs, brace-expansion)
2026-06-02 11:04:27 +02:00
Pouzor 16183285b5 chore(deps): bump qs to 6.15.2 and brace-expansion to 5.0.6
Fixes Dependabot alerts #1 (brace-expansion DoS) and #2 (qs stringify
DoS). Lockfile-only, transitive deps via shadcn/ts-morph/express.

ha-relevant: no
2026-06-02 10:10:04 +02:00
Rémy 3b3cc1eba0 Merge pull request #181 from Pouzor/chore/bump-2.3.0
chore: bump version to 2.3.0
2026-05-31 23:58:05 +02:00
Pouzor 3da5517996 chore: bump version to 2.3.0 2026-05-31 23:51:26 +02:00
Rémy cb26da3de5 Merge pull request #180 from Pouzor/feat/issue-174-mcp-full-node-schema
feat(mcp): expose full node schema in create_node/update_node (#174)
2026-05-31 23:14:31 +02:00
Pouzor 23a0a47a7f feat(mcp): expose full node schema in create_node/update_node (#174)
The MCP create_node/update_node inputSchema only advertised
type/label/ip/hostname/status (plus parent_id on update), so LLM
clients could not set documentation/hardware fields the backend
already validates and stores.

Mirror NodeBase/NodeUpdate by spreading a shared _NODE_FIELDS schema
into both tools: os, notes, mac, check_method, check_target, services,
cpu_count, cpu_model, ram_gb, disk_gb, show_hardware, container_mode,
custom_icon, properties. type stays an enum of canonical node types.
_dispatch already forwarded args verbatim, so no dispatch change.

Also extend _slim_canvas NODE_KEEP so get_canvas round-trips the new
documentation fields the LLM can now write.

Refactor tool definitions into a module-level TOOLS list for direct
schema assertions in tests.

ha-relevant: no
2026-05-31 22:53:00 +02:00
Rémy aac6c09a04 Merge pull request #178 from Pouzor/feat/issue-168-mac-in-properties
feat(scan): carry scanned MAC onto approved nodes (#168)
2026-05-31 16:39:50 +02:00
Pouzor bf90d6312b fix(canvas): include MAC property in approved node sent to canvas (#168)
The backend stored the MAC, but the frontend built the approved canvas
node locally with properties=[] (non-zigbee) and no mac field. On save,
canvas/save upserts every node field from the in-memory canvas, so the
empty properties overwrote the DB MAC — the address showed after a
no-save reload but vanished once saved.

Now both approve paths (single + bulk) attach a MAC property row and
the mac field to the node added to the canvas, mirroring the backend
build_mac_property. Hidden by default, toggleable from the right panel.

- new buildMacProperty util + unit tests
- bulk-approve modal test asserts MAC propagation (IP device) and
  absence for a zigbee device

ha-relevant: yes
2026-05-31 16:15:14 +02:00
Pouzor ec15c260e1 feat(scan): carry scanned MAC onto approved nodes (#168)
The network scan captures each device's MAC, but approving a pending
device dropped it: the new Node had mac=NULL and no MAC in its
properties pane. Now both approve paths (single + bulk) copy the
pending device's MAC to Node.mac and add a MAC row to the node
properties so it shows in the standard properties pane with toggleable
visibility (hidden by default, consistent with zigbee props).

- build_mac_property: MAC NodeProperty row (hidden by default)
- merge_mac_property: append MAC to user-supplied props, idempotent,
  preserves existing visibility
- single approve prefers the pending device MAC, falls back to payload

Adds unit tests for the helpers and API tests for both approve paths.

ha-relevant: yes
2026-05-31 14:47:15 +02:00
Rémy 47ab3a9a76 Merge pull request #176 from Pouzor/fix/issue-167-zigbee-pending-revival
fix(zigbee): revive orphaned approved devices on re-import (#167)
2026-05-31 14:21:24 +02:00
Pouzor e8bcf04b46 fix(zigbee): revive orphaned approved devices on re-import (#167)
When a Zigbee device was approved (PendingDevice.status=approved + Node
created) and the user later deleted the canvas Node, the PendingDevice row
was left orphaned as approved. On re-import _persist_pending_import found
the existing row, refreshed its fields but left status=approved, so it
never reappeared in the Pending list (which filters status==pending) —
the import reported devices found but Pending stayed empty.

Reset such orphaned approved rows back to pending on re-import. Hidden
devices stay hidden (explicit user intent). Adds regression tests for
both revive-approved and keep-hidden paths.

ha-relevant: yes
2026-05-31 01:54:24 +02:00
41 changed files with 1688 additions and 418 deletions
+1 -1
View File
@@ -1 +1 @@
2.2.0 2.4.0
+22
View File
@@ -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),
+35 -2
View File
@@ -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,
+7 -1
View File
@@ -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
+2
View File
@@ -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):
+1
View File
@@ -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)
+1
View File
@@ -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
+2
View File
@@ -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
+18
View File
@@ -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)
+81
View File
@@ -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"]
+134
View File
@@ -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()
+138
View File
@@ -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."""
+86
View File
@@ -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."""
+14 -14
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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)}
+10
View File
@@ -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')
+3 -1
View File
@@ -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 = {
+8 -2
View File
@@ -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()
})
})
+13 -12
View File
@@ -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()
})
})
+13 -128
View File
@@ -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)
+7 -3
View File
@@ -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()
})
})
+12
View File
@@ -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. */
+121 -14
View File
@@ -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: [] },
}) })
}) })
+115 -20
View File
@@ -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([])
})
})
+21
View File
@@ -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 */
}
}
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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"}])